Skip to main content
Heroku gets you running fast, but you pay for it in cost at scale, in limited control over the underlying infrastructure, and in features AWS has had for years. Moving to AWS fixes all three — and Ravion removes the reason people stay on Heroku, which is that AWS is a lot of work to set up and operate by hand. Everything Ravion provisions lives in your AWS account, built from Terraform you can inspect. There is no lock-in on the way out either.

The zero-downtime plan

  1. Deploy your app to AWS, connected to your existing Heroku database.
  2. Verify it end to end, then switch production traffic to AWS by updating DNS.
  3. Migrate the database to RDS.
  4. Shut down the Heroku app.
Steps 1 and 2 are reversible by pointing DNS back at Heroku. After the database cutover in step 3, rolling back means moving data written to RDS back to Heroku, so treat that step as the point of no return.

Terminology

Step 1: Deploy to AWS against the Heroku database

  1. Connect your AWS account and your Git provider to Ravion.
  2. Create a project with a production environment. Pick us-east-1 (or eu-west-1 for EU apps) — that is where Heroku runs, and keeping the app in the same region as the Heroku database keeps latency low during the transition.
  3. Add a network, an ECS cluster, and one rvn-ecs-web module per web process type, plus an rvn-ecs-worker per worker process type. Copy build and start commands from your Procfile. The framework guides have working configs for Rails, Django, Node, Laravel, and others.
  4. Export your Heroku config vars and load them into one Secrets Manager secret, then reference them from the modules:
    See Managing secrets for the reference format. Leave DATABASE_URL and REDIS_URL pointing at Heroku for now.
  5. Allow the AWS app to reach Heroku Postgres. Heroku Postgres is publicly reachable over TLS, so this works from a private subnet through the NAT gateway. If you use Heroku’s IP allowlisting (Private or Shield spaces), allowlist the NAT gateway’s public IP. Pass pre-allocated nat_gateway_eip_allocation_ids to the network module so that IP stays stable — see rvn-aws-network.
  6. Deploy and test on the Ravion-provided URL. Your app is now running on AWS against production data, but serving no production traffic.
Make sure db:migrate does not run on container start. Heroku users usually have it in the Procfile release phase — move it to the end of the build (fastest, when Ravion builds your image) or to a pre-deploy hook. See Running database migrations for the trade-offs.

Step 2: Switch production traffic

  1. Set up your domain following Custom domains: request an rvn-acm-certificate, create the validation record, attach the certificate to the ECS cluster load balancer (or CloudFront). None of this moves traffic yet.
  2. Lower the TTL on your existing DNS records to 60 seconds and wait for the old TTL to expire.
  3. Save the current Heroku DNS values, then update the records to the Ravion-provided ones.
  4. Watch error rates and the Ravion logs and metrics tabs. To revert, point DNS back at Heroku.

Step 3: Migrate the database

Production traffic is now served from AWS, but the database is still on Heroku. Follow Migrate Heroku Postgres to RDS to move it, then update the DATABASE_URL key in your environment secret and redeploy. Do the same for Redis if you use it: create an rvn-elasticache module and update REDIS_URL. Cache data usually does not need to be migrated; queue data (Sidekiq, Bull, Celery) should be drained on Heroku before the switch.

Step 4: Decommission Heroku

Keep the Heroku app and database running for about a week after the database cutover, scaled to the minimum. Then delete them.

Application changes to expect

  • Commit SHA. HEROKU_SLUG_COMMIT and SOURCE_VERSION do not exist. If your app needs the commit at runtime (for Sentry releases, for example), pass it through the pipeline: take the commit as a pipeline input and forward it as a build environment variable or image tag — see Build.
  • Client IP. Behind an ALB the socket peer address is the load balancer’s, and the ALB appends the client IP it saw to X-Forwarded-For — so the first entry is client-controlled and spoofable. Take the rightmost entry the ALB added, or configure the framework’s trusted-proxy setting for one hop (Express app.set("trust proxy", 1), Rails config.action_dispatch.trusted_proxies). With CloudFront in front, use CloudFront-Viewer-Address or trust two hops. Rate limiters and audit logs are the usual places this bites.
  • No swap. Heroku dynos swap to disk when memory is exhausted; Fargate tasks are killed. Set task_memory with headroom and watch the memory chart after cutover. Node apps also need NODE_OPTIONS=--max-old-space-size sized to the task — see JavaScript heap out of memory.
  • Keep-alive timeouts. The ALB idle timeout is 60 seconds, and several frameworks default to less. See Random 502 errors behind a load balancer.
  • Postgres over TLS. RDS enforces TLS on PostgreSQL 15+ by default. See PostgreSQL SSL connection error.
  • Port. Ravion sets PORT from the module’s container_port, the same way Heroku does.
  • Ephemeral filesystem. Same as Heroku: anything written to disk is lost when the task is replaced. Use S3 (rvn-s3) or EFS (rvn-efs) for files that must persist.