Oct 28, 202611 min read

How to Move a Node.js Backend from Heroku to AWS

Moving an Express app with a web dyno, a worker dyno, Heroku Postgres, config vars and Heroku Scheduler onto Encore in your own AWS account, one route at a time.

A Node.js app on Heroku usually has the same handful of pieces: a Procfile with web: node server.js and worker: node worker.js, a Heroku Postgres add-on that sets DATABASE_URL, a Redis or queue add-on between the two processes, config vars for the Stripe key, and a Heroku Scheduler entry that runs a cleanup script every hour. It starts to pinch when the database outgrows its plan, when each staging copy means another set of dynos and add-ons, or when a customer asks for the data to live in an AWS account you control.

This guide moves that kind of Express backend onto Encore in your own AWS account. It covers the mapping, tested code for each piece, a way to keep the unported Express routes running on Heroku while the new backend takes traffic, and the data move. The generic Heroku to AWS migration guide covers the platform side for any language, and Encore's Heroku migration notes cover other process types and add-ons.

Heroku to AWS mapping for a Node.js app

HerokuEncore on AWSManual AWS
web dynoEncore service on FargateECS service, task definition, load balancer
worker dynoSubscription on a Topic, backed by SNS and SQSSQS queue and a polling ECS service
Heroku PostgresSQLDatabase, provisioned as RDSRDS instance, subnet group, security groups
Config varssecret(), stored in Secrets ManagerSecrets Manager entries in the task definition
Heroku SchedulerCronJob calling an endpointEventBridge Scheduler and a task
Release phaseMigrations applied on deployA one-off task before the service update
Review appsPreview environments per pull requestA separate stack per branch

Encore: the Heroku pieces as declarations in the TypeScript code

Encore

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that provisions what the code declares in your own AWS or GCP account. The add-ons Heroku attached from outside become objects in the service, encore run starts Postgres locally with a local broker and the app, and on deploy each one becomes an AWS resource in your account, and Encore provisions an IAM role for the service.

Encore declares infrastructure in application code, runs it locally and provisions it in your AWS or GCP account
Infrastructure declared in application code, running locally under encore run and provisioned in your own AWS or GCP account on deploy.

Install the CLI and create an Encore app next to the Express code, so the old routes stay in the repository for reference while you port them:

~/shop
$ brew install encoredev/tap/encore$ encore app create

A service is a directory with an encore.service.ts file. The database replaces the Postgres add-on and reads its schema from SQL migration files in shop/migrations:

// shop/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("shop"); // shop/db.ts import { SQLDatabase } from "encore.dev/storage/sqldb"; // Replaces the managed Postgres add-on: a local database under `encore run`, // RDS when deployed to AWS. export const db = new SQLDatabase("shop", { migrations: "./migrations" });

The Express POST /orders route becomes a typed endpoint. Where the web dyno used to push a job onto a Redis queue for the worker dyno, it now publishes to a topic:

// shop/orders.ts import { api, APIError } from "encore.dev/api"; import { Topic } from "encore.dev/pubsub"; import { db } from "./db"; export interface Order { id: number; email: string; totalCents: number; status: string; } export interface OrderPlaced { orderId: number; email: string; } // Replaces the job queue the worker process read from. export const orderPlaced = new Topic<OrderPlaced>("order-placed", { deliveryGuarantee: "at-least-once", }); export const createOrder = api( { expose: true, method: "POST", path: "/orders" }, async (p: { email: string; totalCents: number }): Promise<Order> => { if (!p.email.includes("@") || p.totalCents < 0) { throw APIError.invalidArgument("a valid email and a non-negative total are required"); } const order = await db.queryRow<Order>` INSERT INTO orders (email, total_cents) VALUES (${p.email}, ${p.totalCents}) RETURNING id, email, total_cents AS "totalCents", status`; await orderPlaced.publish({ orderId: order!.id, email: order!.email }); return order!; }, );

The worker dyno becomes a subscription, and since delivery is at least once, the handler claims the order in a sent_emails table first and returns early when a redelivered message finds the row already there:

// shop/worker.ts import { Subscription } from "encore.dev/pubsub"; import log from "encore.dev/log"; import { db } from "./db"; import { orderPlaced } from "./orders"; // Replaces the worker process. The row in sent_emails makes a redelivered // message a no-op instead of a second email. const _ = new Subscription(orderPlaced, "send-confirmation", { handler: async ({ orderId, email }) => { await using tx = await db.begin(); const claimed = await tx.queryRow` INSERT INTO sent_emails (order_id) VALUES (${orderId}) ON CONFLICT DO NOTHING RETURNING order_id`; if (!claimed) return; // Call your email provider here, before the commit. log.info("sending confirmation", { orderId, email }); await tx.commit(); }, });

