What you'll build
The same Notes API โ this time as a containerized service on AWS ECS Fargate. You package the app in a Docker image, push it to ECR, and run it as a long-lived container behind a load balancer. This is the path for a "real server" app โ a framework like NestJS, Express, or FastAPI, with persistent connections, background workers, Redis, or Kafka โ that isn't a natural fit for per-request Lambda.
Containerize โ push the image to ECR โ run it on Fargate behind an ALB โ ship new images from CI. Fargate is "serverless containers": no servers to patch, but an always-on process rather than per-request functions.
Real-world example: JunctionNet's jnet-transport-backend โ a NestJS + PostgreSQL + Redis + Kafka logistics API โ ships exactly this way: a multi-stage Docker build, image in ECR, an ECS Fargate task + service, deployed by GitHub Actions. The snippets below are drawn from it.
Node.js & Python. The code blocks below toggle between Node.js (NestJS โ the jnet-transport-backend stack) and Python (FastAPI). The Docker / ECS mechanics are identical; only the app runtime differs.
Lambda vs. containers โ when to pick this
- Reach for Fargate (this part) for a long-running framework app, persistent connections (WebSockets, Kafka consumers, DB pools), heavy dependencies, or "one image that runs anywhere."
- Reach for Lambda (Part 5) for spiky or low-traffic request/response APIs where scale-to-zero and zero ops matter more.
The pieces
Docker
Packages your app + its runtime into one portable image.
ECR
AWS's private registry โ where your built images live.
ECS Fargate
Runs your container without managing servers; scales tasks up and down.
ALB
An Application Load Balancer that routes HTTPS traffic to your tasks.
The project structure
Start minimal, then let it grow into the shape below. The container-native twist: one image, two entrypoints (web + worker), and everything about infra lives in the repo as reviewable code:
notes-api/
โโ Dockerfile # multi-stage build โ small runtime image
โโ .dockerignore
โโ docker-compose.yml # app + postgres + redis, wired for local dev
โโ src/
โ โโ main.ts # web entrypoint (HTTP server)
โ โโ worker.ts # worker entrypoint (SQS consumer) โ SAME image
โ โโ notes/ # a module: controller โ service โ repository
โ โโ common/ # guards (auth), pipes (validation), filters (errors)
โ โโ db.ts # the pool โ one per container
โโ deploy/
โ โโ task-def.web.json # ECS task: the web service
โ โโ task-def.worker.json # ECS task: the worker (same image, diff command)
โโ infra/ # Terraform: cluster, services, ALB, RDS, IAM
โโ .github/workflows/
โโ backend-pipeline.yml # build โ push ECR โ migrate โ update ECS
| Path | Owns |
|---|---|
Dockerfile | How the image is built โ multi-stage, so the final image ships only what runs. |
src/main.ts / worker.ts | The two entrypoints baked into one image: the HTTP server and the queue consumer. |
src/<module>/ | A feature: controller (thin) โ service (logic) โ repository (the only DB access), scoped by orgId. |
src/common/ | Cross-cutting guards (auth), pipes (validation), and filters (error shaping). |
deploy/task-def.*.json | The ECS task definitions โ what to run, which secrets to inject, log config. |
infra/ | Terraform for the cluster, services, ALB, RDS, Redis, and IAM โ no console clicks. |
One image, many roles. A background worker or a cron task isn't a new codebase โ it's the same image run with a different command as its own ECS service. Grow by adding a task definition, not a repo.
Containerize with a Dockerfile
A multi-stage build keeps the final image small โ build with the full toolchain, ship only what runs:
# Build stage
FROM node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/src/main"]# Build stage โ install deps into a venv
FROM python:3.13-slim AS builder
WORKDIR /app
RUN python -m venv /venv
ENV PATH="/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Production stage โ copy only the venv + app
FROM python:3.13-slim
WORKDIR /app
COPY --from=builder /venv /venv
ENV PATH="/venv/bin:$PATH"
COPY . .
EXPOSE 3000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "3000"]Run the whole stack locally
Docker Compose mirrors production dependencies (database, cache) on your machine:
docker compose up # app + postgres + redis, wired together
# app now on http://localhost:3000
Step 3
Push the image to ECR
aws ecr create-repository --repository-name notes-api
aws ecr get-login-password --region us-east-1 \
| docker login --username AWS --password-stdin <acct>.dkr.ecr.us-east-1.amazonaws.com
docker build -t notes-api .
docker tag notes-api:latest <acct>.dkr.ecr.us-east-1.amazonaws.com/notes-api:latest
docker push <acct>.dkr.ecr.us-east-1.amazonaws.com/notes-api:latest
Step 4
Define the Fargate task & service
The task definition says what to run; the service keeps N copies alive behind the load balancer:
// task-definition.json (trimmed)
{
"family": "notes-api",
"requiresCompatibilities": ["FARGATE"],
"networkMode": "awsvpc",
"cpu": "512", "memory": "1024",
"containerDefinitions": [{
"name": "notes-api",
"image": "<acct>.dkr.ecr.us-east-1.amazonaws.com/notes-api:latest",
"portMappings": [{ "containerPort": 3000 }],
"environment": [
{ "name": "DATABASE_HOST", "value": "...rds.amazonaws.com" },
{ "name": "REDIS_HOST", "value": "...cache.amazonaws.com" }
],
"logConfiguration": {
"logDriver": "awslogs",
"options": { "awslogs-group": "/ecs/notes-api", "awslogs-region": "us-east-1", "awslogs-stream-prefix": "ecs" }
}
}]
}
aws ecs register-task-definition --cli-input-json file://task-definition.json
# then create/update an ECS service (Fargate) attached to your ALB target group
Do this in code, not clicks. Define the cluster, service, ALB, and networking with Terraform or CDK (JunctionNet uses Terraform). AWS Copilot (copilot init) is the fastest way to stand up a Fargate service if you're new to it.
Deploy from CI
A GitHub Actions pipeline builds the image, pushes it to ECR, and rolls the ECS service to the new image on every merge โ exactly what jnet-transport-backend does:
# .github/workflows/backend-pipeline.yml (essence)
- run: |
docker build -t $ECR_URI:$SHA .
docker push $ECR_URI:$SHA
- run: |
aws ecs update-service --cluster jnet --service notes-api \
--force-new-deployment
The database connection
Unlike a per-request Lambda, a container is a long-lived process โ so a normal connection pool, created once at startup and reused for the container's whole life, is exactly right. The thing to watch shifts from "reconnecting per request" to total connections across all tasks.
// src/db.ts โ one pool per container, created at startup, reused for its lifetime
import { Pool } from 'pg';
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10, // per container
});# app/db.py โ one engine/pool per container, created at import, reused for its life
import os
from sqlalchemy import create_engine
engine = create_engine(
os.environ["DATABASE_URL"],
pool_size=10, # per container
pool_pre_ping=True,
)Do the multiplication. Connections used โ max ร running tasks. 10 per container ร 20 autoscaled tasks = 200 connections โ past many Postgres limits. Keep max modest, size the DB to match, and put RDS Proxy (or PgBouncer) in front once task count grows โ it multiplexes tasks onto a few real connections. Long-lived pools make this far tamer than Lambda's storm, but the ceiling is real.
Migrations are a deploy step, not app boot. Don't migrate when the container starts โ every task would race. Run them once in the pipeline (a one-off ECS task or a CI step) before the service rolls to the new image.
Secrets & config
The same image ships to every environment, so configuration comes from
outside the image at run time. ECS injects it two ways in the task
definition โ environment for plain values,
secrets for anything sensitive:
// task-def.web.json (container definition, trimmed)
"environment": [
{ "name": "NODE_ENV", "value": "production" },
{ "name": "REDIS_HOST", "value": "...cache.amazonaws.com" }
],
"secrets": [
{ "name": "DATABASE_URL",
"valueFrom": "arn:aws:secretsmanager:us-east-1:<acct>:secret:notes/db_url" },
{ "name": "JWT_SECRET",
"valueFrom": "arn:aws:ssm:us-east-1:<acct>:parameter/notes/jwt_secret" }
]
secrets[].valueFrompulls from Secrets Manager or SSM Parameter Store and injects the value as an env var at container start โ the plaintext never lives in the image, the task def, or your logs.- The execution role needs
secretsmanager:GetSecretValue/ssm:GetParametersfor exactly those ARNs โ least privilege, not a wildcard. - Never bake secrets into the image or a committed
.envโ anyone who pulls the image would have them.
Pipeline: CI stores app secrets in Secrets Manager / SSM (seeded from GitHub Secrets); the task definition references only their ARNs. Rotating a secret is a Secrets Manager update + a new task revision โ no image rebuild.
The request's user & organization context
Every request belongs to a signed-in user in an
organization, and one org must never see another's data. Verify the
token and resolve identity in one place โ a NestJS guard (or Express
middleware) โ then scope every query by orgId. Never trust an id
from the body.
// src/common/auth.guard.ts โ verify the JWT, attach { userId, orgId }
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(ctx: ExecutionContext) {
const req = ctx.switchToHttp().getRequest();
const token = req.headers.authorization?.replace('Bearer ', '');
const claims = verifyJwt(token); // throws 401 if invalid
req.ctx = { userId: claims.sub, orgId: claims.org_id };
return true;
}
}# app/deps.py โ verify the JWT, return { user_id, org_id }
from fastapi import Header, HTTPException
def require_context(authorization: str = Header(default="")):
token = authorization.removeprefix("Bearer ")
try:
claims = verify_jwt(token) # raises on a bad token
except Exception:
raise HTTPException(status_code=401)
return {"user_id": claims["sub"], "org_id": claims["org_id"]}// notes.service.ts โ scope by the resolved org, never anything from the body
listNotes(orgId: string) {
return pool.query('select id, text from notes where org_id = $1', [orgId]);
}# notes_service.py โ scope by the resolved org, never anything from the body
def list_notes(org_id: str):
with engine.connect() as conn:
rows = conn.execute(
text("select id, text from notes where org_id = :org"), {"org": org_id})
return [dict(r._mapping) for r in rows]Never trust identity from the client. The body can claim any orgId; only the verified token is real. Resolve it in the guard, scope in the repository โ no route can leak another tenant's data. The ALB terminates TLS and passes the token through; the guard is your auth boundary.
A worker from the same image
A container shines at work Lambda finds awkward: long jobs, queue consumers, scheduled tasks. The pattern is one image, two entrypoints โ the web service runs the HTTP server; a worker service runs the same image with a different command, consuming a queue. No second repo.
// src/worker.ts โ long-running SQS consumer (its own ECS service)
import { pool } from './db';
const sqs = new SQSClient({});
while (true) {
const { Messages } = await sqs.send(new ReceiveMessageCommand({
QueueUrl: process.env.QUEUE_URL, WaitTimeSeconds: 20, MaxNumberOfMessages: 10,
}));
for (const m of Messages ?? []) {
await handle(JSON.parse(m.Body)); // idempotent
await sqs.send(new DeleteMessageCommand({
QueueUrl: process.env.QUEUE_URL, ReceiptHandle: m.ReceiptHandle }));
}
}# app/worker.py โ long-running SQS consumer (its own ECS service)
import os, json, boto3
sqs = boto3.client("sqs")
QUEUE = os.environ["QUEUE_URL"]
while True:
resp = sqs.receive_message(
QueueUrl=QUEUE, WaitTimeSeconds=20, MaxNumberOfMessages=10)
for m in resp.get("Messages", []):
handle(json.loads(m["Body"])) # idempotent
sqs.delete_message(
QueueUrl=QUEUE, ReceiptHandle=m["ReceiptHandle"])// task-def.worker.json โ SAME image, different command
"image": "<acct>.dkr.ecr.../notes-api:latest",
"command": ["node", "dist/src/worker"] // web uses dist/src/main// task-def.worker.json โ SAME image, different command
"image": "<acct>.dkr.ecr.../notes-api:latest",
"command": ["python", "-m", "app.worker"] // web uses uvicorn app.main:appProducer / consumer, decoupled. The web service enqueues a job โ or publishes a domain event to EventBridge (see Part 7) โ and returns immediately; the worker processes it with retries and autoscales on queue depth. In-process BullMQ + Redis works too, but a separate worker service scales independently of web traffic.
Give each service a health check. The web task needs an ALB health-check path (e.g. GET /health) so Fargate replaces unhealthy tasks; the worker needs a container health check so a wedged consumer gets restarted.
A CLAUDE.md for the container service
# CLAUDE.md
## What this is
Containerized API (NestJS) on AWS ECS Fargate. Postgres (RDS) + Redis (ElastiCache).
One image, two entrypoints: web (dist/src/main) and worker (dist/src/worker).
## Structure
src/main.ts # web entrypoint (HTTP)
src/worker.ts # worker entrypoint (SQS consumer) โ same image
src/<module>/ # controller โ service โ repository (data access here)
src/common/ # guards (auth), pipes (validation), filters (errors)
src/db.ts # one pool per container
deploy/task-def.*.json # ECS tasks (web + worker) ยท infra/ # Terraform
## Commands
- Local: docker compose up (app+db+redis on :3000)
- Build: docker build -t notes-api .
- Deploy: push to main โ GitHub Actions builds, pushes ECR, migrates, updates ECS.
## Conventions
- Multi-stage Dockerfile; final image runs `node dist/src/main`, EXPOSE 3000. Region us-east-1.
- Infra (cluster, services, ALB, RDS, Redis) in Terraform โ no console clicks.
- Controllers thin; data access in the repository/service; nothing bypasses it.
## Auth & multi-tenancy
- A guard verifies the JWT and attaches { userId, orgId }; never trust ids from the body.
- Scope every query by orgId โ no route may leak another tenant's data.
## Database
- One pool per container (created at startup, reused). Keep max modest: used โ max ร tasks.
- RDS Proxy / PgBouncer once task count grows. Migrations run in the pipeline, not at boot.
## Secrets & config
- environment[] for plain config; secrets[].valueFrom for Secrets Manager / SSM (injected at start).
- Never bake secrets into the image or commit .env. Execution role: GetSecretValue on those ARNs only.
## Background work
- Worker = same image, different command, its own ECS service; consumes SQS, autoscales on depth.
- Enqueue / publish (EventBridge) from web and return; worker retries. Handlers idempotent.
## Do not
- Don't run migrations at container boot (tasks race) or one-off prod tasks by hand.
- Don't commit .env or AWS keys.
## Verify
docker compose up and curl :3000; after deploy, check the ECS service + CloudWatch logs.
Grow
Adding the next service โ same shape
Growth here has three moves, in increasing order of separation โ reach for the lightest one that fits:
# 1. a new MODULE in the same image (most features)
src/orders/
orders.controller.ts // thin โ delegates to the service
orders.service.ts // business logic
orders.repository.ts // data access, org-scoped
# 2. a new ECS SERVICE from the SAME image (a worker / cron)
task-def.orders-worker.json "command": ["node","dist/src/orders-worker"]
# 3. a new MICROSERVICE (own repo, image, task-def) โ same module shape
- New module = controller โ service โ repository โ thin controller, reuse the auth guard and the shared pool. Nothing existing changes.
- Same image, different command for a worker or scheduled task โ one build, many roles.
- Shared infra by reference (SSM), a least-privilege task role, and a health check per service.
Split only when you must. Keep new work as a module in the one image while it's cohesive โ cheapest to build and deploy. Break out a separate service (or microservice) when it needs independent scaling, a different runtime, or its own release cadence. Separate services talk over a queue or EventBridge (Part 7), not direct calls.
Ask for a feature the way an engineer would
For a container service, a good prompt says what runs where โ which container or service owns the work โ pushes config to env/SSM, treats infra as reviewable IaC with least-privilege, and ends with a local run before any deploy:
> Add an email-sending background worker to this service:
- It consumes jobs from an SQS queue โ one message = one email.
Don't poll the database.
- Run it as its own ECS service from the SAME image with a
different command (worker vs web), not a new repo.
- Config via env vars / SSM โ no secrets baked into the image.
- Terraform: the queue, the worker service, autoscaling on queue
depth, and a least-privilege IAM role that reads only that queue.
- Add a container healthcheck and sensible CPU/memory limits.
- Don't change the web service. Let me run web + worker + a local
SQS with docker compose to test, then show the Terraform plan.
- Say what runs where โ "its own ECS service from the same image, worker vs web command" is the container-native pattern, not a second codebase.
- Config via env/SSM โ never bake secrets or endpoints into the image; the same image ships to every environment.
- Infra as reviewable IaC โ the queue, service, autoscaling, and IAM role in Terraform, so the change is a diff you can read.
- Least-privilege by default โ "reads only that queue" keeps the task role tight instead of wildcard permissions.
- Run it locally first โ
docker composeup the whole stack, thenterraform planโ verify before you touch the cluster.
Reusable shape: what & where it runs โ config via env/SSM โ infra as IaC (task def, scaling, IAM) โ least-privilege โ don't break X โ verify with docker compose then terraform plan. Same recipe for a new endpoint, a cron task, or a cache.
Let Claude Code help
Ask for the container Skills by name โ and the AWS Agent Toolkit gives Claude Code live access to your AWS account:
aws-containers
ECS, Fargate, ECR โ task definitions, services, health checks, scaling.
aws-cdk / aws-cloudformation
Author the cluster, service, and ALB as reviewable infrastructure-as-code.
> Containerize this NestJS app with a multi-stage Dockerfile, add a
docker-compose for local postgres + redis, and give me a Terraform
module for an ECS Fargate service behind an ALB, plus a GitHub
Actions workflow that builds, pushes to ECR, and updates the service.
Other backend paths: Part 4 ยท Node.js in Next.js or Part 5 ยท Python + SAM. Pair any with the frontend from Part 1, hosted per Part 3.