Moving a Node.js app with fly.toml process groups, a volume, Fly Postgres and fly secrets onto Encore in your own AWS account.
A Node.js app on Fly.io is described by its fly.toml: an [processes] section with app = "node dist/server.js" and worker = "node dist/worker.js", a [mounts] entry that attaches a volume at /data where invoices are written, a Fly Postgres app attached with fly postgres attach, and values set with fly secrets set. It starts to pinch when the volume pins the app to one Machine per region and makes scaling out a data problem, when the Postgres app needs the kind of upgrades and backups that a managed database would handle, or when a customer wants the backend in an AWS account.
This guide moves that kind of app onto Encore in your own AWS account. Each fly.toml section gets tested TypeScript that replaces it, followed by the manual AWS path and a data move through fly proxy. The generic Fly.io to AWS migration guide covers the platform side for any language.
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. Process groups, mounts and the attached database stop being deployment config and become objects in the service, encore run starts Postgres locally next to a broker and a bucket, and deploys create the AWS versions in your account.

Install the CLI and create an Encore app next to the existing code:
fly postgres attach set a DATABASE_URL secret on the app. The Encore version is a declaration with your schema as numbered SQL files, and migrations run on every deploy, which covers what a release_command running your migration tool used to do:
// 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" });
A worker process group on Fly runs the same image with a different command, and usually reads jobs from a Redis instance or polls a table. With Encore, the web side publishes to a topic and the worker side is a subscription in the same codebase:
// 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!;
},
);
// 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;
log.info("sending confirmation", { orderId, email });
await tx.commit();
},
});
Delivery is at least once, and the claim row keyed on the order id makes a second delivery return without sending again. A send that throws rolls the claim back, so the retry gets another chance.
The volume at /data becomes an object storage bucket, shared by every instance instead of attached to one Machine:
// shop/uploads.ts
import { api } from "encore.dev/api";
import { Bucket } from "encore.dev/storage/objects";
// Replaces files written to a mounted volume or a hosted blob store:
// a local bucket under `encore run`, S3 when deployed to AWS.
export const invoices = new Bucket("invoices");
export const uploadInvoice = api(
{ expose: true, method: "POST", path: "/invoices/:id" },
async ({ id, contents }: { id: string; contents: string }): Promise<{ key: string }> => {
const key = `invoice-${id}.txt`;
await invoices.upload(key, Buffer.from(contents), { contentType: "text/plain" });
return { key };
},
);
export const invoiceLink = api(
{ expose: true, method: "GET", path: "/invoices/:id/link" },
async ({ id }: { id: string }): Promise<{ url: string }> => {
const { url } = await invoices.signedDownloadUrl(`invoice-${id}.txt`, { ttl: 300 });
return { url };
},
);
To copy the files, open fly ssh console, archive /data, download the archive with fly ssh sftp get, and upload each file to the bucket with a short script that keeps the same key names.
Each fly secrets set value becomes a secret declared in the module that uses it, and set per environment type with encore secret set --type prod StripeWebhookSecret:
// 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}');
},
);
Add a timestamp tolerance check before production use, as Stripe's webhook docs recommend. Running everything locally, with a secret in .secrets.local.cue:
After you connect your AWS account and push, Encore provisions RDS, the SNS topic and SQS queue, the S3 bucket, the secrets in Secrets Manager and the Fargate service in your own account, and provisions an IAM role for the service. Region choice moves from primary_region in fly.toml to the environment's AWS region.

An agent working in this codebase no longer has to keep fly.toml, a Dockerfile and the code in sync: a new worker is a Subscription and a new bucket is a Bucket, both runnable under encore run in the same change.
Without Encore, each fly.toml section turns into AWS resources defined in Terraform or CDK:
app group as an ECS service on Fargate behind an Application Load Balancer, from a non-root image in ECR.worker group as a second ECS service on the same image with a different command, polling an SQS queue with a dead-letter queue.s3:GetObject and s3:PutObject on the web service's task role, since EBS volumes attach to one host and do not fit Fargate tasks that come and go.Fly Postgres runs as a Fly app, and fly proxy forwards a local port to it. Stop writes by scaling the app and worker groups to zero, dump in custom format and restore only the data into the Encore database:
Compare row counts, point DNS at the Encore environment, and keep the Fly apps scaled to zero until you are sure you do not need to roll back. The pg_restore docs cover restoring a subset of tables.
encore run and in your own AWS account.Fly.io alternatives covers other platforms, and How to deploy Node.js to AWS compares the AWS compute options.
Map each part of fly.toml to an AWS service: the app process group to a container service, a worker process group to a queue subscriber, volumes to S3, Fly Postgres to RDS and fly secrets to Secrets Manager. With Encore these are declared in the TypeScript code, run locally under encore run and are provisioned in your AWS account on deploy. Dump the database through fly proxy, restore it with pg_restore and switch DNS.
For files the app writes, such as uploads or generated documents, an S3 bucket. A Fly volume is attached to one Machine in one region, while a bucket is shared by every instance. Signed URLs let clients download files straight from the bucket.
Forward a local port to the database app with fly proxy, for example fly proxy 15432:5432 -a shop-db, and run pg_dump -Fc against localhost:15432 with the database user and password. Restore the dump with pg_restore into the new database.
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.