If the email call throws, the transaction never commits, the claim rolls back, and the retry sends again. The Scheduler entry becomes a cron job that calls a private endpoint, and the script it ran becomes the endpoint body:

// shop/jobs.ts import { api } from "encore.dev/api"; import { CronJob } from "encore.dev/cron"; import { db } from "./db"; // Only touches rows that are still pending, so a repeated run is harmless. export const expirePending = api( { expose: false, method: "POST", path: "/internal/expire-pending" }, async (): Promise<{ cancelled: number }> => { const rows = await db.queryAll` UPDATE orders SET status = 'cancelled' WHERE status = 'pending' AND created_at < now() - interval '24 hours' RETURNING id`; return { cancelled: rows.length }; }, ); // Replaces the scheduler entry on the old platform. const _ = new CronJob("expire-pending", { title: "Cancel orders left pending for a day", every: "1h", endpoint: expirePending, });

Cron jobs do not run under encore run, so you test the endpoint by calling it, and the schedule starts once the app is deployed. Config vars become secrets, set once per environment type with encore secret set --type prod StripeWebhookSecret. A Stripe webhook needs the raw request body to check the signature, which a raw endpoint gives you:

// shop/stripe.ts import { api } from "encore.dev/api"; import { secret } from "encore.dev/config"; import { createHmac, timingSafeEqual } from "node:crypto"; // Replaces the STRIPE_WEBHOOK_SECRET config var. const stripeWebhookSecret = secret("StripeWebhookSecret"); // A raw endpoint keeps the exact request body, which signature checks need. export const stripeWebhook = api.raw( { expose: true, method: "POST", path: "/webhooks/stripe" }, async (req, resp) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const body = Buffer.concat(chunks).toString("utf8"); // Stripe-Signature looks like t=<timestamp>,v1=<hex hmac>. const header = String(req.headers["stripe-signature"] ?? ""); const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2))); const expected = createHmac("sha256", stripeWebhookSecret()) .update(`${parts.t}.${body}`) .digest("hex"); const given = Buffer.from(parts.v1 ?? "", "hex"); if (given.length !== 32 || !timingSafeEqual(given, Buffer.from(expected, "hex"))) { resp.writeHead(400).end("bad signature"); return; } resp.writeHead(200, { "content-type": "application/json" }).end('{"received":true}'); }, );

In production you would also reject timestamps older than a few minutes, as Stripe's signature docs describe, or use the stripe package's constructEvent with the same raw body.

Keeping the unported routes on Heroku

You do not have to port every Express route before the switch. A fallback route with the path /!rest receives every request that no Encore endpoint matches, and this one forwards it to the Heroku app, whose URL is stored as a secret so each environment can point somewhere different:

// shop/legacy.ts import { api } from "encore.dev/api"; import { secret } from "encore.dev/config"; // Where the old app still runs while routes move over, for example // https://shop-legacy.herokuapp.com. Set per environment with `encore secret set`. const legacyBaseUrl = secret("LegacyBaseURL"); // Any request that no Encore endpoint matches is forwarded to the old app. export const fallback = api.raw( { expose: true, method: "*", path: "/!rest" }, async (req, resp) => { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk as Buffer); const hasBody = req.method !== "GET" && req.method !== "HEAD"; // Only relative paths are forwarded, so a request cannot point the proxy at another host. const target = req.url ?? "/"; if (/^(?:[a-z][a-z\d+.-]*:|\/\/)/i.test(target)) { resp.writeHead(400).end("absolute request targets are not supported"); return; } const upstream = await fetch(new URL(target, legacyBaseUrl()), { method: req.method, headers: { "content-type": String(req.headers["content-type"] ?? ""), authorization: String(req.headers["authorization"] ?? ""), }, body: hasBody ? Buffer.concat(chunks) : undefined, }); resp.writeHead(upstream.status, { "content-type": upstream.headers.get("content-type") ?? "application/octet-stream", }); resp.end(Buffer.from(await upstream.arrayBuffer())); }, );

The proxy forwards only the content type and authorization headers, and returns only the status and content type, so add any others your routes rely on, such as cookies on the request and Set-Cookie or Location on the response. Running the app with a local copy of the Express server behind the fallback shows both halves answering on one port:

