On February 6, 2026, Heroku CPO Nitin Bhat confirmed what Django teams had suspected for months: Salesforce is moving Heroku into "sustaining engineering" mode — no new features, and no new Enterprise contracts for new customers. Existing apps keep running, but the roadmap is frozen. For the thousands of Django apps still on buildpacks and Procfiles, the migration question flipped overnight from whether to how, and how long.
And the first thing every team reaches for is the price table. Appliku's Django Heroku-alternative guide lays it out cleanly: a typical production Django app costs $140/month on Heroku, ~$38 on Render, ~$20–30 on Railway, ~$15–35 on Fly.io, ~$20–40 on a Hetzner box you own. Those numbers are accurate. They are also nearly useless for scoping your migration, because two Django apps with identical $140 bills can have migration costs that differ by an order of magnitude.
The framework-specific guides — Appliku's Django rundown, LearnDjango's 2026 deployment options, Qovery's Procfile-preserving migration paths — all start somewhere the price tables never go: buildpack behavior, release-phase migrations, collectstatic pipelines, Celery topology, Postgres extensions. Those framework-shaped details decide whether your exit is a weekend or a quarter. This post turns them into the inventory to fill in before you trust any invoice.
The price table is accurate — and useless without an inventory
Start with the bill everyone quotes, because it anchors what "typical" means. Appliku prices a standard production Django app — web process, Celery worker, Celery beat, Postgres, Redis — at $140/month on Heroku: three Standard-1X dynos at $25 each, Postgres Standard-0 at $50, and Redis Premium-0 at $15. The same shape lands at ~$38 on Render ($7 each for web, worker, beat, and Postgres, plus $10 Redis), ~$20–30 on Railway's usage metering, and ~$15–35 on Fly.io's pay-as-you-go machines. Our own September teardown of Heroku's sustaining mode put the frozen-Heroku version of this setup at ~$130–165/month. The ledgers agree with each other.
Now the trap: that $140 figure describes five line items, and your app's migration cost lives in the details those line items hide. Consider two Django apps, both paying exactly $140. App A serves WSGI via Gunicorn, stores media on S3, runs stock Postgres with no extensions, and has a release phase that runs migrate --noinput. App B serves ASGI with Django Channels, relies on PostGIS queries, runs beat with a custom schedule backed by django-celery-beat, and auto-runs collectstatic at build with DISABLE_COLLECTSTATIC unset. Same bill. App A migrates to Render in a weekend; App B discovers that every one of those framework choices is a separate migration workstream with its own failure modes.
Price-first scoping systematically underestimates App B, because every price table assumes the App A shape: stateless web, boring Postgres, workers that scale horizontally. Framework-first scoping inverts the order: capture the Django-shaped facts first, then read the price table against the target each fact points to. The inventory below is that capture step.
The Django-on-Heroku inventory: seven things to capture first
Walk your repo and your Heroku dashboard and write down the answers to these seven. Each one maps to a concrete target-platform decision in the next section.
1. Python and buildpack pins. Heroku's heroku/python buildpack now installs in a native uv mode from pyproject.toml plus uv.lock plus .python-version. Record your Python version, whether you lock with uv or pip, and every buildpack in order (a heroku/nodejs buildpack before Python usually means a heroku-postbuild step compiling frontend assets). The target must reproduce this build: Render and Railway use Nixpacks or Docker and will not read your buildpack list, so each buildpack becomes a Dockerfile stage or a build command you now own.
2. Procfile process topology. Copy every line, including the ones you forgot: web, worker, beat, release, clock, one-off admin types. Note which processes must run as singletons — Celery beat especially — because "just scale workers horizontally" is wrong for the scheduler. On Heroku each line is a dyno type with independent scaling; on every target below, each line becomes a separately priced or separately configured service, and the singleton constraint needs an explicit mechanism.
3. Release-phase semantics. Heroku's release: python manage.py migrate runs before the new dynos start serving, and — critically — a failed release blocks the deploy and keeps the old version live. As Qovery's migration guide puts it, the release phase is the one everyone forgets, and it bites hard. Record what your release phase runs (migrations only? collectstatic? compilemessages? cache-table setup?) and whether any deploy has ever depended on the block-on-failure behavior. Every target has a different hook with different failure semantics, and "migrations run somewhere around deploy time" is how you get a new web process talking to an old schema.
4. Static files pipeline. Heroku runs collectstatic automatically during the build unless DISABLE_COLLECTSTATIC=1 is set, and the standard setup serves the result through WhiteNoise middleware. Record: who runs collectstatic (buildpack? release phase? both — a classic double-run bug), which storage backend (ManifestStaticFilesStorage fingerprints names; S3-backed storages change the answer), and whether WhiteNoise or a CDN serves the files. Miss this and your first deploy on the new platform serves a broken admin with 404'd CSS — a classic "it deployed but it's broken" Django migration symptom.
5. User-uploaded media. Heroku's dyno filesystem is ephemeral, so any working Heroku Django app already stores uploads on S3 or equivalent via django-storages — but record the bucket, the IAM path (access keys in config vars? instance roles?), and total size. The media question is really a data-migration question: the files move separately from the code, and a 500 GB bucket turns "a weekend" into "a weekend plus a multi-day aws s3 sync with a cutover plan."
6. Postgres extensions and add-ons. List every Postgres extension (SELECT * FROM pg_extension — PostGIS, pgvector, pg_trgm, uuid-ossp) and every Heroku add-on (Scheduler, log drains, Papertrail, Redis provider and eviction policy). Extensions are the single biggest scope jump in this inventory: a stock Postgres moves anywhere in an afternoon, while PostGIS or pgvector restricts which managed databases you can target and may force a self-managed database or a different provider pick entirely.
7. ASGI vs WSGI and settings shape. Record your server command verbatim — gunicorn project.wsgi versus uvicorn project.asgi versus Daphne — plus whether anything needs websockets or sticky sessions (Django Channels does). Then record the Heroku-isms in settings: django-heroku auto-configuration, DATABASE_URL parsing, ALLOWED_HOSTS/CSRF_TRUSTED_ORIGINS values. The django-heroku package papers over a dozen settings; on the new platform each one becomes explicit config, and the ASGI answer determines whether you need websocket support verified on day one or never.
Seven answers, one page. Now the price table can mean something.
How each inventory item lands on the four targets
With the inventory filled in, each line points at a specific mechanism per target. This is the mapping the generic comparisons skip:
| Inventory item | Render ($38+/mo path) | Railway (~$20–30/mo) | Fly.io (~$15–35/mo) | Owned box (Hetzner + Dokku/Coolify/Appliku) |
|---|---|---|---|---|
| Python build | Nixpacks auto-detect or your Dockerfile; pip install in build command | Nixpacks builder; railway.json start command | Your Dockerfile via fly launch; full control | Your Dockerfile or buildpacks; full control |
| Procfile processes | One service per process type; beat as a private service or cron job | One service per process type, same project | One Machine (or app) per process; beat pinned to a single Machine | Compose services or Procfile on Dokku; systemd units |
| Release phase | Pre-deploy commands; verify block-on-failure behavior | Run commands before deploy; verify ordering | release_command in fly.toml; runs before new Machines start | Your deploy script; you define the semantics |
| collectstatic | Build command (./build.sh running collectstatic) | Build/start commands in config | Dockerfile RUN step | Build step you own |
| Media | Still S3/R2; no persistent disk on web services by default | Volumes available; S3 still recommended | Volumes available per Machine; S3 still recommended | Local disk or volumes; S3 still recommended |
| Postgres extensions | Managed Postgres; verify PostGIS/pgvector availability before committing | Managed Postgres; verify extension list | Postgres is self-managed via flyctl, not fully managed — extensions are your job | You run Postgres; any extension, your backups |
| ASGI/websockets | Supported; verify per plan | Supported | Supported via Machines + Anycast | Your reverse proxy; your config |
Three rows deserve emphasis because they flip platform picks. First, Postgres extensions: if item 6 lists PostGIS, your shortlist is "targets whose managed Postgres ships PostGIS" plus "run it yourself" — and that check happens before any price comparison, because it can disqualify the cheapest row outright. Second, the beat singleton: Celery beat must run exactly once, which is a config line on Render (a single-instance private service), a scale setting on Railway, a one-Machine constraint on Fly.io, and your problem on your own box. Third, the release phase: block-on-failure deploy semantics differ per target, so whichever you pick, your first staging deploy should deliberately fail a migration to confirm the new platform blocks the rollout rather than serving new code against an old schema.
Worked example: the same app, scoped three ways
Take the canonical app from the price tables: Gunicorn web, Celery worker plus beat, Postgres Standard-0, Redis, WhiteNoise static files. Price-first scoping says: Render, ~$38/month, one weekend — a render.yaml with five services, a build script, done.
Framework-first scoping fills the inventory first and finds: uv-locked Python 3.12 with a Node buildpack compiling Tailwind, a release phase running migrations plus collectstatic, media already on S3 (40 GB), stock Postgres with pg_trgm only, and WSGI throughout. The estimate becomes: still Render, still ~$38, but two weekends, not one. The Node buildpack step becomes a Dockerfile multi-stage build or a Render build command that installs Node first; the release phase becomes pre-deploy commands whose failure behavior gets an explicit staging test; pg_trgm gets a five-minute availability check on Render Postgres. Nothing here changes the platform pick — it changes the schedule from a guess into a plan.
Now vary the inventory answers and watch the estimate move. This sensitivity table is the artifact price-first scoping never produces:
| Inventory answer | Effort delta | Why |
|---|---|---|
| PostGIS or pgvector in use | +1–3 weeks, may change platform pick | Extension availability gates the database choice; worst case is a self-managed Postgres you now operate |
| Media on the way to S3 but 500 GB+ | +1 week, parallelizable | Bucket sync with cutover planning; bandwidth and verification dominate |
| ASGI with Channels/websockets | +days | Verify websocket + stickiness support per target; load-test the real connection pattern |
django-celery-beat database schedules | +days | Beat singleton plus schedule-table migration; duplicate-beat protection on the new topology |
| Heroku Scheduler jobs beyond the Procfile | +days | Each job becomes a cron service or external scheduler with its own deploy wiring |
| Review apps per pull request | +1 week | Render preview environments, Railway PR deploys, or Fly.io per-PR Machines each need explicit setup |
| Multiple Heroku pipelines / staging parity | +1–2 weeks | Every environment gets the full mapping above; staging drift becomes visible for the first time |
Read the rows together and "weekend or a quarter" stops being rhetoric. The weekend app is the all-boring row: stock Postgres, small media, WSGI, no review apps. The quarter app is PostGIS plus heavy media plus review-app parity plus a staging pipeline — each row individually reasonable, collectively a multi-month program once database self-management enters the picture. The inventory doesn't just estimate the work; it tells you which answers to renegotiate. Dropping PostGIS for a managed-compatible alternative, or accepting Render's preview environments instead of replicating Heroku review apps exactly, can move the estimate a full column leftward before anyone writes a line of migration code.
The rule: inventory before invoice
Heroku's sustaining mode gives Django teams no deadline but also no future: the platform runs, the roadmap doesn't. That makes this a planning problem, not a fire drill — and planning problems reward sequence. Fill the seven-item inventory, map each answer onto the target table, then read the price table. Any estimate produced in the opposite order is a guess wearing a spreadsheet costume.
If your inventory points at owned hardware — and for a Django app with PostGIS, heavy workers, or data-sovereignty needs, it often does — the migration is also the moment to stop renting dynos by the line item. Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



