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.
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.

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:
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.
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:
/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.
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 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.
Without Encore, the same pieces map onto AWS services you wire together yourself:
secretsmanager:GetSecretValue on those secrets.sqs:ReceiveMessage and sqs:DeleteMessage on its task role.Each of these is usually a Terraform or CDK module, plus a separate copy for staging.
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:
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.
encore run.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.
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.
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.
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.