~/shop
$ encore runYour API is running at: http://127.0.0.1:4000$ curl -X POST localhost:4000/orders -d '{"email":"ada@example.com","totalCents":5500}'{"id":1,"status":"pending","email":"ada@example.com","totalCents":5500}$ curl localhost:4000/products[{"sku":"mug","priceCents":1500}]$ curl -X POST localhost:4000/orders -d '{"email":"nope","totalCents":1}'{"code":"invalid_argument","message":"a valid email and a non-negative total are required",...}

/orders is answered by the new endpoint and /products by the Express app behind the fallback. When the last route has moved, delete legacy.ts and the LegacyBaseURL secret.

Deploying to AWS

Connect your AWS account, create an environment on AWS and push. Encore provisions the RDS database, the SNS topic and SQS queue, the secrets in Secrets Manager and the Fargate service in your own account, and applies the migrations on deploy. The AWS infrastructure docs list what gets created. Each pull request can also get its own preview environment, which takes the place of Heroku review apps.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

The database, topic and job live in the same files as the handlers, so a coding agent porting the next Express route writes the endpoint and any table or topic it needs in one change, then checks it against the Postgres and broker that encore run already has running.

The manual AWS path

Without Encore, the same pieces map onto AWS services you wire together yourself:

  • Build a container image of the Express app with a non-root user, push it to ECR, and run it as an ECS service on Fargate behind an Application Load Balancer.
  • Create an RDS for PostgreSQL instance in private subnets, with a security group that only the service's tasks can reach.
  • Store each config var in Secrets Manager and reference it from the task definition, and grant the task execution role secretsmanager:GetSecretValue on those secrets.
  • Replace the worker dyno with an SQS queue, a dead-letter queue and a second ECS service that polls it, with sqs:ReceiveMessage and sqs:DeleteMessage on its task role.
  • Replace Heroku Scheduler with EventBridge Scheduler running an ECS task on a cron expression.
  • Run migrations as a one-off task in the deploy pipeline before updating the service.

Each of these is usually a Terraform or CDK module, plus a separate copy for staging.

Moving the data

Heroku Postgres backups download in the pg_dump custom format that pg_restore reads. Stop writes for a few minutes: maintenance mode only stops web traffic, so also scale the worker dynos to zero and remove the Heroku Scheduler job before you take a fresh backup, and restore it into the new database. Encore has already applied the migrations, so restore only the data, using a connection string with write access from encore db conn-uri:

~/shop
$ heroku maintenance:on -a shop-legacy$ heroku ps:scale worker=0 -a shop-legacy$ heroku pg:backups:capture -a shop-legacy$ curl -o latest.dump "$(heroku pg:backups:url -a shop-legacy)"$ pg_restore --data-only --no-owner \ --dbname "$(encore db conn-uri shop --env=prod --write)" latest.dump

Check the row counts on both sides, reset the sequences if your restore skipped them, and then point the DNS record for your API at the Encore environment. Keep the Heroku app in maintenance mode for a day before deleting it, in case you need to compare data.

How to choose

  • Encore if you want the Heroku workflow of pushing code and getting a database, worker and schedule, with all of it running in your own AWS account and starting locally under encore run.
  • Manual AWS if your team already runs ECS with Terraform or CDK modules and wants the Express app to join that setup.

Express vs Encore compares the two programming models in more depth, and Heroku alternatives covers platforms that keep the app on someone else's infrastructure.

Frequently asked questions

How do I migrate a Node.js app from Heroku to AWS?

Map each Heroku piece to an AWS one: the web dyno to a container service, the worker dyno to a queue subscriber, Heroku Postgres to RDS, config vars to Secrets Manager and Heroku Scheduler to a scheduled job. With Encore you declare the database, topic, cron job and secrets in the TypeScript code, forward routes you have not ported yet to the Heroku app with a fallback endpoint, and move the data with pg_dump and pg_restore before switching DNS.

What replaces a Heroku worker dyno on AWS?

A queue and a consumer. On plain AWS that is usually SQS with an ECS service polling it. For fan-out to several consumers, SNS in front of one SQS queue per consumer.

Can I move a Heroku app to AWS without rewriting every route at once?

Yes. Put the new backend in front and forward any request it does not handle to the Heroku app. Routes then move over one at a time, and the fallback shrinks until you can delete it.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders