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

Install the CLI and create an Encore app next to the existing code:
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 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.
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.
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:
The log shows one sending confirmation line for order 1 from the subscriber.
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.

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.
Without Encore, each Blueprint entry becomes AWS resources you define yourself, typically in Terraform or CDK:
sqs:ReceiveMessage and sqs:DeleteMessage on its task role.s3:GetObject and s3:PutObject granted to the web service's task role, and presigned URLs from the AWS SDK.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.
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:
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.
encore run, and run in your own AWS account.Render alternatives covers platforms that keep the app on someone else's infrastructure, and Express vs Encore compares the programming models.
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.
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.
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.