Heroku's February 6, 2026 announcement that it was moving to a "sustaining engineering model" changed nothing about your running app that day. Dynos kept serving traffic, Postgres kept accepting connections, and the bill arrived on schedule. That's exactly the trap: a platform in sustaining mode doesn't break on the day of the announcement, it breaks quietly, months later, when a language runtime your buildpack doesn't support ships, or a CVE patch takes three weeks longer than it would have a year earlier.
Most of the coverage since then ranks Heroku alternatives on price. Almost none of it shows the actual mechanics of leaving — how a Procfile becomes a deployable manifest somewhere else, which of your thirty-line add-on invoice you replace versus keep versus just lose, how to move config vars without a secret ending up in a shell history file, and how to rehearse the database cutover so the outage window is minutes, not an afternoon. This is that playbook, worked against a real dyno formation. The bill at the end goes from $226/month on Heroku to roughly $12/month in owned hardware for the same workload — with the caveats that number needs, because a hardware line by itself is a marketing trick, not an answer.
Procfile to buildpack: the same three process types, a different file
Heroku's Procfile is the smallest part of this migration and the part every guide gets right, so start there to build confidence before the harder steps. A typical Rails app's Procfile declares three process types:
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq
clock: bundle exec clockwork clock.rbAny git-push PaaS that speaks Render's manifest shape (bex, and Render itself) maps these onto typed services in a repo-committed YAML file instead of a bare-named process list. The mapping is direct — Heroku's web/worker/clock process types become type: web / worker / cron services with the same start commands, and the pattern generalizes to any process type you've named that isn't web:
services:
- type: web
name: shop
runtime: docker
repo: https://github.com/acme/shop
branch: main
buildCommand: bundle install
startCommand: bundle exec puma -C config/puma.rb
healthCheckPath: /healthz
envVars:
- key: DATABASE_URL
fromDatabase:
name: shop-db
property: connectionString
- type: worker
name: shop-worker
runtime: docker
repo: https://github.com/acme/shop
startCommand: bundle exec sidekiq
- type: cron
name: shop-clock
runtime: docker
repo: https://github.com/acme/shop
schedule: "*/10 * * * *"
startCommand: bundle exec clockwork clock.rb
databases:
- name: shop-db
plan: starterThree details make this less mechanical than it looks:
- Heroku's
releasephase (the one-off dyno that runsrails db:migratebefore a new release goes live) doesn't map to a service type — it's a build- or deploy-time hook, not a long-running process. Wire it as a pre-deploy step in your build command or CI pipeline instead of trying to force it into a fourth service. - Buildpacks vs. Dockerfile: Heroku's Cedar buildpacks (the automatic language detection that turns a bare
bundle installinto a running app with noDockerfile) run unmodified as abuildCommandon most Render-shaped platforms. If you were already usingheroku.ymlwith a container, pointruntime: dockerat the sameDockerfileand skip buildpack detection entirely — you've already done the harder migration. - Scaling:
heroku ps:scale web=2becomes anumInstancesor replica count in the same manifest, so your formation survives the move as a diff, not a redesign.
This step takes an afternoon for a typical three-process app. The next one takes longer, because it forces a decision about every line on your invoice.
Every add-on, sorted into three buckets
Heroku's add-on marketplace is the single biggest thing this migration takes away, and pretending otherwise wastes your first week on the new platform hunting for a one-click equivalent that doesn't exist. Every add-on you're running falls into exactly one of three buckets — sort your invoice into them before you touch a line of infrastructure code:
| Bucket | What it means | Typical examples |
|---|---|---|
| Becomes an owned service on the same fleet | You run the open-source equivalent yourself, on hardware you already pay for, with no new SaaS invoice | Heroku Postgres → managed Postgres on your own cluster; Heroku Data for Redis → Valkey (Redis-compatible) on the same fleet |
| Stays external SaaS — and should | The add-on's value is a network effect or reputation asset you cannot replicate by self-hosting, so you keep paying for it directly instead of through Heroku's markup | Transactional email (SendGrid, Postmark) — IP reputation and deliverability take months to build and are not worth rebuilding for a mid-size app; hosted error tracking (Sentry) if your team already lives in its UI |
| Has no real equivalent and just goes away | Heroku-specific conveniences that were never really "add-ons" so much as platform features, and the honest answer is you lose them | The one-click marketplace itself (200+ add-ons provisioned with zero config); per-pull-request review apps; Private Spaces' fully managed network isolation tier |
The first bucket is where the actual savings live, and it's worth being specific about why. Heroku Postgres and Heroku Data for Redis are Heroku's own managed wrappers around Postgres and Redis — the underlying software is the same open-source project you'd run yourself, with Heroku's margin sitting on top of the provisioning and backup automation. A Render-shaped platform that ships managed Postgres (via CloudNativePG) and managed Valkey as first-class resources in the same manifest gives you that same provisioning-and-backup automation without the markup, because you're paying for the hardware once rather than for hardware plus a managed-service fee on top of it.
The second bucket is the one every "just self-host everything" migration guide gets wrong. Email deliverability is not a technology problem you solve by standing up your own SMTP relay — it's a reputation problem that takes months of consistent sending volume and complaint-rate history to build, and a self-hosted mail server with no sending history will land in spam folders regardless of how correctly you configure SPF and DKIM. Keep paying SendGrid or Postmark directly; you're not losing anything by leaving Heroku's marketplace markup behind since you can wire the same SaaS in as an external service either way.
The third bucket needs the same honesty. A self-hosted platform that claims parity with Heroku's marketplace breadth or its per-PR review apps is lying to you, and it's worth naming that plainly rather than discovering it mid-migration: Heroku's 200+-add-on Elements marketplace and disposable review-app-per-branch workflow are genuine strengths of a managed platform with a decade of ecosystem investment behind it, and the honest trade you're making is giving up that breadth for owning the hardware underneath everything that's left. If your team leans heavily on review apps for every pull request, budget time to stand up a branch-based preview workflow by hand — it's not automatic anywhere outside Heroku.
Moving config vars without a leak window
heroku config:set KEY=value writes to Heroku's own encrypted store, and the temptation during migration is to heroku config the whole list to a terminal and retype it somewhere else — which is exactly how a database password ends up in .bash_history, a Slack message, or a CI log. The safer pattern pipes the export directly into the new platform's secrets API without an intermediate step a human or a shell history file can capture:
# Pull every config var as JSON, straight into the new platform's env-vars API —
# no plaintext file, no shell history entry, no clipboard
heroku config --json --app shop | jq -r '
to_entries[] | "{\"key\":\"\(.key)\",\"value\":\(.value | tojson)}"
' | while read -r var; do
curl -sf -X PUT "https://api.bex.co/v1/services/shop/env-vars" \
-H "Authorization: Bearer $BEX_TOKEN" \
-H "Content-Type: application/json" \
-d "[$var]"
doneTwo things matter more than the exact syntax here. First, never paste secret values into the new platform's committed manifest — a bex.yml or render.yaml file lives in git, and a database URL in that file is a leaked credential the moment the repo is cloned anywhere, including your own CI runners. Literal, non-secret configuration goes in the manifest; anything that looks like a password, key, or connection string goes through the env-vars API only. Second, rotate every credential you moved this way once the migration is confirmed working — not because the pipe above leaked anything, but because any config var that transited two platforms during a migration window is a reasonable one to treat as burned, and rotation is cheap insurance against a mistake in step one you haven't noticed yet.
Rehearsing the database cutover: the only step with real downtime
Everything above can happen with your Heroku app still serving live traffic. The Postgres cutover cannot — some window of write-unavailability is unavoidable with a pg_dump/pg_restore migration, and the entire point of rehearsing it is making that window minutes instead of an afternoon spent debugging a restore failure with production down. Here is the sequence, in order, with the failure modes at each step:
- 48 hours ahead: drop your DNS TTL. If your app's DNS currently has a TTL of an hour or more, drop it to 60 seconds now. A DNS change made the moment you're ready to cut over is useless if resolvers are still caching the old record for the next hour — you want the low TTL already propagated before you need it.
- Provision the target database and verify connectivity before touching production. Create the new managed Postgres instance, confirm you can connect from your laptop with
psqlover its external URL, and confirm your app's services can reach it over its internal URL from inside the new platform. Do this a day ahead, not during the cutover window. - Dry-run the dump and restore against a throwaway database.
pg_dump -Fcyour production database into a custom-format archive, restore it withpg_restoreinto a scratch database on the new platform, and compare row counts per table against production. This step catches encoding mismatches, extension availability gaps, and permission issues while your real database is still untouched — surfacing them here costs you nothing; surfacing them during the real cutover costs you the whole outage window. - Cutover: put the Heroku app in maintenance mode.
heroku maintenance:onstops new writes reaching your database — this is the start of your actual downtime clock. Confirm no write traffic is landing (check active connections or apg_stat_activityquery) before proceeding. - Dump for real, restore for real. Run the same
pg_dump -Fc/pg_restorepair you already validated in step 3, against the live database this time. For a database under a few gigabytes this typically takes single-digit minutes; larger databases should have already told you in step 3 whether this needs a longer window or a different strategy (logical replication instead of dump/restore). - Verify before you flip anything. Re-run the row-count comparison from step 3 against the restored data, and spot-check a handful of recently modified rows by primary key. This is the last point where you can abort and stay on Heroku with zero data loss — use it.
- Flip the CNAME. Repoint your domain's CNAME from Heroku's target to the new platform's hostname. Because you dropped the TTL in step 1, propagation should be seconds to low minutes rather than the original TTL's full window.
- Watch certificate issuance, then confirm end-to-end. A DNS-validated TLS certificate for the new hostname typically issues within about a minute of the CNAME resolving; until then the domain briefly serves a mismatched certificate, which is normal and expected, not a failure. Once the certificate is live, load the app over HTTPS and exercise a write path — not just a health check — before declaring the cutover done.
- Exit maintenance mode on the new platform, decommission the old one. Only after step 8 passes. Keep the Heroku app and database intact (not deleted) for at least a few days as a rollback path in case something surfaces under real traffic that your rehearsal didn't catch.
Done this way — with the dry run in step 3 doing the real debugging ahead of time — the actual production downtime is bounded by steps 4 through 6: a maintenance-mode window measured in minutes for anything short of a very large database, not the multi-hour outage a first-time, unrehearsed dump/restore tends to produce.
The real bill: 12
Here's the dyno-by-dyno invoice for a real small production app — two web processes for headroom, one background worker, a database, and the two add-ons almost every team running Sidekiq or a similar job queue actually has installed:
| Line item | Heroku plan | Monthly cost |
|---|---|---|
| 2× web dynos | Standard-2X | $100 |
| 1× worker dyno | Standard-1X | $25 |
| Postgres | Standard-0 | $50 |
| Redis (Key-Value Store) | Premium-0 | $15 |
| Log drain / search | Papertrail, mid tier | $7 |
| Error tracking | Sentry Small add-on | $29 |
| Total | $226/month |
On the other side, the same web and worker processes, plus a self-managed Postgres cluster and Valkey instance, run comfortably on a small self-hosted node fleet. A minimal production-grade setup — one control-plane node plus two worker nodes on Hetzner's CX22 tier (2 vCPU / 4 GB RAM / 40 GB NVMe / 20 TB traffic included, €3.79/month each) — comes to €11.37/month, call it $12 at current exchange rates, for the raw hardware underneath everything except the two add-ons you decided to keep as external SaaS.
That $12 needs two honest qualifications, not a victory lap:
- It's marginal cost, not total cost, once the fleet exists. A three-node cluster is typically running more than one migrated app — the control-plane overhead amortizes across every workload on the fleet, so the fair comparison for a team migrating a second or third app off Heroku is closer to "zero additional hardware" than to "$12 again." The first app off Heroku pays the fleet's full setup cost; every app after it pays close to nothing.
- It doesn't include the SaaS you kept, or your own time. If you kept Sentry and Papertrail's replacement in bucket two, that's still a real invoice — self-hosting the compute doesn't erase the add-ons you decided not to self-host. And the eight-step cutover above is engineering hours, not a cost you can zero out by picking cheaper hardware.
Even accounting for both, the gap is the point: $226/month on a metered, per-dyno, per-add-on invoice that grows every time you add a process type, against hardware whose monthly number doesn't change based on how many services you run on it. That's the actual argument for owning the machine underneath a sustaining-mode platform — not that self-hosting is free, but that a flat number you control beats a metered one you don't, especially once "sustaining engineering" means the metered one stops getting cheaper on its own.
Migrating off a platform in sustaining mode isn't a weekend project, and this playbook doesn't pretend otherwise — the Procfile mapping is an afternoon, the add-on triage is a real conversation with whoever owns your on-call rotation, and the database cutover deserves the dry run before it touches production. But none of those steps require waiting for Heroku to force your hand with a breaking change nobody budgeted time for.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with the same Procfile-to-manifest and env-vars-API shapes this playbook walks through. Star the repo on GitHub or read the Heroku migration guide before you start your own cutover.



