A Heroku to AWS migration is mostly a translation exercise: dynos become containers on ECS Fargate, Heroku Postgres becomes RDS, config vars become SSM parameters, and the release phase becomes a one-off migration task in your pipeline. The part that needs real planning is moving the database with little downtime, because classic Heroku Postgres does not let you replicate out with standard tools.
Heroku's own direction has made the question more urgent. In February 2026 Heroku announced a move to a sustaining engineering model, focused on stability, security and support rather than new features. Nothing is shutting down, and existing customers keep their plans, but many teams now want a platform with a roadmap they control.
Map every Heroku concept to its AWS equivalent
Start with an inventory. Run heroku apps, heroku addons, heroku ps and heroku config for every app and pipeline, then map each line to a target before you write any Terraform.
| Heroku | AWS target | Notes |
|---|---|---|
| Web and worker dynos | ECS on Fargate services | One service per process type. Size tasks from real CPU and memory metrics, not dyno names. |
| Heroku router | Application Load Balancer | Health checks, TLS termination, host-based routing. |
| Heroku Postgres | Amazon RDS for PostgreSQL or Aurora PostgreSQL | Multi-AZ for production. Match the major version. |
| Heroku Key-Value Store (formerly Heroku Redis) | Amazon ElastiCache (Valkey or Redis OSS) | Keep TLS on; check your client's TLS settings. |
| Config vars | SSM Parameter Store / Secrets Manager | Injected through the task definition's secrets block. |
| Procfile | ECS task definitions | Each process type gets its own command. |
| Release phase | One-off ECS task before deploy | Runs migrations; fails the pipeline on a non-zero exit code. |
| Heroku Scheduler | EventBridge Scheduler running ECS tasks | Cron expressions carry over almost unchanged. |
| Review apps | Preview environments per pull request | Separate ECS service and database schema per PR, torn down on merge. |
| Log drains, Papertrail | CloudWatch Logs | The awslogs driver; ship onward if you prefer another tool. |
| Buildpacks | Dockerfile or Cloud Native Buildpacks, images in ECR | Heroku's heroku/builder images work outside Heroku. |
Third-party add-ons (Sentry, SendGrid, New Relic, Bonsai and similar) are usually the easiest part: most of them are independent SaaS products you can sign up for directly and keep using with the same code. Replace them with AWS services only where there is a clear reason.
Choose the compute target: ECS Fargate, not App Runner
For a Heroku-style app, run containers on Amazon ECS with Fargate. It is the closest match to dynos: no servers to patch, per-second billing, and a service per process type.
App Runner used to be the obvious "Heroku on AWS", but AWS has closed App Runner to new customers and does not plan new features for it. AWS now points new users to Amazon ECS Express Mode, which takes a container image and two IAM roles and creates a Fargate service, an Application Load Balancer, auto scaling and networking for you, at no extra charge beyond the underlying resources.
Express Mode is a fine way to get a first service running. For production we usually define the same pieces in Terraform (VPC, ALB, ECS cluster, services, task definitions), because you will want control over networking, deployment settings and costs within a few months anyway. If you are weighing Kubernetes instead, our ECS vs EKS comparison for startups explains why ECS is the right default for most teams leaving Heroku.
Step 1: containerise the app and build the pipeline
The first deliverable is a container image that runs your app with the same behaviour as the slug Heroku builds today. You have two routes.
- Cloud Native Buildpacks. Heroku publishes its builder images, so
pack build my-app --builder heroku/builder:24produces an OCI image from the same source with similar detection logic (heroku/cnb-builder-images). Good for a fast first pass. - A Dockerfile. More explicit and easier to tune (multi-stage builds, smaller images, ARM64). This is what we end up with for most production apps.
Then replace git push heroku main with a pipeline. Below is a trimmed GitHub Actions job that builds the image, registers a new task definition, runs migrations as a one-off task (the release phase), and only then rolls the service. It authenticates with OIDC instead of stored keys; see our guide to GitHub Actions to AWS with OIDC for the IAM side.
name: deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
env:
AWS_REGION: eu-central-1
CLUSTER: prod
SERVICE: web
ECR_REPO: my-app
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: ${{ env.AWS_REGION }}
- id: ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Build and push
run: |
IMAGE=${{ steps.ecr.outputs.registry }}/${{ env.ECR_REPO }}:${{ github.sha }}
docker build -t "$IMAGE" .
docker push "$IMAGE"
echo "IMAGE=$IMAGE" >> "$GITHUB_ENV"
- id: render
uses: aws-actions/amazon-ecs-render-task-definition@v1
with:
task-definition: ecs/web.json
container-name: web
image: ${{ env.IMAGE }}
- name: Register task definition
run: |
ARN=$(aws ecs register-task-definition \
--cli-input-json "file://${{ steps.render.outputs.task-definition }}" \
--query 'taskDefinition.taskDefinitionArn' --output text)
echo "TASK_DEF=$ARN" >> "$GITHUB_ENV"
- name: Run migrations (release phase)
run: |
TASK=$(aws ecs run-task --cluster "$CLUSTER" --launch-type FARGATE \
--task-definition "$TASK_DEF" \
--network-configuration 'awsvpcConfiguration={subnets=[subnet-aaa,subnet-bbb],securityGroups=[sg-app],assignPublicIp=DISABLED}' \
--overrides '{"containerOverrides":[{"name":"web","command":["bin/rails","db:migrate"]}]}' \
--query 'tasks[0].taskArn' --output text)
aws ecs wait tasks-stopped --cluster "$CLUSTER" --tasks "$TASK"
EXIT=$(aws ecs describe-tasks --cluster "$CLUSTER" --tasks "$TASK" \
--query 'tasks[0].containers[0].exitCode' --output text)
test "$EXIT" = "0"
- name: Deploy
run: |
aws ecs update-service --cluster "$CLUSTER" --service "$SERVICE" --task-definition "$TASK_DEF"
aws ecs wait services-stable --cluster "$CLUSTER" --services "$SERVICE"
Two details that catch people. Keep ecs/web.json limited to fields that register-task-definition accepts (not the output of describe-task-definition, which includes read-only fields). And swap bin/rails db:migrate for whatever your Procfile's release: line runs.
Step 2: move config vars into SSM and Secrets Manager
Put each config var in SSM Parameter Store (SecureString) or Secrets Manager, and reference it from the task definition so it arrives as an environment variable. Your code does not change.
"secrets": [
{ "name": "DATABASE_URL", "valueFrom": "arn:aws:ssm:eu-central-1:123456789012:parameter/my-app/prod/DATABASE_URL" },
{ "name": "STRIPE_SECRET_KEY", "valueFrom": "arn:aws:secretsmanager:eu-central-1:123456789012:secret:my-app/prod/stripe" }
]
The task execution role needs read access to those parameters and the KMS key that encrypts them. Use a path per app and environment (/my-app/prod/) so IAM policies stay short. This is also the moment to drop config vars nobody can explain; every Heroku app we have migrated had a few.
Step 3: migrate Heroku Postgres to RDS
For databases up to a size you can dump and restore inside an acceptable maintenance window, use pg_dump and pg_restore. For anything bigger, your options depend on which Heroku Postgres plan you are on.
The simple path: dump and restore
# 1. Put the app in maintenance mode so nothing writes
heroku maintenance:on -a my-app
# 2. Capture a fresh backup and download it
heroku pg:backups:capture -a my-app
curl -o latest.dump "$(heroku pg:backups:url -a my-app)"
# 3. Restore into RDS (same major version)
pg_restore --verbose --clean --no-acl --no-owner \
-h my-db.xxxx.eu-central-1.rds.amazonaws.com -U app -d app latest.dump
Do a full rehearsal against a staging RDS instance first and time it. That number decides whether this path works for you. Run ANALYZE after the restore so the planner has statistics, and check row counts on your largest tables.
Near-zero downtime: what actually works
The usual near-zero-downtime tools (AWS DMS with change data capture, or native logical replication into RDS) need logical replication on the source. Classic Heroku Postgres plans do not offer this: Heroku states it does not support logical replication to external instances, and it does not grant the superuser or replication roles that tools like DMS rely on. DMS can still do a one-off full load, but that has the same write freeze as a dump.
The exception is Heroku Postgres Advanced, which offers managed logical replication to an external Postgres such as RDS. If you are on Advanced, or can move to it, the flow is: copy the schema with pg_dump --schema-only, create a publication on Heroku, a subscription on RDS, wait for the initial sync, then cut over. Heroku's docs list the caveats worth repeating: DDL is not replicated, sequence values must be advanced with setval() before cutover, and every table needs a primary key or unique index for updates and deletes to replicate.
For classic plans, the remaining options are trigger-based replication or application-level dual writes. Both add risk and engineering time. In our experience, a well-rehearsed maintenance window of under an hour is the better trade for most startups.
Step 4: Redis, workers, scheduler and preview environments
These pieces are mechanical once the app runs on ECS.
- Redis. Create an ElastiCache cluster with in-transit encryption and point
REDIS_URLat it. If Redis holds only cache and job queues, let it start empty and drain the old queue before cutover rather than migrating keys. - Workers. A second ECS service using the same image with the worker command. Scale it on queue depth if your queue exposes a metric.
- Scheduler. EventBridge Scheduler can run an ECS task on a cron or rate expression, which replaces Heroku Scheduler one job at a time.
- Review apps. A pipeline job on pull-request open that deploys a separate ECS service behind a host-based ALB rule (
pr-123.preview.example.com), with its own database or schema, and a job on close that deletes it. Start with this only if your team actually used review apps.
Step 5: DNS and SSL cutover
The cutover is the shortest step if you prepare it a few days ahead.
- Request an ACM certificate for your domains in the same region as the ALB, validated with DNS records.
- Lower the TTL on your app's DNS records to 60 seconds at least a day before.
- Deploy the full stack on AWS and test it through the ALB hostname with a
Hostheader or a temporary subdomain. - On the night: maintenance mode on Heroku, final database copy, verify, switch the DNS record to the ALB (an alias record on Route 53, or a CNAME elsewhere), and watch errors and latency.
- Keep the Heroku app scaled down but intact for a week as a rollback path. Remember that rolling back after new writes on RDS means copying data back.
Our cloud migration checklist covers the parts that apply to any migration, from backups to the post-cutover review.
How to estimate the cost before you move
Do not trust anyone's "AWS is X% cheaper" claim, including ours. Build the estimate from your own numbers.
- Current spend. Take the last three Heroku invoices and split them into dynos, Postgres, Redis and each add-on.
- Real resource use. Use Heroku metrics for memory and CPU load per process type. Many apps run on oversized dynos because memory was the only knob.
- Price the AWS side line by line in the AWS Pricing Calculator: Fargate vCPU and memory hours per service, ALB, RDS instance plus storage and backups (double the instance for Multi-AZ), ElastiCache, CloudWatch Logs ingestion, ECR storage and data transfer out.
- Add the network costs teams forget. A NAT gateway for private subnets has an hourly charge plus a per-GB processing fee, which we cover in the NAT gateway hidden cost.
- Add people time. Someone now owns patching base images, database upgrades, alarms and on-call. On Heroku, part of that was in the price.
Compute and databases are often cheaper on AWS for the same capacity, and Savings Plans and reserved instances widen the gap. Whether the total is cheaper depends on step 5.
When not to leave Heroku
Stay on Heroku if the migration costs more than it returns. That is a real outcome, not a failure.
- Your Heroku bill is small and stable, and nobody on the team wants to own infrastructure.
- You depend on Heroku Connect for Salesforce sync and have no replacement planned.
- You are about to raise, launch or rebuild, and a migration would compete with work that matters more this quarter.
- You have no one who can own AWS after the migration. A platform nobody maintains is worse than Heroku.
The reasons that do justify the move: rising cost at scale, needing VPC-level networking or private connectivity, compliance requirements that need controls you cannot get on Heroku, or simply wanting a platform whose roadmap you control.
If you are planning a move off Heroku and want a second pair of hands for the database cutover or the Terraform foundation, this is the kind of project our cloud migration service is built around: a rehearsed plan, a short maintenance window, and a team that can run the platform afterwards.
Frequently asked questions
What is the AWS equivalent of Heroku?
The closest equivalent is Amazon ECS on Fargate behind an Application Load Balancer, with RDS for Postgres and ElastiCache for Redis. AWS App Runner was the simpler option, but AWS closed it to new customers and now recommends ECS Express Mode, which sets up Fargate, a load balancer and auto scaling from a single container image.
Can I migrate Heroku Postgres to RDS with zero downtime?
Only on Heroku Postgres Advanced, which supports managed logical replication to an external Postgres such as RDS. Classic Heroku Postgres plans do not allow logical replication or the replication role, so AWS DMS change data capture will not work. For those plans, a rehearsed pg_dump and pg_restore during a short maintenance window is the reliable option.
How long does a Heroku to AWS migration take?
For a typical startup with one or two apps, Postgres, Redis and a handful of add-ons, plan a few weeks: setting up the AWS foundation and pipeline, a staging environment, one or two database rehearsals, then a cutover in a single maintenance window. The database size and the number of add-ons affect the timeline more than the code does.
Is AWS cheaper than Heroku?
Often for compute and databases at the same capacity, especially with Savings Plans, but not automatically. Price your own usage in the AWS Pricing Calculator and add load balancers, NAT gateways, logs, data transfer and the engineering time to run the platform. For small, stable apps, Heroku can still be the cheaper option overall.