Before a Heroku exit, account for the application’s last accepted write, pending jobs and existing sessions on the destination. A successful deploy does not establish any of those things.
Heroku’s 6 February 2026 announcement said, “Today, Heroku is transitioning to a sustaining engineering model focused on stability, security, reliability, and support.” It did not announce a platform shutdown. Heroku Ruby engineer Richard Schneeman also clarified that distinction in a personal comment.
There is a specific stack deadline. Heroku-22 reaches end of life on 30 April 2027; builds stop on 1 May. Existing applications are not automatically switched off. Facts below were checked on 7 September 2026.
Staying can be the right engineering decision
An application with a supported runtime, suitable operational controls and a tested recovery procedure can reasonably stay. List the requirement that Heroku cannot meet, then compare that gap with the work of operating the destination. Include who responds to failed backups, expired certificates and a stalled deployment.
Heroku-26 became generally available on 20 May 2026, while Heroku-24 remains the documented default. Assess the stack’s Ruby support and native packages against the application’s lockfile. A supported stack move can resolve an operating-system deadline without relocating the database or replacing Sidekiq.
The end of free services on 28 November 2022 and the February announcement concern different decisions. Neither establishes that a particular application should leave today. If its restore fails, or nobody owns the destination’s on-call work, postponing the exit is a defensible outcome.
Prove the database restore before estimating the window
PGBackups guidance covers moderately loaded databases up to 20 GB. That is operating guidance, not a hard archive-size limit. For larger or busy databases, Heroku recommends considering a direct logical dump from a short-lived fork, rather than burdening the primary or a follower. PGBackups is not currently supported on Postgres Advanced.
For an eligible Classic database, the documented capture/download path is heroku pg:backups:capture followed by heroku pg:backups:download, with the source app and database selected explicitly. Those account operations were not run for this article. The executed fallback was a local PostgreSQL 15.17 source and a disposable destination on the same major version.
The rehearsal used 5,000 synthetic records. In the companion’s configured local shell, these commands make a custom archive and restore it with two workers. The destination database is disposable; --clean removes objects that the archive will recreate. Never point this example at a live destination.
set -eu
pg_dump --version
pg_restore --version
pg_dump --format=custom --verbose \
--dbname=ci69_source --file=tmp/source.dump
pg_restore --clean --if-exists --no-owner --no-acl \
--exit-on-error --jobs=2 --verbose \
--dbname=ci69_restored tmp/source.dump
Check both server versions as well. PostgreSQL documents which source versions a dump client can read; restoring into an older major is not a supported downgrade method. --no-owner --no-acl omits original ownership and grants, so establish and test destination roles separately.
Run the same inspection against source and restore. These queries cover rows, the next identity value, indexes and extension placement. They are the small fixture’s checks, not an automatic inventory of an arbitrary application.
SELECT count(*) FROM records;
SELECT last_value, is_called FROM records_id_seq;
SELECT max(id) FROM records;
SELECT indexrelid::regclass, indisvalid, indisready
FROM pg_index WHERE indrelid = 'records'::regclass;
SELECT extname, extversion, extnamespace::regnamespace
FROM pg_extension ORDER BY extname;
SELECT datcollate, datctype, datcollversion,
pg_database_collation_actual_version(oid)
FROM pg_database WHERE datname = current_database();
The single local run measured 0.051 seconds for the dump and 0.041 seconds for restoration. The source occupied 10,214,759 bytes. These timings describe this small fixture, not a production outage estimate.
The download also compares ordered content fingerprints. They caught a changed value even though the row count stayed at 5,000. A deliberately stale sequence failed a separate check. Run ANALYZE after restoration, then use read-only application checks and verify actual destination permissions.
Extension availability includes version and namespace, not just the gem in the lockfile. Inventory PostGIS, vector, pg_stat_statements, pgcrypto, uuid-ossp, citext and hstore where used. Heroku required heroku_ext from August 2022, then removed the requirement for existing databases in August 2023. The current extensions page uses public by default. Existing extensions can retain their original schemas. The fixture deliberately placed citext in heroku_ext. After restoration, the unchanged Rails lookup for ref-000001 failed against the stored REF-000001. Adding public, heroku_ext to the application connection’s search path made it pass. PostgreSQL documents that missing citext operators cause case-sensitive comparison. Only add trusted schemas with controlled CREATE privileges. Correct rows and types had not established correct application behavior.
A fresh logical restore builds new indexes under the destination’s collation rules. Different rules can change ordering or uniqueness and can make restoration fail. That differs from retaining old index files after an operating-system collation update. For a recorded/actual version mismatch, PostgreSQL requires affected objects to be rebuilt before refreshing the recorded version. The C-locale fixture does not reproduce a glibc or ICU change.
Do not promise a universal replication shortcut. Heroku’s current Postgres comparison lists managed logical replication as unsupported for Classic and planned for Advanced. A separate Kafka streaming connector provides an outbound path for eligible databases. Neither proves that this application’s destination supports a complete, lossless cutover.
Redis and scheduled work need their own inventory
Stopping web requests does not stop webhook consumers, schedulers or other writers. Stop producers first, finish in-flight work, and only then stop workers. Heroku maintenance mode controls incoming router traffic on Cedar; it is unavailable on Fir. It is not a database write lock and does not stop Scheduler.
| State | Decision before cutover |
|---|---|
| Sidekiq queues, retries and scheduled jobs | Preserve payloads and their execution times, or record how each will complete. A zero queue length ignores retries, scheduled work and busy workers. |
| Redis cache, sessions and counters | A rebuildable cache can warm again. Losing sessions or rate-limit counters changes behavior and needs an explicit decision. |
| Action Cable pub/sub | Plan reconnects and missed notifications. Pub/sub is not a durable job archive. |
| Active Storage | Transfer or retain both blobs and database references. Test download and upload permissions separately. |
| Scheduler and add-ons | Record command, cadence, timezone, owning app, shared attachments and provider export/retention terms. Give each recurring task one active scheduler. |
The companion’s Sidekiq API probe reads queues, retries, scheduled jobs and WorkSet busy counts. It was tested with Redis writes denied. It avoids ProcessSet‘s default cleanup behavior. Heartbeat information can lag, and the reads are not atomic; even zero counts, including the dead set, cannot prove that every producer has stopped.
Run the check from the companion directory with its connection configuration. It observed one busy, queued, retry and scheduled job. After the synthetic entries were cleared and the active job finished, a deliberately late producer made the check fail again. Clearing fixture entries is not a production drain procedure.
require "json"
require_relative "queue_connection"
queues = Sidekiq::Queue.all.to_h { |queue| [ queue.name, queue.size ] }
counts = {
busy: Sidekiq::WorkSet.new.size,
queued: queues.values.sum,
retry: Sidekiq::RetrySet.new.size,
scheduled: Sidekiq::ScheduledSet.new.size,
dead: Sidekiq::DeadSet.new.size
}
puts JSON.pretty_generate(counts: counts, queues: queues,
observation: "not atomic; busy follows worker heartbeats; intake stop is not verified")
exit(counts.values.all?(&:zero?) ? 0 : 1)
The recurring summary command ran twice for the same reporting date and retained one summary row. That tests repeat handling; no persistent scheduler or Heroku Scheduler job was installed.
Rails 8.0’s 2024 release introduced Solid components as defaults for new applications. Moving existing Sidekiq payloads to Solid Queue, or Redis semantics to Solid Cache/Cable, is a separate migration. Keep those backends fixed during the hosting rehearsal.
Move configuration according to its job
A config export is an inventory containing secrets. Keep the actual export in restricted storage and out of logs, tickets and this public example. Classify names before assigning destination values.
| Group | Examples and treatment |
|---|---|
| Application settings to review and carry | Feature flags, locale and asset configuration. Verify the intended production value. |
| Credentials whose continuity matters | Deliver RAILS_MASTER_KEY securely and preserve the effective SECRET_KEY_BASE. Plan any rotation separately. |
| Destination-specific connections | Database/Redis URLs, storage permissions, SMTP and callback URLs. Provision and test replacement credentials. |
| Platform and build settings | PORT, process counts, logging, static-file serving and memory tuning. Re-establish each behavior in the target runtime. |
Rails 5.2 introduced encrypted credentials in 2018; 6.0 added environment-specific credentials in 2019. The master key decrypts that file. The effective signing secret helps derive keys for cookies and other signed/encrypted data. Copying one while silently generating the other can invalidate existing sessions.
A synthetic Rails CookieStore session survived with the same signing key and became unreadable after that key changed. Separately, copied encrypted content decrypted with its preserved key and rejected a changed key. These checks establish the two different continuity requirements; they do not test an existing application’s sessions.
Rails 8.1 added credentials:fetch for retrieving a value from the encrypted store, including deployment secrets. This is a CLI improvement, not a new reason to rotate keys during a hosting move. It was not used in the fixed Rails 8.0 rehearsal.
Recreate deployment and proxy behavior
Heroku’s 2011 Cedar design made the process model and declared processes central to deployment. Rails 7.1’s generated Docker files in 2023 and 8.0’s Kamal 2/Thruster defaults make a different deployment path available. They do not reproduce an existing Procfile, release phase or Preboot arrangement automatically.
Compare classic buildpacks with Cloud Native Buildpacks explicitly. The latter document RAILS_LOG_TO_STDOUT, RAILS_SERVE_STATIC_FILES and MALLOC_ARENA_MAX=2; do not assume identical injection across generations. Record the actual Ruby/Bundler versions, native libraries, asset build, stdout logging, static-file handling and worker concurrency. PORT is a platform contract. Carrying a historical MALLOC_ARENA_MAX value is not a substitute for measuring memory in the destination image.
Recreate the release phase, generally available since 2017, and readiness behavior deliberately; do not run migrations concurrently in every web/worker startup. Heroku documents a 30-second initial response limit and a rolling 55-second window. Verify generation-specific routing behavior and the destination proxy’s streaming and timeout settings.
At TLS termination, verify forwarded scheme, request IDs and the Rails proxy settings. Rails 7.1 added assume_ssl for a verified TLS-terminating proxy; it does not install a certificate. Rails 6.0 added host authorization, but an empty config.hosts list disables that check. Update the allowlist when enabled. Trusted proxies govern client-IP interpretation. Test redirects, secure cookies and generated URLs. Inventory DNS, certificate renewal, SSO callbacks and outbound IP allowlists before switching traffic.
Rehearse the last write and the first new write
- Before the window, restore a recent copy and time capture, transfer, restore and verification. Exercise one recurring command and record the owner of each check. Confirm certificates and external callbacks.
- At the write freeze, stop every producer and scheduler, drain accepted work, then stop workers. Confirm no remaining writer. Take the final capture and retain its identity.
- Before destination writes, restore, inspect rows/content/sequences/indexes/extensions, refresh statistics and smoke-test reads. Keep the old application frozen and its add-ons intact.
- At traffic switch, permit one destination writer and one scheduler. Watch errors, queue age, database connections and application-level reconciliation.
- If rollback becomes necessary, determine whether the destination accepted writes. After that point, DNS reversal alone loses or divides data. Use a rehearsed reverse-sync/reconciliation procedure, or explicitly accept the identified loss.
The write-freeze window includes all these operations, not just pg_restore. Local restore speed cannot estimate network transfer, a busy production database or external callback changes. Keeping the old app is useful only when its data state is understood. Review add-on ownership before deleting anything.
What to do this week
- Record the concrete reason to leave, or the supported-stack work needed to stay.
- Restore a representative copy and prove that your checks reject a stale sequence and changed data.
- Inventory every Redis role, recurring task, secret and external callback with an accountable owner.
- Rehearse the write freeze and the rollback decision without changing Rails or queue backends in the same operation.
Reproduce the Heroku exit checks
Download the reproducible Heroku exit checks. The AI-assisted example and review used Ruby 3.4.10, Rails 8.0.5.1, PostgreSQL 15.17, Sidekiq 7.3.10 and Redis 8.0.2 on macOS. All records and keys are synthetic. It tests local behavior, with no Heroku account, production data or remote cutover. The archive includes commands, SQL, configuration classification and expected failures. The fixed versions describe the experiment, not a production upgrade recommendation.
For framework work, the Rails upgrades guide and support calendar describe separate decisions. The Rails engineering library collects the related investigations.
Sources checked on 7 September 2026 include the Heroku announcement, stack deadline, backup guidance, PostgreSQL restore reference, Sidekiq API and Rails configuration guide. Cutover ordering is engineering advice based on those facts and the application’s actual writers.