Heroku Went to 'Sustaining Engineering' in February. Move Your App Off It in 7 Steps

- Book: Ship It — the pragmatic startup tech stack
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
On February 6, 2026, Heroku posted that it is "transitioning to a sustaining engineering model focused on stability, security, reliability, and support," with the emphasis on operational quality "rather than introducing new features." The same post says there is "no change for customers using Heroku today." Credit card customers keep their pricing, billing and service. Existing Enterprise contracts are honored, and new customers can no longer sign one.
Nothing is shutting down, and the post names no end date. Some releases still land, too, such as Postgres Advanced going generally available on October 6, 2026. Simon Willison read the announcement as "they'll keep the lights on and that's it," and wrote that he'd "better migrate" his blog, the last project he runs on Heroku, probably to Fly.
No deadline is the best setup a migration can have, because you get to rehearse. The seven steps below work whether the destination is a VPS running Docker Compose, a managed container platform, or Kubernetes: anything that runs a container image and hands you a Postgres connection string. The examples assume an app called my-app with a web and a worker process and a Heroku Postgres database.
Steps 1 and 2: write down everything Heroku does for you
Step 1: inventory. A git push to Heroku hides a lot of moving parts. List them with the Heroku CLI before you build anything:
heroku addons -a my-app heroku ps -a my-app heroku buildpacks -a my-app heroku domains -a my-app heroku pg:info -a my-app heroku addons lists every add-on and attachment. Each one is a service you either replace or keep calling from the new host: Postgres, Redis, mail, log drains. heroku ps gives you the dyno formation, which becomes your process list and replica counts. heroku domains prints each custom domain with its herokudns.com DNS target (custom domains docs). pg:info shows the Postgres version. Write it down, because it decides what you provision in step 4.
Heroku Scheduler needs its own line on the list. Its jobs live in the add-on dashboard, not in your repo (Scheduler docs). Open it with heroku addons:open scheduler -a my-app and copy every command and frequency into the cron your new host offers. Daily times there are in UTC.
Step 2: export the config.
heroku config --shell -a my-app > heroku.env chmod 600 heroku.env --shell prints config vars "in shell format" (CLI reference): one KEY=value per line. In the CLI source, a value with shell-special characters gets single quotes, and a value containing a single quote or a newline gets double quotes with escapes. That file now holds every secret the app has. Keep it out of git and out of chat tools.
Then go through it line by line. Add-ons set some of these vars themselves, and the provider can update those values "at any time" (config vars docs). Delete DATABASE_URL, REDIS_URL and anything else owned by an add-on you are replacing, and set them again once the new service exists. Your own API keys and feature flags carry over as they are.
Step 3: turn the Procfile into an image
Your Procfile already describes your processes. Each line is <process type>: <command>, web is the only type that receives HTTP traffic from Heroku's routers, and release runs during the release phase (Procfile docs). Say yours looks like this:
release: npm run migrate web: node server.js worker: node worker.js You have two routes to an image.
Keep the buildpacks. Heroku publishes Cloud Native Buildpacks builder images, and heroku/builder:26 is the recommended tag as of October 2026 (cnb-builder-images). pack builds an image from the same source you push to Heroku:
pack build my-app --builder heroku/builder:26 docker run --rm -e PORT=8080 -p 8080:8080 my-app docker run --rm --entrypoint worker my-app The Procfile buildpack "replicates the behavior of Procfile from builds on the Heroku Platform" (buildpacks-procfile). To run a non-default process type from a buildpack-built image, you set the type as the container entrypoint (buildpacks.io). This is the shortest path when your app leans on buildpack behavior you never had to look at.
Write a Dockerfile. More control, and no builder version to track. For a Node app:
FROM node:24-slim WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev COPY . . ENV PORT=3000 EXPOSE 3000 CMD ["node", "server.js"] Build it from the repo root with docker build -t my-app .. Each Procfile line then maps to one entry in your host's config. With Compose:
services: web: image: my-app:latest env_file: heroku.env ports: - "3000:3000" worker: image: my-app:latest env_file: heroku.env command: ["node", "worker.js"] 
Each Procfile line becomes a process on the new host: web gets the port, worker gets a command override.
Compose's env_file format reads single-quoted values literally, which matches what the Heroku CLI emits for any var it single-quotes. Check every double-quoted value by hand, since Compose applies interpolation and escapes to those. The release line has no Compose equivalent, so run it as a one-off before each rollout: docker compose run --rm web npm run migrate. The Dockerfile sets PORT, so code written to bind $PORT on Heroku keeps working. This Compose file assumes the Dockerfile image. For a pack image, use entrypoint: worker instead of command, and add PORT: 3000 under environment.
Steps 4 and 5: rehearse the Postgres move, then count rows
Step 4: dump and restore. Provision the new Postgres on the major version pg:info reported, or a newer one. The pg_dump docs say its output "can be expected to load into PostgreSQL server versions newer than pg_dump's version," while loading into an older major version is not guaranteed. Then:
heroku pg:backups:capture -a my-app curl -o latest.dump "$(heroku pg:backups:url -a my-app)" pg_restore --verbose --no-owner --no-acl \ --jobs=4 -d "$NEW_DATABASE_URL" latest.dump pg:backups:url returns a link to the latest backup that "expires after 60 minutes" (Heroku backups docs), so download with curl right away. Downloading to disk also matters for speed: pg_restore only runs --jobs in parallel when the input is a regular file, not a pipe. --no-owner skips the ownership statements, which fail unless you connect as a superuser or as the role that owned the objects. --no-acl skips the grants. Heroku's import/export guide uses the same two flags and adds one fix: if the restore complains about the heroku_ext schema, run CREATE SCHEMA heroku_ext; on the target first.
The same page sets a limit on this method: use it "only for moderately loaded databases up to 20 GB," since the backup can time out on bigger or heavily loaded databases, or ones with many schemas or large objects. Past that size, follow Heroku's logical backups guide instead. Capturing a backup adds load to the database while it runs, per the backups docs, so rehearse off-peak. Time the whole thing. That number is your downtime estimate for step 7.
Step 5: count the rows. By default pg_restore keeps going after an error and reports a count at the end, so a restore that "finished" can still be missing a table. Count every table on both sides with one query:
-- counts.sql: exact row count for every table SELECT table_schema || '.' || table_name AS tbl, (xpath('/row/n/text()', query_to_xml(format( 'SELECT count(*) AS n FROM %I.%I', table_schema, table_name), false, true, '')))[1]::text::bigint AS n FROM information_schema.tables WHERE table_type = 'BASE TABLE' AND table_schema NOT IN ('pg_catalog', 'information_schema') ORDER BY 1; OLD_DB="$(heroku config:get DATABASE_URL -a my-app)" psql "$OLD_DB" -At -f counts.sql > before.txt psql "$NEW_DATABASE_URL" -At -f counts.sql > after.txt diff before.txt after.txt && echo "counts match" These are exact count(*) results, not the estimates in Postgres statistics views, so on big tables psql will take a while. During a rehearsal the source still takes writes, so a small drift on busy tables is expected. A table missing from after.txt is not. On cutover day the diff has to come back empty.

