Heroku to AWS Migration Walkthrough
Learn the sequencing and gotchas that determine whether your migration succeeds.

Moving off a platform-as-a-service onto AWS means trading a handful of abstractions for dozens of explicit decisions. Every layer that used to be invisible, compute, networking, storage, managed services, now has a name, a console page, and a configuration file that has to be filled out correctly before anything runs.
AWS lays these layers out in the open. Compute splits across EC2, ECS, EKS, Lambda, Fargate, and a few other options. Networking means setting up VPCs, subnets, and security groups by hand. Storage spans S3, EBS, EFS, and more specialized services like FSx or S3 Glacier. Managed services, the database and caching and queuing layer, live under names like RDS, ElastiCache, and SQS. None of these configure themselves, and none of them share defaults with each other the way a platform-as-a-service bundles things together.
That's where teams get into trouble. A team that containerizes its app and points it at AWS without first mapping each piece will hit broken database connections, missing environment variables, and security group rules that are either wide open or too restrictive to let anything talk to anything else, none of which a design review will catch.
The fix is sequencing. Compute strategy gets decided first, because everything else depends on how the application runs. Database migration comes second, because it's the step with the least room for error. CI/CD gets rebuilt last, once there's a stable environment worth deploying into repeatedly. Treating the move as a single cutover event, instead of three ordered decisions, is where most of the pain comes from. The rest of this walkthrough follows that order, starting with the compute decision everything else depends on.
Choosing between ECS Fargate and EKS for the compute layer

A dyno-based app translates most directly into either ECS tasks or EC2 instances, and for most teams coming from a platform-as-a-service, ECS Fargate is the closest match to what they're used to. A container gets defined, CPU and memory get specified, and AWS runs it. There's no server to patch and no instance to babysit, which is close to how a dyno felt.
ECS and EKS both orchestrate containers, and Fargate is a compute engine that works underneath either one, so the real decision is whether the team needs Kubernetes. If the answer is yes, EKS is the path. If the answer is no, ECS is simpler and avoids an entire ecosystem of configuration the team doesn't need. From there, a second question decides the billing model: manage the underlying nodes, or let Fargate manage them. ECS running on EC2 is the cheapest combination, since it skips any control plane charge and allows full use of Spot Instances and Savings Plans. ECS on Fargate costs 40 to 60% more than ECS on EC2, but it means nobody has to think about node pools. EKS on EC2 adds a control plane charge of $73 a month on top of ECS and buys access to the Kubernetes ecosystem. EKS on Fargate is the most expensive combination of the four, but also the simplest way to run Kubernetes without managing nodes.
None of these are wrong choices. A small team without Kubernetes experience, migrating an app that doesn't need multi-cloud portability, usually has nothing to gain from EKS's added complexity. A team that already runs Kubernetes elsewhere, or that needs to avoid being tied to any single cloud's orchestration model, has good reason to pay the EKS premium. The decision should get made once, early, and in writing, because reversing it later means re-architecting the deployment pipeline that gets built around it.
Whichever path gets chosen, the infrastructure should be defined as code from the first resource created, not after the environment is already running. JavaScript, TypeScript, Python, Java, Go, and C# teams have a natural fit in AWS CDK. Teams operating across more than one cloud provider are usually better served by Terraform. Teams planning to stay entirely inside AWS can use CloudFormation directly. Picking one of these three before any resource gets provisioned by hand avoids rework later. Going back and writing infrastructure-as-code after a VPC, a cluster, and a database have already been clicked into existence through the console is a slow, error-prone project that nobody budgets for and everybody ends up doing anyway.
Migrating the database without losing data or accepting downtime

The database cutover is the step in this migration with the least tolerance for error, so it should be treated as its own project rather than a line item inside the compute migration. The first move is loading the data itself. AWS Database Migration Service handles the bulk load for larger databases or longer migration windows, while a straightforward pg_dump and pg_restore often gets the job done faster for smaller ones. Which tool fits depends on the size of the database and how much downtime the team can tolerate while the load runs.
Loading the data isn't the same as trusting the data. Before any write traffic touches the new environment, row counts need to match between the old and new databases, indexes need to check out, and the application's actual read queries need to run against RDS and return correct results. Skipping this validation step to save a few hours is how teams end up debugging data integrity problems weeks after launch, instead of catching them the same afternoon.
Once the data checks out, the cutover itself follows a specific sequence. The application goes into maintenance mode, or a feature flag drains writes so nothing new is landing on the old database. Replication lag needs to read zero before anything else happens. Then the DATABASE_URL environment variable (or its equivalent in Secrets Manager) gets flipped to point at RDS, and the application comes back online. Each of these steps depends on the one before it finishing cleanly, and skipping ahead is how teams end up writing to a database that's no longer the one anyone's reading from.
The most common failure after cutover is a mismatch: the application is still pointing at the old DATABASE_URL or REDIS_URL, so requests fail with errors that look exactly like application bugs. Engineers end up debugging code that was never broken, because the actual problem is a stale environment variable sitting one layer below where anyone's looking. Catching this means checking environment variables first, before touching a line of application code, any time something breaks right after a cutover.
Security groups cause a quieter version of the same problem. ECS tasks need to reach RDS on port 5432, and the default RDS security group doesn't allow that connection out of the box. The inbound rule has to reference the ECS task's security group directly, not a CIDR range, because task IPs rotate constantly and a rule written against a range of addresses will eventually stop matching the tasks it was meant to allow. Getting this rule wrong doesn't throw an obvious error; it just means the application can't reach its own database, which looks like a connectivity problem until someone checks the security group configuration specifically.
With compute running and the database migrated cleanly, the deployment workflow is the last piece standing between the team and a cutover that holds. CI/CD has to get rebuilt here, because without it, every future deploy to the new environment means someone manually pushing a container and hoping nothing drifts.
The AWS-native version of that pipeline uses CodePipeline to orchestrate the stages, CodeBuild to compile the image and run the test suite, ECR to store the built container, and either CodeDeploy or a direct ECS service update to ship it. All of this triggers off a webhook from GitHub or GitLab, so a merge to the deployment branch is what starts the pipeline moving.
In practice, that pipeline runs in four stages. A merge to the deployment branch fires the webhook that starts CodePipeline. CodeBuild then runs the test suite, builds the Docker image, tags it with the commit SHA, and pushes it to ECR. From there, CodePipeline triggers an ECS service update using the new image tag, and ECS performs a rolling update, replacing old tasks with new ones while respecting the health check grace period so traffic doesn't route to a task before it's actually ready. A final smoke test stage in CodeBuild hits a health endpoint after deploy and rolls back automatically if that check fails.
Every secret this pipeline touches, database credentials, API keys, anything sensitive, belongs in Secrets Manager or Parameter Store, injected into the task at startup. Baking a secret into the image or hardcoding it into the task definition is the single most common source of post-cutover errors, and like the environment variable problem above, it produces failures that look like code bugs instead of what they actually are.
None of this requires buying into AWS's own tooling end to end. Teams that already run GitHub Actions or GitLab CI can wire either one directly into ECS using the aws-actions/amazon-ecs-deploy-task-definition action, using ECR and ECS as the only AWS-native pieces in the chain. CodePipeline and CodeBuild are a convenient default, not a requirement.
Environment hygiene, cost controls, and the compliance baseline after cutover
Cutover day is the point where a new set of operational habits needs to start, and the next 30 to 90 days decide whether the environment stays clean or slowly turns into the kind of unmanaged sprawl AWS is famous for.
Logging is the first habit to lock in. Application logs should flow to CloudWatch Logs through the awslogs driver configured directly in the ECS task definition, and a retention policy needs to be set on that log group from the start. Without one, logs accumulate indefinitely and the storage bill grows with them, quietly, for months before anyone notices.
Cost control starts with tagging. Every resource should carry an Environment tag, an Owner tag, and a cost-allocation tag from the moment it's created. Tagging a few dozen resources on day one takes an afternoon. Going back to tag hundreds of resources after they've multiplied across a growing account is a multi-day project that nobody wants to own, and it usually gets delayed until a billing surprise forces the issue. That billing surprise is avoidable: AWS doesn't alert on overage by default, so a billing alarm needs to be set manually, at a threshold comfortably above expected monthly spend, or the first sign of a cost problem will be the invoice itself.
Beyond tagging and alarms, the instance-purchasing decisions carry real savings for the resources that run continuously. RDS Reserved Instances can cut costs by as much as 69% compared to on-demand pricing. Database Savings Plans cover RDS provisioned instances at roughly 20 to 35% off, depending on the deployment type, though for ElastiCache specifically, that Savings Plans coverage is limited to Valkey instances. Redis and Memcached on ElastiCache still need Reserved Instances to get a comparable discount. Spot Instances have a place too, but only for batch jobs and background work that can tolerate interruption. Running the primary web process on Spot trades a cost saving for the risk of losing capacity at the exact moment traffic needs it most.
Teams most often defer compliance, and deferring it costs more than deferring anything else. AWS CloudTrail needs to be enabled in every region from day one, because it's the audit log that both SOC 2 and HIPAA require, and it cannot be turned on retroactively to cover a gap that's already passed. Alongside CloudTrail, AWS Config tracks configuration changes over time, GuardDuty watches for threats, and Security Hub pulls all of these findings into one place. These three services, together with CloudTrail, are the first things a compliance auditor checks, and standing them up after an audit is already scheduled turns a day of configuration into months of engineering work spent reconstructing a history that should have been logging itself the whole time. The fix is scheduling a compliance and legal review of the architecture in the second week of the migration, not the eighteenth, so the controls that need to exist from day one actually do. Teams that want SOC 2 or HIPAA posture without assembling each of these services by hand can use Porter's one-click compliance tooling, which configures the required AWS controls as part of setting up the environment rather than treating compliance as a separate project bolted on later.
The same logic applies to the deployment pipeline itself. Teams that would rather not wire CodePipeline, CodeBuild, and ECR together manually can look at platforms like Porter, which handle that orchestration layer, pipeline, secret injection, rolling deploys, health checks, inside the team's own AWS account, delivering something close to the deployment simplicity of a platform-as-a-service without giving up ownership of the underlying infrastructure.
By the end of this process, the team holds something a platform-as-a-service never gave them: direct ownership of the infrastructure, a direct line to AWS for pricing conversations as usage grows, and a foundation that can absorb compliance requirements, multi-region deployment, or GPU workloads later without a second migration. The work of mapping services, sequencing the cutover, and rebuilding CI/CD is what makes that ownership real instead of theoretical.


