Oct 28, 20269 min read

How to Move a Node.js Backend from Fly.io to AWS

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.

Fly.io to AWS mapping for a Node.js app

Fly.ioEncore on AWSManual AWS
app process groupEncore service on FargateECS service behind a load balancer
worker process groupSubscription on a Topic, backed by SNS and SQSSQS queue and a polling ECS service
[mounts] volumeBucket, provisioned as S3S3 bucket and an IAM policy
Fly Postgres appSQLDatabase, provisioned as RDSRDS instance in private subnets
fly secrets setsecret(), stored in Secrets ManagerSecrets Manager entries per task
release_commandMigrations applied on deployA one-off task in the pipeline

Encore: fly.toml sections as declarations in the 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. 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.

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 existing code:

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

The attached Postgres app

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" });

The app and worker process groups

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

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.

Secrets

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:

~/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 -X POST localhost:4000/webhooks/stripe -H "stripe-signature: t=$T,v1=$SIG" -d "$BODY"{"received":true}$ curl -X POST localhost:4000/webhooks/stripe -H "stripe-signature: t=$T,v1=00" -d "$BODY"bad signature$ curl -X POST localhost:4000/invoices/7 -d '{"contents":"hello"}'{"key":"invoice-7.txt"}

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.

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.

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.

The manual AWS path

Without Encore, each fly.toml section turns into AWS resources defined in Terraform or CDK:

  • The app group as an ECS service on Fargate behind an Application Load Balancer, from a non-root image in ECR.
  • The worker group as a second ECS service on the same image with a different command, polling an SQS queue with a dead-letter queue.
  • The volume as an S3 bucket with 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 as an RDS for PostgreSQL instance, with automated backups and the password in Secrets Manager.
  • Secrets as Secrets Manager entries referenced from each task definition.

Moving the data

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:

~/shop
$ fly scale count app=0 worker=0 -a shop$ fly proxy 15432:5432 -a shop-db &$ pg_dump -Fc --no-owner "postgres://postgres:$PG_PASSWORD@localhost:15432/shop" -f shop.dump$ pg_restore --data-only --no-owner \ --dbname "$(encore db conn-uri shop --env=prod --write)" shop.dump

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.

How to choose

  • Encore if you want process groups, volumes and the database to become TypeScript declarations that run locally under encore run and in your own AWS account.
  • Manual AWS if your team already runs ECS and RDS from Terraform modules and wants the Node.js app to join them.

Fly.io alternatives covers other platforms, and How to deploy Node.js to AWS compares the AWS compute options.

Frequently asked questions

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

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.

What replaces a Fly.io volume on AWS?

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.

How do I export a Fly Postgres database?

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.

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