Heroku to AWS Migration for Production Apps
Heroku abstracts away infrastructure; AWS makes you own it all at once.

The most common mistake at the start of this migration is treating it as a containerization exercise: write a Dockerfile, point it at some AWS compute, and call it done. Competent teams go down that road. The surprise is never the container. It is everything the container was never responsible for.
Heroku is simultaneously your build system, process manager, secrets store, HTTP router, logging aggregator, add-on marketplace, and release coordinator. None of those roles disappear when you leave. They become your explicit responsibility, usually all at once, and usually faster than you planned for — like inheriting a house and discovering it came with a full-time maintenance schedule.
Some of what vanishes on AWS is easy to overlook until it is gone. Heroku restarts dynos automatically when they crash. Eco and Basic dynos restart daily regardless of health. The platform enforces a 30-second HTTP timeout across the board. The filesystem is ephemeral; anything written to disk disappears on restart. These are not quirks. They are architectural constraints that shaped how your application behaves in ways most teams have not revisited since the original build. Audit them before you move.
Heroku Postgres carries its own constraints worth cataloguing. You never had superuser access. The extension ecosystem was curated and restricted. Version upgrades required coordination with support and, in some cases, planned downtime. Moving to RDS expands what you can do, but only if you know what you were actually using on Heroku first.
Config Vars are the clearest illustration of the abstraction gap. On Heroku, you set a key-value pair in the dashboard or CLI and it is immediately available to every dyno. AWS has no equivalent that just works. You choose between Systems Manager Parameter Store and Secrets Manager, decide which team members have access to which secrets, build governance around rotation, and ensure the application fetches configuration correctly at startup. Not technically hard. But it did not previously exist as your problem.
Networking is the largest invisible layer. Heroku abstracts it entirely. On AWS, every decision about VPCs, subnets, security groups, and ACLs must be made explicitly before a single container runs. Engineers who have worked exclusively on Heroku often underestimate this gap, and understandably: they were never asked to think about it. That changes immediately.
Complete this inventory before you choose an AWS compute target. The gaps you find here determine which services you actually need.
Matching Each Heroku Primitive to Its AWS Equivalent
Once you know what you are mapping, the translation is mostly mechanical. The hard part was the inventory. Think of it as translating a recipe into a new language: the ingredients are the same, but you have to name every one of them yourself.
Compute. Web dynos map to Amazon ECS with Fargate. Fargate runs containers without requiring you to manage underlying servers, and the web-and-worker model Heroku made familiar translates directly: web processes become ECS services behind a load balancer, worker processes become separate ECS services or scheduled tasks consuming from a queue.
Elastic Beanstalk is worth considering for teams that want to minimize how much changes at once. It handles deployment, scaling, and monitoring with a smaller operational surface area than raw ECS; the experience is closer to handing something a deployable artifact and letting it manage the rest. The cost is less granular control when something unusual needs tuning, and things always eventually need tuning.
EKS is the right answer only if your organization already has platform engineers and multiple services converging on Kubernetes. For one or two applications, ECS with Fargate is significantly easier to operate and reason about. AWS App Runner is viable for simpler containerized web services where the team wants the smallest possible infrastructure surface.
Database. Heroku Postgres maps to Amazon RDS for PostgreSQL. This is the substantive upgrade the migration actually buys you: superuser access, the full extension ecosystem, clean version upgrade paths, and Multi-AZ failover that Heroku's Common Runtime cannot match. Heroku Redis maps to Amazon ElastiCache. Heroku Data for Apache Kafka maps to Amazon MSK.
Configuration and secrets. Config Vars map to SSM Parameter Store for standard configuration values and Secrets Manager for credentials requiring encryption and automated rotation. The migration is a natural forcing function for formalizing secret governance that should have happened earlier.
Routing and TLS. The Heroku Router and automatic SSL termination map to an Application Load Balancer paired with AWS Certificate Manager. ACM handles provisioning and renewal automatically, so you do not lose the zero-maintenance TLS behavior Heroku provided.
CI/CD. Heroku's git push pipeline maps to AWS CodePipeline with CodeBuild, or to a GitHub Actions workflow using AWS's official actions for ECR and ECS. Both work; the right choice depends on where your team already lives.
Observability. Log drains map to CloudWatch Logs. Heroku's metrics dashboard maps to CloudWatch Metrics with configured alarms. Neither is as frictionless out of the box. Both are more capable once you invest in setting them up correctly.
Add-ons. The Heroku add-on marketplace has no single equivalent on AWS. Each dependency gets wired explicitly: SES for transactional email, SNS and SQS for queuing and async processing, S3 for file storage. More work upfront. At scale, typically less expensive and more configurable.
| Heroku Primitive | AWS Equivalent | |---|---| | Web dynos | ECS Fargate (web service) | | Worker dynos | ECS Fargate (worker service) or scheduled tasks | | Heroku Postgres | Amazon RDS for PostgreSQL | | Heroku Redis | Amazon ElastiCache | | Heroku Kafka | Amazon MSK | | Config Vars | SSM Parameter Store / Secrets Manager | | Heroku Router + SSL | ALB + ACM | | Heroku CI / Review Apps | CodePipeline + CodeBuild / GitHub Actions | | Log drains | CloudWatch Logs | | Heroku Metrics | CloudWatch Metrics + Alarms | | Heroku Add-ons (email, queue, storage) | SES, SQS/SNS, S3 |
Designing the VPC and Networking Layer Before Anything Else Runs
This is where migration stops being mechanical and becomes architectural. Every downstream decision, containers, databases, compliance posture, sits on top of this layer. Get it wrong here and you are retrofitting under pressure later. Do it first.
The standard production VPC layout is not complicated, but it demands intentionality. Public subnets hold your load balancers and nothing else. Private subnets hold your application containers and your database instances. Nothing in the database layer should have a public IP address. That is the baseline, not a suggestion.
Security groups are where most teams accumulate technical debt they do not notice until someone is afraid to delete something. The Application Load Balancer accepts inbound traffic on port 443. ECS tasks accept traffic only from the ALB's security group. RDS accepts traffic only from the ECS tasks' security group. Each rule should have a documented reason for existing. If you cannot articulate it, the rule should not exist. Orphaned security group rules have a way of becoming load-bearing mysteries six months later, when no one remembers what they were doing and everyone is afraid to find out.
Network ACLs function as a secondary, stateless layer. Useful for satisfying specific compliance requirements, not a substitute for rigorous security group design.
Getting the networking architecture right from the start is also what enables your compliance posture. CloudTrail, GuardDuty, and VPC Flow Logs all attach to this layer. They generate the audit evidence that a SOC 2 or HIPAA assessment requires. You cannot retrofit this cleanly once the environment is already running production traffic.
Multi-AZ deployment belongs in this planning phase as well. RDS Multi-AZ gives you automatic failover to a standby instance in a separate availability zone. ECS tasks spread across availability zones means a single AZ outage does not take down the application. This is the reliability Heroku's Common Runtime does not and cannot guarantee, and it is one of the real reasons this migration is worth the effort.
Document every security group rule and subnet CIDR as you create it. Teams that delay this documentation reliably produce security group sprawl, orphaned rules, and the specific anxiety of not knowing whether removing something will break production. That anxiety compounds over time.
Database Migration Without Taking the App Offline
The database carries the most risk in this migration. The stakes are asymmetric: a successful migration goes unnoticed, and a failed one is a crisis. The good news is the paths are well-defined.
For smaller databases, or applications where a brief maintenance window is acceptable, pgdump from Heroku followed by pgrestore into RDS is the straightforward path. Well-understood, finite in its risk, operationally simple. For large datasets or applications where zero downtime is a hard requirement, AWS Database Migration Service with logical replication is the right tool. DMS keeps the source Heroku Postgres and the target RDS instance in continuous sync until you decide to cut over. The setup is more involved, but it means the actual cutover moment is measured in seconds rather than hours.
Before you do anything else, audit the Heroku Postgres environment thoroughly. Confirm that your current PostgreSQL version has a direct equivalent available on RDS. Confirm that every extension your application uses is available on RDS. Resolve discrepancies before migration day. Finding a version mismatch or a missing extension during the cutover window is avoidable, and being in that position during a live cutover is exactly as bad as it sounds.
Always overprovision the RDS instance size at migration. Start at two to three times the RAM of your current Heroku Postgres plan. Validate performance over two weeks of baseline production traffic, then use AWS Compute Optimizer recommendations to rightsize. Downsizing a db.m5.large after the fact is easy. Recovering from performance degradation on migration day is not.
Connection pooling deserves attention before you go to production, not after connection limits start getting hit. Heroku provides PgBouncer-equivalent pooling implicitly on some plans. RDS does not. For applications with high connection counts, evaluate RDS Proxy or a self-managed PgBouncer layer as part of the design.
Test the restored database under production-like query load before the cutover. Schema migrations that performed acceptably on Heroku Postgres can behave differently on RDS due to different autovacuum tuning defaults. Find those differences during testing.
Building a CI/CD Pipeline That Replaces What Heroku's Git Push Did
The simplicity of git push heroku main is deceptive. That single command hides a complete build pipeline: source detection, buildpack selection, slug compilation, dyno replacement, traffic shifting. Leaving Heroku means making that entire pipeline explicit. That is the work. In other words, git push heroku main was the tip of an iceberg — you are now responsible for everything below the waterline.
Start with containerization. Write a Dockerfile that replicates what your Heroku buildpack was doing. For most Ruby, Python, Node.js, or Java applications, the file itself is small. The challenge is identifying the implicit runtime behavior the buildpack handled silently: build dependencies installed automatically, environment-specific startup scripts, health check behaviors. All of it needs to be made explicit. Run the container locally against a copy of your production configuration before you build any pipeline around it.
Push container images to Amazon ECR and tag them by git SHA. This gives you complete traceability: you know exactly which commit is running in production at any moment, and rollback is a matter of redeploying a previously tagged image rather than reverting code and rebuilding from scratch.
For the pipeline, AWS CodePipeline with CodeBuild is the native path: a source stage triggered by a GitHub or GitLab webhook, a build stage that runs the Docker build and pushes to ECR, and a deploy stage that updates the ECS service. GitHub Actions is a viable and often simpler alternative for teams already working in that ecosystem; AWS provides official actions for ECR login and ECS deployment.
Zero-downtime deployment in ECS requires explicit configuration. Set rolling update parameters to minimum healthy percent 100 and maximum percent 200. New tasks start before old tasks stop, so there is always full capacity serving traffic during a deployment. Combine this with ALB connection draining, which allows in-flight requests to complete before a task is deregistered, and you get clean deployments without dropped requests. Neither of these behaviors is on by default. Both matter.
Heroku's release phase, the mechanism that runs database migrations before traffic shifts to the new version, maps to either ECS task lifecycle hooks or a CodeBuild step that runs migrations before triggering the service update. Same pattern, explicit wiring instead of a Procfile entry.
Cutover Strategy That Keeps the Production App Running Throughout
The cutover is not a single event. It is a sequence of validated state transitions, each independently reversible until the final DNS TTL expires. Teams that treat it as a single event are the ones who end up making judgment calls under pressure at 2 a.m.
Before you touch DNS, complete a pre-cutover checklist without exception: all environment variables confirmed in Parameter Store or Secrets Manager, database replication lag at zero or near-zero, smoke tests passing against the AWS environment under a staging hostname, monitoring and alerting configured in CloudWatch. Most importantly, rollback thresholds defined explicitly before the window opens. "Error rate above X percent triggers immediate rollback to Heroku" is a decision you make with a clear head beforehand. It is not a judgment call you want to be making while production is degraded.
Reduce the DNS TTL on your production domain to 60 seconds at least 24 to 48 hours before the cutover window. This ensures fast propagation when you make the switch and fast rollback if something goes wrong.
Use weighted routing in Route 53 to shift traffic gradually: 5% to AWS, then 25%, then 100%. This lets you validate behavior under real production traffic before you commit fully. During this phase, run both environments in parallel and watch error rates, p95 latency, and database connection counts in CloudWatch.
The database write cutover is the most critical moment in the migration. Put the application in read-only or maintenance mode briefly, let DMS replication drain to zero lag, flip the DATABASE_URL environment variable in Parameter Store to point at RDS, restore write access, and verify. With DMS running cleanly, this window is seconds. Do not attempt it without having rehearsed it at least once against a non-production environment. Rehearsal is not optional; it is the only thing that tells you whether the plan is real.
After the cutover, keep Heroku dynos running at reduced scale for 48 to 72 hours. Keep Heroku add-ons active for at least one full billing cycle. Raise the DNS TTL again only once you have confidence in the new environment. Declare the migration complete after one full week of normal traffic patterns has run cleanly on AWS, not before.
Operational Responsibilities That Land on Your Team After the Migration
Moving to AWS transfers operational responsibility from Heroku's SRE team to your team. That is the trade for the cost savings, the compliance capabilities, and the control. It is a worthwhile trade for many teams, but only if you go in clear-eyed about what you are actually taking on.
Container image security is now your problem. Heroku applied buildpack updates automatically when vulnerabilities were patched. On AWS, ECR image scanning and CVE patching require a workflow you build and maintain: regularly rebuilding base images when security patches are released, redeploying affected services, tracking which images are running in which environments. This is not especially complex work, but it is persistent work, and it surprises teams who did not account for it.
ECS's control plane is managed by AWS, which removes a significant operational burden compared to running your own Kubernetes cluster. Task definition updates and ECS agent versions still require attention. The managed surface area is smaller, but it is not zero.
Cost monitoring requires active engagement in a way that Heroku's flat dyno pricing never demanded. Set up AWS Cost Explorer from day one, configure billing alerts with meaningful thresholds, and tag every resource by application and environment before it runs in production. Retroactive tagging is painful and reliably incomplete. The cost reductions teams document after migrating from Heroku come from deliberate rightsizing after migration, not from the migration event itself. Launch at two to three times your estimated capacity, collect two weeks of CloudWatch and Compute Optimizer data, then rightsize to match actual workload.
Autoscaling on ECS requires target tracking policies tied to CPU utilization, memory utilization, or ALB request count. Heroku's scaling model is simpler: add dynos, remove dynos. ECS autoscaling is more powerful and more precise, but correct configuration is required. Get the policies wrong and the application either over-provisions steadily, costing you the savings you migrated for, or under-provisions under load, producing the kind of degradation you left Heroku to escape. There is no default setting that is right for your application; you have to tune it, and you have to monitor whether the tuning is still correct as the workload changes.
The operational overhead described here is the real cost of the control and economics AWS offers. For teams with DevOps capacity, it is manageable and the economics are compelling. For teams without that capacity, the migration decision should account for the cost of building or acquiring it, or for using a managed platform that handles cluster management, CVE patching, and autoscaling on top of AWS infrastructure. Neither path is wrong. But only one of them is free.
