Infra Stack Review

Heroku to AWS Migration Walkthrough for Node.js Apps

Cut time in half with upfront documentation work before touching a single AWS service.

· 9 min read · Updated
Cover illustration for “Heroku to AWS Migration Walkthrough for Node.js Apps”
PaaS Migration Guides · August 12, 2026 · 9 min read · 1,996 words

Migration speed gets decided before the first container builds. The highest-leverage work in the entire process is a documentation sprint: every config var recorded, every add-on catalogued, every worker process assigned an owner. Teams that skip this step don't save time. They spend it later, under worse conditions, with production traffic on the line.

Every config var needs a mapped destination in AWS. Most belong in Secrets Manager or Parameter Store. None of these should sit as plaintext environment variables inside an ECS task definition, because if you do, anyone with task-definition read access can see the secret in plain text. Going through this exercise var by var forces a team to confront which values are actually secrets, which are just configuration, and which nobody can explain anymore.

Technical debt lengthens the migration timeline. Undocumented add-ons, config vars with no clear purpose, worker processes with no owner: each one adds friction to the migration, and the research on more complex setups puts the range at two to four months for teams carrying this kind of baggage. Clean inventories cut that time substantially. The inventory isn't paperwork sitting beside the real migration work. It functions as the risk register: any service without a documented AWS equivalent is a blocker that stops a cutover cold, treated as such before launch rather than after.

That inventory also surfaces the true shape of a team's infrastructure dependencies: which pieces are add-ons that need direct AWS replacements, which are custom-built, and which can be dropped entirely once nobody needs them anymore. Platforms that unify add-on integration into a single deployment model, where databases, secrets, and background workers all show up as composable services rather than a pile of separate AWS decisions, can shrink this research phase considerably, because the migration mental model is already flat going in.

Choosing the AWS compute target for a Node.js app in 2026

Two deprecations in 2026 narrow this decision before any real evaluation starts. AWS App Runner, long the lowest-friction way to get a container running on AWS, moved to maintenance mode in 2026 and closed to new customers as of April 30, 2026. Any migration plan built around App Runner needs a rewrite. Separately, AWS retired the Node.js 20 Elastic Beanstalk platform branch (alongside Python 3.9 and Ruby 3.2, all on Amazon Linux 2023) on August 13, 2026. Teams that planned to use Beanstalk as a bridge for a Node.js 20 app have to account for that retirement directly.

With App Runner off the table, the real choice sits between Elastic Beanstalk and ECS Fargate.

Beanstalk still works for a specific shape of application: a monolith running a single HTTP process, with no complex networking requirements and no ambition to split into services. It keeps the operational surface small. A team pushes code, and Beanstalk handles the provisioning underneath, so there's little infrastructure to reason about day to day.

ECS Fargate asks more up front and gives more back. It offers full container orchestration, granular control over VPC and networking, support for coordinating multiple containers, advanced scaling behavior, and the ability to define background worker tasks alongside the main app. Setup takes longer and the learning curve runs steeper, but Fargate has become the default foundation for containerized Node apps that are built to grow.

The heuristic is straightforward. An app with background workers, service-to-service communication, compliance networking requirements, or a roadmap toward microservices belongs on ECS Fargate. An app without any of that can stay on Beanstalk and avoid the extra operational weight.

For teams weighing the operational load that Fargate brings, some platforms now offer a managed Fargate abstraction that handles task definition configuration, image updates, and scaling logic, without requiring a team to learn the full ECS API surface first. That trades some configurability for a faster path to a first deployment, which matters for teams that want Fargate's architecture without hiring for it immediately.

Containerizing the Node.js app for ECS Fargate

Once ECS Fargate is the target, the Dockerfile and task definition become the real work. Start with an official Node.js image pinned to a specific minor version. Never pull latest. If you pin the version, a rebuild six months from now gives you the same runtime behavior it does today, instead of silently picking up whatever the Node.js project shipped that week.

Distroless images go further still, stripping out the shell and package manager entirely, though that tradeoff means keeping a separate debug image on hand for troubleshooting, since there's no shell to exec into when something breaks in production.

Structure the Dockerfile as a multi-stage build. One stage installs dependencies and produces build artifacts. A second, leaner stage copies only what the app needs to run, and that stage becomes the actual runtime image. This keeps the final image free of build tools, dev dependencies, and source files that have no business being in production.

Memory allocation deserves close attention, because it's the gotcha that catches teams who've containerized plenty of other languages but not Node specifically. ECS task memory needs deliberate headroom above what the app is expected to use. Node apps require NODE_OPTIONS=--max-old-space-size set explicitly, sized to the task's memory allocation. Without that setting, V8 doesn't know about the container's memory ceiling. The heap grows past it, and the process gets killed by an out-of-memory error at the container level rather than failing cleanly with a JavaScript heap error that points a developer at the actual problem. Set the heap limit below the task's memory limit, then watch the memory chart after cutover and adjust from there.

Building the CI/CD pipeline from GitHub to ECR to ECS

The pipeline that gets code from a GitHub repo to a running ECS task follows a predictable shape, and that shape determines how failures are caught and how easily tools inside it can later be swapped out.

