Updated Nov 9, 202613 min read

Deploy an Express App to Your Own AWS Account with Postgres and a Queue, Without Terraform

Keep your Express routes, add RDS Postgres, an SNS and SQS queue and a cron job as declarations in the code, and deploy to your own AWS account with git push.

Express backends that outgrow a single process usually pick up Postgres for orders and a queue for confirmation emails, then spend the next sprint wiring RDS, SQS, IAM and a worker by hand. The route code is the easy part; keeping local Docker Compose, staging and production in sync is the rest.

This guide uses a small orders API: POST /orders writes a row and puts a message on a queue, a worker sends the confirmation email, and an hourly job expires unpaid orders. It covers the hand-built AWS layout first, then the same shape with Encore alongside Express.

What are the ways to deploy Express to AWS with Postgres and a queue?

RankOptionWhat you write besides the appLocal Postgres and queueBest for
1Encore alongside ExpressNothing: the database, queue and cron are declarations in the appYes, encore runTeams that want their own AWS account without owning IaC
2Terraform or AWS CDKHCL or a CDK app for VPC, RDS, SQS, ECS and IAMNoDocker Compose by handAWS-only teams that already maintain IaC
3SSTsst.config.ts with componentsPartly, through sst dev against real AWSExisting SST users
4Elastic Beanstalk with RDS and SQSConsole clicks or .ebextensions, plus SQS and IAM by handNoOne small app, few changes
5ECS or App Runner wired by handTask definitions, security groups, IAM policies, queue wiringNoLearning how the pieces fit

Options 2 to 5 all work, and each adds a second thing to maintain: a Terraform module, a CDK app, a config file or a set of console settings that has to change whenever the app needs a new queue.

What does an Express app need on AWS?

The app needsOn AWSWith Encore alongside Express
A place to run the Express serverECS on Fargate, App Runner, load balancer, VPCProvisioned from the app, Fargate or EKS
PostgresRDS, subnet group, security group, credentialsnew SQLDatabase(...)
A queue between the API and a workerSQS (and SNS for fan-out), IAM for both sidesnew Topic(...) and new Subscription(...)
A scheduled jobEventBridge Scheduler and a task, or a cron containernew CronJob(...), Encore platform Managed
API keysSecrets Manager plus task definition wiringsecret(...), stored in Secrets Manager

How do you build it by hand on AWS?

The hand-built path keeps Express as a plain Node process. You provision RDS Postgres, an SQS queue, a Fargate service (or App Runner) and the IAM that ties them together, usually with Terraform or CDK, and you run migrations and the worker yourself.

A minimal Terraform sketch for the data plane looks like this:

