Move a Heroku App: Processes, Database, and Scheduled Jobs
You can still deploy a Heroku app with Git, but Git pushes here need a Dockerfile. Config vars move to environment variables, and a web and worker process become separate apps. Inventory your database, scheduled jobs, persistent files and app URL before switching: those parts need more than a new deploy remote.
The mapping
| Heroku | Container platform equivalent |
|---|---|
git push heroku main |
git push <deploy-url> HEAD:deploy |
Procfile web: process |
Dockerfile CMD, plus an HTTPS port binding |
Procfile worker: process |
Same, with no port — that's all a worker is |
| Config vars | Environment variables |
| Buildpacks | Dockerfile for Git pushes, or build detection for a supported ZIP project |
| Ephemeral filesystem | Same by default — but you can mount a volume |
| Heroku Postgres add-on | Self-managed Postgres from a template, or an external provider |
| Heroku Scheduler | No equivalent — schedule in-process |
heroku logs --tail |
The Logs panel |
heroku run bash |
Shell in the running container; no one-off dyno equivalent |
| Eco web dyno idle sleep | No request-idle sleep while the app has credit |
Start with the process and database rows. Check how your users reach the app before the cutover: custom domains are not currently supported here, so an app that needs its existing domain cannot make a like-for-like move.
Push-to-deploy carries over
If you already deploy with git push heroku main, you can still push to a per-app deploy remote. Add a Dockerfile at the repository root first, then push a branch to it:
git push -u <deploy-url> HEAD:deploy
The build output streams back over the same connection, so a failed build shows up in your terminal rather than in a dashboard you have to go find. There's no GitHub connection or OAuth grant involved — details here.
One difference worth noting: Heroku's own docs describe its git remote as a deploy mechanism rather than a place to keep your code, and the same is true here — this remote is push-only, so keep your repository wherever it already lives.
Procfile → Dockerfile, and what a "worker" really is
A Procfile declares process types. In a container the equivalent is your image's command:
web: gunicorn app:app --bind 0.0.0.0:$PORT
becomes
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8080"]
Note 0.0.0.0 — Heroku's $PORT convention already trained you into this, and it stays essential. A server bound to localhost inside a container is unreachable, and it fails as a successful deploy with a 502 rather than a build error.
A worker: process becomes an app with no port binding. Bots, queue consumers, and pollers can run this way. The platform restarts a process that exits, but does not detect a worker that stays alive while failing to do its job; monitor that behavior yourself.
If your app has both a web and a worker process, deploy two apps here. Each has its own CPU, RAM and disk allocation and contributes to the bill.
Config vars → environment variables
A direct swap: same names, same values, set on the app instead of via heroku config:set. Your code doesn't change.
Two things to do while you're in there:
- Rotate anything sensitive. A migration is the natural moment, and credentials that have lived in a platform for years have usually been pasted somewhere they shouldn't.
- Drop the Heroku-specific ones.
DYNO,PORTif you've hard-coded your own, anything an add-on injected.
DATABASE_URL deserves its own decision — see below.
The ephemeral filesystem, except you can fix it
You've already designed around this. Heroku's docs are explicit that each dyno gets its own ephemeral filesystem and anything written to it is discarded when the dyno stops or restarts, including automatic restarts (Heroku Dev Center). So your uploads went to S3 and your data went to Postgres, because there was no other option.
Container filesystems behave the same way by default — the writable layer goes when the container is recreated, which happens on every redeploy. The difference is that here you can mount a volume and get genuine persistence on a path you choose. SQLite on a volume is a real option for a small app, which it never was on a dyno.
That's the one place where the migration can simplify your architecture rather than just relocate it: an app that was using Postgres purely because the filesystem wasn't durable might not need it any more. How volumes work.
Database and scheduling changes
Managed Postgres. Heroku Postgres features, including backup and recovery options, vary by plan (Heroku Postgres backups). You can run Postgres from a template or use an external database provider. A self-managed database needs its own allocation, persistent volume and tested off-platform backups.
There is no private networking between apps here. If a separate app connects to your database, the bound database port is public. Use strong credentials and encrypted connections where supported, and check whether that exposure fits your requirements.
Heroku Scheduler. There's no cron service here. Move scheduled jobs into a long-running process using APScheduler, node-cron, or equivalent, or keep an external scheduler. A job cannot run inside an app while that app is stopped. The trade-offs.
What gets better
- No traffic-based sleeping. Heroku's Eco web dynos sleep after 30 minutes without web traffic and wake on the next request (Heroku Dev Center). Apps here run continuously — there is no idle policy to work around, which matters most for anything holding a long-lived connection.
- Resource-based pricing. Allocate the CPU, RAM, and disk your app needs and pay the published rates per minute, with no separate per-app charge.
- Volumes, as above.
A migration order that works
- Get it running first. Deploy the code, set environment variables, and test startup against a copy of the database. Avoid writes to the production database from both hosts during testing.
- Move the data. Plan a write freeze or another consistent cutover, export Heroku Postgres, restore to the destination, and repoint
DATABASE_URL. Test the restored data before accepting writes. - Move scheduled jobs into the app process.
- Add volumes for anything that should now persist.
- Change the app URL in clients and integrations if you can use the platform hostname. Custom domains are not currently supported. Keep Heroku running until you have tested the new URL and integrations.
Keep a rollback copy of the database and a clear decision point for where new writes go. Reversing a database cutover requires more than pointing traffic back.
FAQ
Do I need a Dockerfile to move off Heroku?
Not for a supported ZIP project: upload the archive and test the detected build and start command. Git push builds need a Dockerfile at the repository root.
What replaces my Procfile?
Your image's start command. A web: process becomes an app with an HTTPS port binding; a worker: process becomes an app with no port at all.
What happens to my config vars?
They become environment variables with the same names, so your code is unchanged. Rotate the sensitive ones during the move.
Is there a managed Postgres?
No. You can run Postgres from a template or use an external provider. A self-managed database needs a volume, resources, security configuration and your own backups.
What replaces Heroku Scheduler?
An in-process scheduler inside a long-running app. There's no cron service, so a job can't fire while the app is stopped.
Will my app sleep like an Eco dyno?
An app with credit does not sleep because of inactivity. A new account's short first-app trial ends by stopping the app unless credit is added.
Wrapping up
Move the deploy and configuration first, then test the database, scheduled work and integrations before directing users to the new app. If you need your existing custom domain, keep it on a host that supports it until that requirement changes.