The pattern worth building toward is GitHub Actions with OIDC federation to AWS. Instead of storing long-lived AWS access keys as GitHub secrets, where a leaked secret means a leaked key that works until someone manually revokes it, the pipeline assumes an IAM role through OIDC at run time. That role gets scoped to the minimum permissions the pipeline actually needs: pushing to ECR, deploying to ECS, and iam:PassRole, nothing broader. A compromised pipeline run under this setup has a narrow blast radius instead of standing credentials to the whole account.

The stages run in a fixed order, and the order is deliberate:

  • Build and test the Node.js app first: unit tests, lint, type checks. Fail fast here, before anything touches AWS.
  • Build the Docker image and push it to ECR with two tags: the Git commit SHA, which is immutable and gives traceability back to the exact commit that produced the image, and latest, for convenience.
  • Update the ECS task definition with the new image URI.
  • Trigger a rolling deployment in ECS: new tasks start, pass health checks, and only after that do the old tasks drain. The deployment is zero-downtime by default as long as health checks are configured correctly.

Cost governance belongs inside this pipeline as part of the normal flow, before deployment happens. Embedding cost estimation tooling, such as tools like Infracost surfacing estimates directly in pull requests, catches spend impact before infrastructure actually gets provisioned. This is becoming standard practice specifically because it flags a deployment that would materially raise spend before Finance discovers the bill weeks later, when the fix is a lot more painful than a comment on a pull request.

Migrating the database without losing data or sleep

The database carries the most anxiety of any step in this sequence, and for good reason: it's the one piece of the migration where a mistake destroys something that can't be rebuilt from a Dockerfile. It also follows a standard, repeatable procedure, so data loss here is a process failure, not a technical inevitability.

The core mechanics: dump the source database, then restore it to RDS using pg_restore into the target database. Before you cut over, validate row counts and spot-check critical tables, and compare source against target directly rather than assuming the restore completed cleanly.

For teams running logical replication to minimize downtime, there's a detail that catches people during cutover: logical replication does not replicate sequence values. After the data moves, a Node.js script needs to compute the maximum value of each identity column on the RDS target and run setval() to advance the sequence to the correct next value. Skipping that step means the next inserted row collides with an ID that already exists.

The cutover window itself is the moment to put the app into maintenance mode, or use feature flags to hold back writes. A brief period of read-only access carries far less risk than attempting a hot cutover without logical replication already in place and tested. If you reduce DNS TTL to 60 seconds ahead of the cutover window, the traffic switch itself stays fast once the database side is confirmed. Moving service by service, rather than flipping everything at once, keeps the blast radius small if any one piece doesn't behave as expected: start with the least critical service, confirm it fully in the new environment, then move to the next.

PaaS alternatives for teams not ready for raw AWS

The strongest argument against migrating to raw AWS has nothing to do with infrastructure cost or capability. It's developer experience regression: when the deployment workflow gets harder, engineering velocity drops, and the migration fails on its own terms regardless of how sound the underlying infrastructure turns out to be. A team that trades a five-minute deploy for a forty-minute one has lost something real, even if the new setup is cheaper and more configurable.

Porter sits in the middle of this tradeoff directly. It deploys into a team's own AWS account rather than shared, multi-tenant infrastructure. A team gets its own VPC, private networking, and a compliance story suited to SOC 2 or HIPAA requirements, without first building out DevOps expertise to get there. Shared-tier infrastructure typically rules out VPC control, private networking, and static IP support, and those gaps block a specific class of enterprise and compliance requirement. Compliance frameworks like SOC 2 and HIPAA impose audit trail and network isolation requirements that are genuinely harder to satisfy on shared-tenant infrastructure, but neither framework actually mandates that a team own its own infrastructure. Dedicated infrastructure just makes the isolation story cleaner, so it lowers compliance risk as a result. Porter's positioning, owned-cloud infrastructure with the ergonomics of a managed PaaS, is built for teams that want that isolation story without giving up the deploy experience they're used to.

Railway occupies a related but different space. It leans harder into developer experience than Render, which instead trades some of that DX polish for flatter, more predictable pricing. Railway runs on usage-based pricing and supports services, databases, and cron jobs from a single dashboard, which suits teams with simple deployment patterns who want to stay close to a git-push workflow without managing AWS primitives directly.

Cloudflare Workers fits a narrower but real need: teams currently on Vercel looking to move for cost control or private networking reasons. Workers offer sub-millisecond cold starts and a generous free tier, so they make a strong option for edge API layers and frontend routing. They aren't built to replace a full backend with stateful services, so teams adopting Workers tend to pair them with something else for the stateful parts of the system.

The split documented across the 2026 PaaS landscape comes down to what a team is optimizing for. Teams that want developer experience preserved gravitate toward managed PaaS options. Teams that want cost control and maximum configurability at scale move toward ECS Fargate directly. Porter's bet is that those two priorities don't have to be in tension: owned-cloud infrastructure paired with managed-PaaS ergonomics gives a team the control of its own AWS account without asking it to build a platform team first.

Sources

  1. aws.amazon.com
  2. Deploying a Dockerized Node.js Application on AWS ECS using Fargate
  3. CI/CD with GitHub Actions: Deploying a node.js app to Amazon Elastic Container Service (ECS)

More in PaaS Migration Guides