Heroku is the easiest place to ship a v1 and one of the most expensive places to run it at scale. This is the senior-engineer playbook for moving off Heroku and onto AWS: the target architecture (dynos → ECS/Fargate or App Runner, Heroku Postgres → RDS/Aurora, add-ons → AWS-native), the exact cutover plan with the downtime window, buildpacks → containers, the real before/after cost, and the gotchas that bite teams. A MAP-funded AWS partner can run the whole thing — often at little-to-no cost to you.
Nobody migrates off Heroku because they dislike it. They migrate because the thing that made Heroku great at v1 — total abstraction at a flat per-dyno price — becomes the thing that caps their margin and their architecture at scale.
The first and loudest reason is cost. Heroku prices by the dyno — a small, fixed slice of compute (a Standard-2X is ~1GB RAM for ~$50/month; a Performance-L ~14GB for ~$500). Against raw AWS compute the convenience looks expensive: a Fargate task with 1 vCPU and 2GB runs ~$35–$40/month at 24/7 (less if it scales down off-hours), and equivalent always-on EC2 with a Savings Plan can be a third of the Heroku price for the same headroom. The gap is not 10–20% — for a dozen dynos plus add-ons, teams routinely land 40–70% lower, and the savings compound as they grow.
The second reason is platform limits. Heroku Postgres caps concurrent connections (Standard ~120, Premier up to 500, the largest plans ~1,000), so a connection-per-request app hits the ceiling fast and is forced into PgBouncer or the Heroku pooler. The router enforces a hard 30-second request timeout you cannot raise. Dynos cycle (restart) at least daily — fine for stateless web, a real constraint for stateful work or long jobs. Autoscaling is coarse and Performance-tier only. None of these are bugs — they are the price of the abstraction, and exactly the walls a scaling team keeps hitting.
The third reason is the add-on tax. Heroku's managed Redis, Postgres, logging, and metrics add-ons each carry a markup over the underlying infrastructure: Heroku Data for Redis premium tiers cost multiples of the equivalent ElastiCache node, and logging/APM billed through Heroku is typically priced above going direct. On AWS you buy the underlying service (ElastiCache, RDS, CloudWatch, OpenSearch) at list price — often at a committed-use discount on top.
The honest counterpoint: Heroku's abstraction has real value, and AWS asks you to own more — a landing zone, IAM, networking, container images, deploy pipelines. That operational surface is precisely why most teams do not run this migration alone. The rest of this page is the architecture and the plan; the CloudRoute angle is that a vetted AWS partner runs it for you, and AWS often funds it.
A seed/Series-A app commonly runs ~10 dynos + Heroku Postgres Standard + Heroku Redis + logging/metrics add-ons for $1,800–$3,500/month. The equivalent on AWS — Fargate (or App Runner) + RDS/Aurora + ElastiCache + CloudWatch, with a Compute Savings Plan — commonly lands at $700–$1,400/month. That recovered margin is what pays for the migration (and with MAP, AWS pays for it instead).
There is no single "AWS version of Heroku." There are two sensible compute targets, and the right one depends on how much operational control you want versus how close to the Heroku experience you want to stay. The data and supporting services map almost one-to-one.
For compute, the two realistic targets are AWS App Runner and Amazon ECS on Fargate. App Runner is the closest thing AWS has to Heroku: point it at a container image and it builds, deploys, serves HTTPS, and autoscales on request volume — the fastest path off Heroku for a straightforward web service. ECS on Fargate is the workhorse: serverless containers with full control over networking (VPC, subnets, security groups), task sizing, service autoscaling, an Application Load Balancer, and clean separation of web and worker services. Teams migrating because they outgrew Heroku usually want Fargate — the control App Runner abstracts away is the very thing they outgrew Heroku for. (The callout below makes the choice concrete; EKS is overkill straight off Heroku unless you already run it.)
For the relational database, Heroku Postgres becomes Amazon RDS for PostgreSQL (the like-for-like managed Postgres, simplest mental model) or Aurora PostgreSQL (AWS's cloud-native engine — a little more per hour, but with faster failover, autoscaling storage, up to 15 read replicas, and automatic capacity scaling in Serverless v2). A common pattern is to land on RDS first to minimize change at cutover, then evaluate Aurora once stable. Because both speak PostgreSQL, your app code and ORM do not change.
Heroku Data for Redis becomes Amazon ElastiCache (Redis OSS or Valkey) — same protocol, so cache, queues (Sidekiq, Bull, Celery), and rate-limiters keep working with a connection-string change. Heroku Scheduler becomes EventBridge Scheduler triggering an ECS task or Lambda; worker dynos become a separate ECS service or queued Fargate tasks. File uploads stay on S3 (you were already there, since the dyno filesystem is ephemeral). Outbound email (SendGrid/Mailgun) can move to Amazon SES or stay with the vendor billed directly.
Heroku Pipelines, review apps, and the build/release flow map to CodePipeline + CodeBuild (+ optionally CodeDeploy), or you keep GitHub Actions and have it push images to ECR and update the ECS service. Heroku Connect (Salesforce sync) has no drop-in replacement — it becomes AWS AppFlow or a scheduled Salesforce-API job, so flag it early as one of the few pieces that needs real design. The supporting glue (config vars, buildpacks, logging) is covered next, where the hands-on work lives.
App Runner for one or a few stateless web services when you want the git-push simplicity Heroku gave you (least new operational surface). ECS on Fargate when you need VPC networking control, coordinated web + worker services, fine-grained autoscaling, or private connectivity to RDS/ElastiCache — the most common landing spot for "we outgrew Heroku" teams. Skip EKS unless you already run Kubernetes.
The full row-by-row mapping — every Heroku building block, its AWS equivalent, the cost direction, and the effort — lives in the comparison table further down. Two principles make reading that table safe.
First, anything that speaks a standard protocol moves with a connection-string change and no application rewrite — PostgreSQL (Heroku Postgres → RDS/Aurora), Redis (Heroku Data for Redis → ElastiCache), and S3 (usually already in place). These are the "trivial" rows.
Second, anything Heroku-proprietary needs a one-time translation into an AWS-native primitive: buildpacks + Procfile → a Dockerfile, config vars → Secrets Manager + SSM Parameter Store, Heroku Scheduler → EventBridge Scheduler, Pipelines → CodePipeline/CodeBuild, custom domains/SSL → Route 53 + ACM, Heroku Connect → AppFlow. Done once and documented, that translation is the work a migration partner does — the "Medium/High effort" rows in the table.
The single most hands-on task in a Heroku→AWS move is turning the buildpack-and-Procfile model into a container image. It is mechanical, but it is where teams without container experience slow down — so here is exactly what it involves.
On Heroku a buildpack inspects your repo, installs the runtime, and produces a slug; your Procfile declares process types. AWS does not run buildpacks (outside App Runner's managed source builds), so you produce the same result with a Dockerfile — for most stacks a dozen lines: a slim base image, copy + install the dependency manifest (npm ci / bundle install / pip install / composer install), copy the app, set the start command. Each process type becomes a container command or a separate ECS service: web is the load-balanced service, worker is its own, and Heroku's "release" phase (migrations on deploy) becomes a one-off ECS run-task or a CodeBuild step. A Dockerfile is the portable choice most partners standardize on, though App Runner can build from source.
Config vars are the other translation. On Heroku `heroku config` is a flat bag of env vars from `DATABASE_URL` to API keys. On AWS you split it by sensitivity: real secrets (DB credentials, API keys, signing secrets) into AWS Secrets Manager, non-sensitive config (feature flags, public URLs, log levels) into SSM Parameter Store. Both are referenced from the ECS task definition (or App Runner config) so the container sees them as environment variables exactly as on Heroku — your code does not change. The migration step is a one-time export, a sort into secret-vs-config, and a load into Secrets Manager / Parameter Store, which a partner scripts so nothing is missed or leaked into an image layer.
Two Heroku conveniences to consciously replace: ephemeral-filesystem assumptions (already forced you onto S3, so usually fine) and automatic `DATABASE_URL`/`REDIS_URL` injection (now you point those at your RDS and ElastiCache endpoints, ideally over private VPC networking). Neither is hard; both are easy to forget — which is why they live on the migration checklist, not in someone's memory.
Everything else can be built and tested without touching production. The database cutover is the one moment that touches live data — where a plan earns its keep. Done right, user-visible downtime is zero to fifteen minutes.
The core technique is to keep the new RDS/Aurora database continuously in sync with Heroku Postgres while you test, so that at cutover you switch to an already-current copy rather than copying a cold database under time pressure. AWS Database Migration Service (DMS) does exactly this: a full load into RDS, then a switch into change data capture (CDC) mode that streams every subsequent insert/update/delete in near-real time. Because both ends are PostgreSQL, this is a homogeneous migration — no Schema Conversion Tool needed; the schema moves as-is via a `pg_dump --schema-only` restore. Very small databases can skip DMS for a single `pg_dump | pg_restore` inside the window; DMS earns its place once a cold dump/restore would blow past an acceptable downtime window.
With CDC running and the new stack smoke-tested against the synced database, the cutover is short and scripted: maintenance mode → let DMS drain the last few seconds → flip `DATABASE_URL` to RDS and switch traffic to the new App Runner/Fargate service → update the Route 53 record (TTL lowered ahead of time) → verify writes land in RDS → exit maintenance. Because the data was already synced, the window is dominated by DNS propagation and verification, not data transfer — commonly 0–15 minutes of write-downtime during a low-traffic period.
The rollback plan is non-negotiable and simple: keep Heroku running until you are confident, and if something is wrong, switch `DATABASE_URL` and DNS back. The clean window is before RDS has accepted production writes Heroku hasn't seen — once committed, rolling back means reconciling those writes — so most teams keep the rollback window short (minutes to hours) and watch closely. Lowering DNS TTL to 60 seconds a day ahead makes both the switch and the rollback fast.
A cold pg_dump | pg_restore makes your downtime window = export + transfer + import of the whole database: 20–60 minutes for 5GB, unacceptable at 50GB+. DMS with CDC moves the bulk load before the window and only drains the last few seconds during it — so downtime stays in single-digit minutes regardless of database size.
Here is the end-to-end sequence a partner runs, mapped to the AWS MAP phases (Assess → Mobilize → Migrate). For a typical Heroku app this is a 3–8 week project — most of it parallelizable, none of it touching production until the final cutover.
Cost is the reason most teams start this, so here is a concrete model rather than a hand-wave. Numbers are 2026 list-price ranges for a mid-size production app; yours vary with traffic and how aggressively you right-size and commit.
Take a typical Series-A SaaS: ~10 dynos across web and worker, a Heroku Postgres Standard/Premier plan, Heroku Redis, and the usual logging + metrics add-ons. On Heroku that commonly totals $1,800–$3,500/month and scales roughly linearly — every new dyno and plan bump adds a fixed chunk.
The same workload on AWS: web + workers on Fargate (right-sized, with off-hours scale-down) or App Runner, RDS/Aurora PostgreSQL, an ElastiCache node, CloudWatch, plus modest data-transfer and S3. At list price that is often $900–$1,600/month; layer a one- or three-year Compute Savings Plan and committed database capacity on top and steady-state lands closer to $700–$1,400/month. That is the 40–70% reduction teams cite — and unlike Heroku, the AWS bill bends down as you commit and right-size rather than only stepping up.
The cost the table cannot show is the migration itself: engineering time, dual-running Heroku + AWS for a few weeks, and the learning curve of owning the infrastructure. This is exactly where the CloudRoute model changes the arithmetic. When the workload qualifies for the AWS Migration Acceleration Program, AWS funds the assessment and credits a large share of the migration/modernization cost — the partner is paid through MAP — so you capture the 40–70% ongoing savings without paying the usual migration bill. When MAP does not apply, it is a vetted-partner engagement that de-risks the cutover: you pay for the work, but you are not improvising the riskiest 20 minutes of your year.
Most Heroku→AWS migrations that go badly go badly for a small, repeatable set of reasons. Naming them up front is the cheapest insurance there is.
The definitive lookup: every Heroku building block, where it lands on AWS, the rough cost direction, and the engineering effort. "Trivial" rows are connection-string or config swaps; "Medium/High" rows are the real work — and the work a partner does for you.
| Heroku | AWS equivalent | What changes | Cost direction | Effort |
|---|---|---|---|---|
| Web dyno | App Runner, or ECS/Fargate + ALB | Buildpack → Dockerfile; router → ALB | Down 40–70% | Medium |
| Worker dyno | ECS/Fargate service or queued tasks | Process type → separate service | Down 40–70% | Medium |
| Heroku Postgres | RDS for PostgreSQL / Aurora PostgreSQL | Connection string + cutover via DMS | Down 30–60% | High (cutover) |
| Heroku Data for Redis | ElastiCache (Redis OSS / Valkey) | Connection string only | Down 50–70% | Trivial |
| Heroku Scheduler | EventBridge Scheduler → ECS/Lambda | Recreate each job explicitly | Down (near $0) | Low |
| Config vars | Secrets Manager + SSM Parameter Store | Split secret vs config; inject to task | Negligible | Low |
| Buildpacks + Procfile | Dockerfile + ECR | Explicit container build | Negligible | Medium |
| Pipelines / Review Apps | CodePipeline + CodeBuild (or GitHub Actions) | Rebuild the CI/CD flow | Down / neutral | Medium |
| Log drains + metrics | CloudWatch Logs + Container Insights | Reconfigure logging sink | Down vs add-ons | Low |
| File uploads / S3 add-on | Amazon S3 | Usually already on S3 | Down | Trivial |
| Email add-ons | Amazon SES (or vendor direct) | Swap SMTP/API config | Down | Low |
| Heroku Connect (Salesforce) | AWS AppFlow / scheduled API job | Redesign the sync | Varies | High |
| Custom domain + SSL | Route 53 + ACM (free TLS) | DNS + cert on ALB/App Runner | Down (free certs) | Low |
Situation: Margin pressure ahead of a Series-B raise, plus two hard technical walls: Postgres connections maxing out at peak and long report requests dying at the 30s router timeout. The two-person platform team had no container/AWS experience and could not afford a multi-month DIY migration or a risky big-bang database cutover.
What CloudRoute did: Routed within 24 hours to an AWS Advanced-tier partner with a Rails + Heroku track record, who ran the MAP Assess phase (free) and filed the work as a MAP engagement. Target: ECS on Fargate (web + a dedicated Sidekiq service) behind an ALB, Aurora PostgreSQL with RDS Proxy for pooling, ElastiCache for Redis, EventBridge Scheduler for cron, config vars split into Secrets Manager + Parameter Store, and GitHub Actions → ECR → ECS for deploys. Postgres moved via AWS DMS (full load + CDC); long report requests were refactored onto a Sidekiq queue.
Outcome: Cutover ran in a Sunday-night window with ~11 minutes of write-downtime — DMS had Aurora fully in sync, so the switch was DNS + verification, not data transfer. Steady-state AWS bill landed at ~$1,150/month (a ~63% cut); connection ceiling and report timeouts both gone; project ran ~6 weeks. Because the workload qualified for MAP, AWS funded the assessment and credited the migration cost — out-of-pocket migration cost was effectively $0, and CloudRoute's commission was paid by the partner from MAP funding.
project length: ~6 weeks · cutover downtime: ~11 min · monthly spend: $3,100 → $1,150 (−63%) · migration cost to customer: ~$0 (MAP-funded)
CloudRoute routes you to a vetted AWS partner who plans and runs the whole Heroku→AWS migration — containers, RDS/Aurora, ElastiCache, the DMS cutover, the cost optimization. Qualifying migrations are MAP-funded, so you capture the 40–70% ongoing savings without paying the usual migration bill.