Oct 28, 20269 min read

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

Moving a Node.js web service, background worker, cron job, persistent disk and Render Postgres from a render.yaml Blueprint onto Encore in your own AWS account.

A Node.js backend on Render is usually described by a render.yaml Blueprint with three or four entries: a web service running node dist/server.js, a background worker reading jobs from a Redis-backed queue, a cron job service that runs a cleanup script every hour, a persistent disk mounted at /var/data for uploaded invoices, and a Render Postgres database wired in through fromDatabase. It starts to pinch when the disk keeps the web service on a single instance, when every preview and staging copy needs its own worker and database, or when the company's other systems already live in an AWS account.

This guide moves that kind of backend onto Encore in your own AWS account. Each Blueprint entry gets a section with the TypeScript that replaces it, tested with encore run, followed by the manual AWS path and the data move. The generic Render to AWS migration guide covers the platform side for any language.

Render to AWS mapping for a Node.js app

render.yaml entryEncore on AWSManual AWS
type: webEncore service on FargateECS service behind a load balancer
type: workerSubscription on a Topic, backed by SNS and SQSSQS queue and a polling ECS service
type: cronCronJob calling an endpointEventBridge Scheduler and a task
disk:Bucket, provisioned as S3S3 bucket and an IAM policy
databases:SQLDatabase, provisioned as RDSRDS instance in private subnets
envVarGroups:secret(), stored in Secrets ManagerSecrets Manager entries per task
Preview environmentsPreview environments per pull requestA separate stack per branch

Encore: each Blueprint entry as a declaration 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. Where a Blueprint lists services and their wiring in YAML next to the code, Encore reads the same information from the code itself, starts Postgres, a broker and a bucket locally under encore run, and creates RDS, SNS and SQS, and S3 in your account when you deploy.

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 database entry

The databases: entry and the fromDatabase reference that handed the web service its DATABASE_URL become one line. The migrations directory holds the SQL your old migration tool ran, as numbered files such as 1_create_orders.up.sql:

// 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 web service and the worker

The Express routes become typed endpoints in the same service, and where the web service used to enqueue a job for the worker, it 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!; }, ); export const getOrder = api( { expose: true, method: "GET", path: "/orders/:id" }, async ({ id }: { id: number }): Promise<Order> => { const order = await db.queryRow<Order>` SELECT id, email, total_cents AS "totalCents", status FROM orders WHERE id = ${id}`; if (!order) throw APIError.notFound("order not found"); return order; }, );

The worker entry becomes a subscription. It no longer needs its own start command or its own copy of the environment variables, because it lives in the same codebase and gets the same database handle:

// 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(); }, });

With the order id as the primary key of sent_emails, a message delivered twice finds the row and returns, and a send that throws rolls the claim back so the retry tries again.

The cron job entry

A Render cron job is a separate service with its own build, start command and copy of the environment. In Encore it becomes a cron job that calls a private endpoint in the service that owns the data:

// 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 call the endpoint directly to test it locally. The schedule starts once the app is deployed.

The persistent disk

Files written to /var/data move to a bucket, which any number of instances can read and write. Signed URLs let the browser download an invoice straight from storage instead of streaming it through the service:

// 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 existing files, run a one-off script that reads each file from the disk over a Render shell and uploads it with the same key scheme, or sync it to S3 with the AWS CLI after the bucket exists. The object storage docs cover public buckets and upload URLs.

Running the service locally exercises the database, the worker and the bucket together:

~/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/orders/99{"code":"not_found","message":"order not found",...}$ curl -X POST localhost:4000/invoices/7 -d '{"contents":"hello"}'{"key":"invoice-7.txt"}$ curl "$(curl -s localhost:4000/invoices/7/link | jq -r .url)"hello

The log shows one sending confirmation line for order 1 from the subscriber.

Environment groups and deploys

Values from an envVarGroups: entry become secrets declared with secret("StripeKey") and set per environment type with encore secret set --type prod StripeKey. After you connect your AWS account and push, Encore provisions RDS, the SNS topic and SQS queue, the S3 bucket, the secrets and the Fargate service in your own account, and provisions an IAM role for the service. Pull requests get preview environments much like Render's.

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.

With the Blueprint gone, an AI coding agent that adds a second worker or a new bucket does it in TypeScript next to the handler that needs it, and runs the result under encore run before the pull request opens.

The manual AWS path

Without Encore, each Blueprint entry becomes AWS resources you define yourself, typically in Terraform or CDK:

  • The web service as an ECS service on Fargate behind an Application Load Balancer, from an image in ECR built with a non-root user.
  • The worker as an SQS queue with a dead-letter queue and a second ECS service that polls it, with sqs:ReceiveMessage and sqs:DeleteMessage on its task role.
  • The cron job as an EventBridge Scheduler schedule running an ECS task.
  • The disk as an S3 bucket with s3:GetObject and s3:PutObject granted to the web service's task role, and presigned URLs from the AWS SDK.
  • The database as an RDS for PostgreSQL instance in private subnets, with the password in Secrets Manager and secretsmanager:GetSecretValue on the task execution role.

The environment group values go into Secrets Manager and are referenced from each task definition that needs them.

Moving the data

Render Postgres accepts standard pg_dump connections on its external URL. Suspend the web service, the worker and the cron job so nothing writes, dump in custom format, and restore only the data into the Encore database, whose schema the migrations already created:

~/shop
$ pg_dump -Fc --no-owner "$RENDER_EXTERNAL_DATABASE_URL" -f shop.dump$ pg_restore --data-only --no-owner \ --dbname "$(encore db conn-uri shop --env=prod --write)" shop.dump

The pg_dump docs cover excluding tables or dumping only some schemas. Compare row counts, point DNS at the new environment, and keep the Render database around until you are sure you do not need it.

How to choose

  • Encore if you want the Blueprint's web, worker, cron and database entries to live in the TypeScript code, start locally under encore run, and run in your own AWS account.
  • Manual AWS if your team already maintains ECS modules in Terraform and wants the Node.js app to slot into them.

Render alternatives covers platforms that keep the app on someone else's infrastructure, and Express vs Encore compares the programming models.

Frequently asked questions

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

Go through the render.yaml Blueprint one entry at a time: the web service becomes a container service, the background worker becomes a queue subscriber, the cron job becomes a scheduled job, a persistent disk becomes an S3 bucket and Render Postgres becomes RDS. With Encore, each of these is declared in the TypeScript code, runs locally under encore run and is provisioned in your AWS account on deploy. Copy the data with pg_dump and pg_restore and switch DNS last.

What replaces a Render persistent disk on AWS?

For files your app writes and serves, such as uploads or generated PDFs, an S3 bucket. A bucket works across any number of instances, unlike a disk attached to one, and signed URLs let clients download files without going through the app.

What replaces a Render cron job on AWS?

EventBridge Scheduler, which can run an ECS task, invoke a Lambda function or call an HTTP endpoint on a cron or rate schedule.

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