resource "aws_db_instance" "shop" { identifier = "shop" engine = "postgres" instance_class = "db.t3.micro" allocated_storage = 20 db_name = "shop" username = "shop" manage_master_user_password = true # VPC, subnet group, security groups and backups omitted } resource "aws_sqs_queue" "order_placed" { name = "order-placed" visibility_timeout_seconds = 60 } resource "aws_secretsmanager_secret" "email_api_key" { name = "email-api-key" } # Set the value out of band before the worker starts, then reference the ARN # from the task definition secrets block (valueFrom): # aws secretsmanager put-secret-value --secret-id email-api-key --secret-string "$EMAIL_API_KEY" resource "aws_iam_role" "shop_task" { name = "shop-task" assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Service = "ecs-tasks.amazonaws.com" } Action = "sts:AssumeRole" }] }) } resource "aws_iam_role_policy" "shop_task" { name = "shop-task" role = aws_iam_role.shop_task.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["sqs:SendMessage", "sqs:ReceiveMessage", "sqs:DeleteMessage"] Resource = aws_sqs_queue.order_placed.arn }, { Effect = "Allow" Action = ["secretsmanager:GetSecretValue"] Resource = aws_secretsmanager_secret.email_api_key.arn } ] }) }

The Express route then talks to Postgres over pg and publishes with the AWS SDK:

import crypto from "node:crypto"; import express from "express"; import { Pool } from "pg"; import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; const app = express(); app.use(express.json()); const db = new Pool({ connectionString: process.env.DATABASE_URL }); const sqs = new SQSClient({}); app.post("/orders", async (req, res) => { const { email, totalCents } = req.body ?? {}; const publicToken = crypto.randomUUID(); const { rows } = await db.query( "INSERT INTO orders (public_token, email, total_cents) VALUES ($1, $2, $3) RETURNING id, public_token", [publicToken, email, totalCents], ); await sqs.send( new SendMessageCommand({ QueueUrl: process.env.ORDER_PLACED_QUEUE_URL, MessageBody: JSON.stringify({ orderId: rows[0].id, email }), }), ); res.status(201).json({ id: rows[0].id, publicToken: rows[0].public_token }); });

Publishing after the insert leaves a small gap: if the process stops between the two calls, the order exists without a queue message. Write an outbox row in the same transaction as the order and publish from that table when every order must produce exactly one message, and accept an idempotency key so retries do not create a second order.

A separate worker process (another Fargate service, or the same task with a different command) long-polls the queue and sends the confirmation email. Delivery is at least once, so the worker claims a pending sent_emails row before calling the provider with an idempotency key, and leaves the row pending on failure so a later delivery can retry without permanently dropping the confirmation.

The hourly cleanup is an EventBridge Scheduler schedule that invokes a Fargate task or a Lambda, with a scheduler execution role. That role should only invoke the target (for example, ecs:RunTask plus iam:PassRole, or lambda:InvokeFunction). The target's task or Lambda execution role, together with its VPC subnets and security groups, provides access to Postgres; grant rds-db:connect to that target role only when using IAM database authentication, and do not reuse the API task role.

Locally you run Postgres and LocalStack or ElasticMQ from Docker Compose, and you keep that compose file in sync with every new queue or migration. Connection strings and queue URLs live in .env for local and in Secrets Manager or SSM for deployed environments, and the task definition has to wire each one by name.

CDK is the same shape in TypeScript: a Vpc, a DatabaseInstance, a Queue, an ApplicationLoadBalancedFargateService and the grants between them, then cdk deploy. App Runner can replace the load-balanced Fargate service for a single container, but you still own the RDS, SQS and IAM pieces next to it. Every new queue in the Express code is a second change in the CDK or Terraform project, and staging only matches production when someone applies the same stack to both.

How do you add Encore to an existing Express app?

Encore

Encore works alongside your framework and provisions the infrastructure your code declares in your own AWS or GCP account. For Express, the Express migration guide describes the setup used here: mount the existing router on a raw endpoint, then add infrastructure declarations next to it.

Install the CLI, add the encore.dev package to the project, and initialize the app:

~/shop
$ brew install encoredev/tap/encore$ npm i encore.dev$ encore app init shop --lang ts

Add the ~encore/* path to tsconfig.json as the migration guide shows, and create a service. A service is a directory with an encore.service.ts file:

// shop/encore.service.ts import { Service } from "encore.dev/service"; export default new Service("shop");

How do you declare the Postgres database?

The database is one object, and Encore reads the schema from numbered SQL files in shop/migrations and applies them on deploy:

// shop/db.ts import { SQLDatabase } from "encore.dev/storage/sqldb"; // Docker Postgres under `encore run`, RDS when deployed to AWS. export const db = new SQLDatabase("shop", { migrations: "./migrations" });
-- shop/migrations/1_create_orders.up.sql CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, public_token TEXT NOT NULL UNIQUE, email TEXT NOT NULL, total_cents INTEGER NOT NULL, status TEXT NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE sent_emails ( order_id BIGINT PRIMARY KEY REFERENCES orders(id), status TEXT NOT NULL CHECK (status IN ('pending', 'sent')), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() );

How do you declare the queue?

A Pub/Sub topic is the queue, and on AWS it becomes an SNS topic with an SQS queue for each subscription, including a dead-letter queue:

// shop/events.ts import { Topic } from "encore.dev/pubsub"; export interface OrderPlaced { orderId: number; email: string; } // A local broker under `encore run`, SNS with SQS when deployed to AWS. export const orderPlaced = new Topic<OrderPlaced>("order-placed", { deliveryGuarantee: "at-least-once", });

How do the Express routes use them?

The Express code stays as it is, apart from two setPrototypeOf lines that let Express work with Encore's raw request and response objects, and the /!rest path sends every request to the router:

// shop/app.ts import crypto from "node:crypto"; import { api, RawRequest, RawResponse } from "encore.dev/api"; import express, { request, response } from "express"; import { db } from "./db"; import { orderPlaced } from "./events"; // Let Express handle Encore's raw request and response objects. Object.setPrototypeOf(request, RawRequest.prototype); Object.setPrototypeOf(response, RawResponse.prototype); const app = express(); app.use(express.json()); app.post("/orders", async (req, res) => { const { email, totalCents } = req.body ?? {}; if (typeof email !== "string" || !Number.isInteger(totalCents) || totalCents < 0) { res.status(400).json({ error: "email and a non-negative integer totalCents are required" }); return; } const publicToken = crypto.randomUUID(); const order = await db.queryRow<{ id: number; public_token: string }>` INSERT INTO orders (public_token, email, total_cents) VALUES (${publicToken}, ${email}, ${totalCents}) RETURNING id, public_token`; await orderPlaced.publish({ orderId: order!.id, email }); res.status(201).json({ id: order!.id, publicToken: order!.public_token }); }); // Status lookup by unguessable public token, not by sequential id. app.get("/orders/status/:token", async (req, res) => { const order = await db.queryRow` SELECT id, total_cents AS "totalCents", status FROM orders WHERE public_token = ${req.params.token}`; if (!order) { res.status(404).json({ error: "not found" }); return; } res.json(order); }); // Every request goes to the Express router. export const expressApp = api.raw( { expose: true, method: "*", path: "/!rest" }, app, );

How does the worker read from the queue?

The worker is a subscription in the same service, and delivery is at least once, so the handler claims a pending row in sent_emails (reclaiming only stale pendings after five minutes), calls the provider with an idempotency key derived from the order id, and marks the row sent only after that call returns:

// shop/worker.ts import { Subscription } from "encore.dev/pubsub"; import log from "encore.dev/log"; import { db } from "./db"; import { orderPlaced } from "./events"; // Delivery is at least once, so the sent_emails row makes a redelivery a no-op. const _ = new Subscription(orderPlaced, "send-confirmation", { handler: async ({ orderId, email }) => { // Claim as pending so concurrent workers cannot both enter the send path. // Pass orderId as the provider idempotency key so an accepted send is not duplicated. const claimed = await db.queryRow` INSERT INTO sent_emails (order_id, status) VALUES (${orderId}, 'pending') ON CONFLICT (order_id) DO UPDATE SET updated_at = now() WHERE sent_emails.status = 'pending' AND sent_emails.updated_at < now() - interval '5 minutes' RETURNING order_id, status`; if (!claimed) { const existing = await db.queryRow<{ status: string }>` SELECT status FROM sent_emails WHERE order_id = ${orderId}`; if (existing?.status === "sent") return; // Fresh pending claim from another worker: do not ack; retry later. throw new Error("confirmation send already in progress"); } if (claimed.status === "sent") return; log.info("sending confirmation", { orderId, email }); // Call your email provider here with Idempotency-Key: order-${orderId}. await db.exec` UPDATE sent_emails SET status = 'sent', updated_at = now() WHERE order_id = ${orderId}`; }, });

How do you add the hourly cleanup job?

A cron job calls an Encore endpoint on a schedule. Cron jobs are Encore platform Managed, with no extra cloud infrastructure. They do not run locally or in preview environments, so call the endpoint by hand to test it:

// shop/cleanup.ts import { api } from "encore.dev/api"; import { CronJob } from "encore.dev/cron"; import { db } from "./db"; export const expirePending = api({}, async (): Promise<void> => { await db.exec` UPDATE orders SET status = 'expired' WHERE status = 'pending' AND created_at < now() - interval '24 hours'`; }); const _ = new CronJob("expire-pending-orders", { title: "Expire unpaid orders", every: "1h", endpoint: expirePending, });

How do you run it locally?

encore run starts Postgres in Docker, a local Pub/Sub broker and the app, and applies the migrations. Docker has to be running:

~/shop
$ encore run$ curl -X POST localhost:4000/orders -H 'Content-Type: application/json' -d '{"email":"ana@example.com","totalCents":4200}'

The local development dashboard at localhost:9400 shows the request, the publish and the subscription handler running. Requests that go through the catch-all endpoint are traced as one raw request, so you see less detail than for typed Encore endpoints until you move routes over.

How do you deploy it to your own AWS account?

  1. In the Encore dashboard, connect your AWS account. Encore gets access through an IAM role you create and can revoke.
  2. Create an environment that uses AWS and pick Fargate or EKS for compute.
  3. Commit and push:
~/shop
$ git add -A .$ git commit -m "Add Postgres, queue and cleanup job"$ git push encore

The first deploy creates a VPC across two availability zones, RDS Postgres, the SNS topic and SQS queues, ECR, the load balancer and the service. Encore provisions an IAM role for the service. Instance sizes, Multi-AZ and backups are environment settings in infrastructure configuration, not code. The AWS infrastructure docs list the defaults.

Need an API key for the email provider? Declare it with secret("EmailApiKey") and set it with encore secret set --type prod EmailApiKey. On AWS it is stored in Secrets Manager in your account.

How do the other options compare?

Terraform or AWS CDK. The section above is the full path: you own the VPC, RDS, SQS, ECS or App Runner and IAM, and local development is Docker Compose you keep in sync by hand. CDK is AWS's own tool, and both are well documented. The cost is a second program that has to change whenever the Express app needs a new queue or cron job.

SST. sst.config.ts with sst.aws.Postgres, sst.aws.Queue and a Service component gets you close in fewer lines, and SST v3 runs on Pulumi. Its core team moved most of its focus to OpenCode in 2025, so check the pace of fixes before starting something new on it. See SST alternatives.

Elastic Beanstalk. Beanstalk runs the Express server with little setup, and you can attach an RDS instance. The queue, the worker process, IAM and the scheduled job are separate pieces you configure by hand or through .ebextensions.

ECS or App Runner by hand. Clicking through RDS, SQS, ECS and IAM in the console is a good way to learn how the pieces fit, and a hard way to reproduce staging and production the same way.

Which option should you pick?

Verdict. If you want your Express app in your own AWS account with Postgres and a queue declared in the app code, add Encore alongside Express. Your routes stay as they are, the database, queue and cron job live in the code that uses them, and the same code runs locally (apart from the cron job, which is Encore platform Managed) and on AWS. Pick Terraform or AWS CDK if your team already maintains that IaC and wants every resource spelled out, and Elastic Beanstalk only for a small app that will not grow a worker.

Echo, acquired by Coinbase, and Pave Bank are fintech teams on the customers page that run their backends in their own cloud with Encore.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

If you want Express to keep the routes while Postgres and the queue are declared next to them, follow the Express migration guide or the quick start, then deploy into RDS, SQS and an IAM role in your own AWS account.

Frequently asked questions

How do I deploy an Express app to my own AWS account with Postgres and a queue without Terraform?

Keep Express and add Encore alongside it. Mount the Express app on an Encore raw endpoint, declare the Postgres database with SQLDatabase and the queue with a Pub/Sub Topic and Subscription in TypeScript, connect your AWS account in the Encore dashboard, and git push. Encore provisions RDS, SNS and SQS, the Fargate service and the networking in your account, with no Terraform, CDK or YAML.

Do I have to rewrite my Express routes to add RDS and a queue?

No. Keep the Express router and wire RDS and SQS through the AWS SDK or a client library, or mount the existing router behind a catch-all raw endpoint provided by an infrastructure-from-code tool so every existing route keeps working while you add typed declarations for the database and queue.

What AWS services does an Express app with Postgres and a queue usually need?

An RDS Postgres instance, an SQS queue (often with SNS for fan-out), Secrets Manager for API keys, ECR for images, and the app on Fargate or App Runner in a VPC with a load balancer. A scheduled job on the hand-built path is typically EventBridge Scheduler plus a task or a cron container. IAM policies have to grant the task role access to the database, queue and secrets.

Can I run the same Express container without a managed deploy platform?

Yes. Build a Docker image of the Express app and run it on ECS, App Runner or any container host, with an infra config or environment variables pointing at your own Postgres and SQS. In that setup you run migrations and trigger scheduled jobs yourself.

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