Heroku to AWS migration: a step-by-step guide for small teams

· 9 min read · aws · migration

Every Heroku concept has an AWS equivalent, but the mapping is not one-to-one and the database is the hard part. This is the order we migrate in, the commands we use, and the cases where we tell teams to stay put.

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.

HerokuAWS targetNotes
Web and worker dynosECS on Fargate servicesOne service per process type. Size tasks from real CPU and memory metrics, not dyno names.
Heroku routerApplication Load BalancerHealth checks, TLS termination, host-based routing.
Heroku PostgresAmazon RDS for PostgreSQL or Aurora PostgreSQLMulti-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 varsSSM Parameter Store / Secrets ManagerInjected through the task definition's secrets block.
ProcfileECS task definitionsEach process type gets its own command.
Release phaseOne-off ECS task before deployRuns migrations; fails the pipeline on a non-zero exit code.
Heroku SchedulerEventBridge Scheduler running ECS tasksCron expressions carry over almost unchanged.
Review appsPreview environments per pull requestSeparate ECS service and database schema per PR, torn down on merge.
Log drains, PapertrailCloudWatch LogsThe awslogs driver; ship onward if you prefer another tool.
BuildpacksDockerfile or Cloud Native Buildpacks, images in ECRHeroku'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.

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.

Step 5: DNS and SSL cutover

The cutover is the shortest step if you prepare it a few days ahead.

  1. Request an ACM certificate for your domains in the same region as the ALB, validated with DNS records.
  2. Lower the TTL on your app's DNS records to 60 seconds at least a day before.
  3. Deploy the full stack on AWS and test it through the ALB hostname with a Host header or a temporary subdomain.
  4. 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.
  5. 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.

  1. Current spend. Take the last three Heroku invoices and split them into dynos, Postgres, Redis and each add-on.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

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.