Same tables, same counts on both sides, or the cutover doesn't happen.
Steps 6 and 7: lower the TTL, then cut over
Step 6: lower DNS TTL days ahead. Resolvers cache a record for as long as its TTL says. Heroku's custom domains docs note that DNS changes "can take between several minutes and several days to take effect" and that lowering the TTL ahead of time minimizes, but does not eliminate, that delay. Drop the TTL on the records that point at herokudns.com to a few minutes, then wait at least the old TTL before cutover so caches pick up the short value. Find out how the new host issues TLS certificates for your domain now, not on cutover day.
Step 7: cut over behind maintenance mode.
heroku addons:open scheduler -a my-app # remove each job heroku ps -a my-app # note the formation heroku maintenance:on -a my-app heroku ps:scale worker=0 -a my-app heroku pg:backups:capture -a my-app curl -o final.dump "$(heroku pg:backups:url -a my-app)" pg_restore --clean --if-exists --no-owner --no-acl \ --jobs=4 -d "$NEW_DATABASE_URL" final.dump Maintenance mode serves a static page to every visitor and Heroku's routers block HTTP requests to web dynos. It does not change the dyno formation: workers keep running and Scheduler jobs can still fire (maintenance mode docs). That's why the block removes the Scheduler jobs first (you copied them in step 1) and scales workers to zero. The same page suggests scaling to zero during maintenance to stop database transactions. It also carries two warnings: don't scale web=0 in Private Spaces, and maintenance mode is not yet available for Fir-generation apps, so on Fir you need another way to stop writes.
--clean --if-exists drops what your rehearsal created before recreating it. Run the step 5 counts again. Nothing writes to the source now, so the diff must be empty. Start web and worker on the new host, smoke-test through the host's own URL, and only then point DNS at it.
Decide your go or no-go before the DNS flip. Until then, rolling back is heroku maintenance:off and scaling the workers back up to the formation you noted. Once the new database takes writes, rolling back means moving data the other way.
After the cutover
Leave the Heroku app in maintenance mode for a few days so stragglers on old DNS caches see a maintenance page instead of a second live copy of your app. Keep final.dump somewhere safe. Then remove the add-ons you replaced, delete the app, and delete every DNS record that still points at herokudns.com. Heroku's custom domains docs warn that records left behind after deleting an app can expose you to subdomain takeover.
None of this is urgent. Heroku says it remains "an actively supported, production-ready platform." The step 4 rehearsal is still the part worth starting this week, because its timing is the number every later decision depends on.
If this was useful
Moving hosts is one slice of a bigger question every small team hits: what should sit between localhost and production, and how much of it you want to run yourself. Ship It is my answer to that, covering hosting, deploys, payments, auth, CI/CD and monitoring for a team that would rather ship product than babysit infrastructure. If this post made you rethink where your app lives, the book walks through the rest of the stack the same way.
