โ† Workshops ยท Part 6 ยท Docker + ECS

Backend Option C: Docker + AWS ECS Fargate

For a real-server app โ€” NestJS, Express, FastAPI, background workers, Redis, Kafka. Containerize it with Docker and run it on ECS Fargate, deployed from CI. Modeled on jnet-transport-backend.

๐Ÿ“ Test yourself โ†“
26 min readDocker + ECSRead or present

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

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
PathOwns
DockerfileHow the image is built โ€” multi-stage, so the final image ships only what runs.
src/main.ts / worker.tsThe 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.*.jsonThe 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.

Step 1

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"]
Step 2

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.

Step 5

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" }
]
๐Ÿ”‘

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.

Multi-tenant

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.

Background work

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:app
๐Ÿ“ฆ

Producer / 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
๐Ÿชœ

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.
๐Ÿงฑ

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

Quiz โ€” 15 questions

Answer every question, then submit to see your score and the correct answers.

Running containers on AWS?

JunctionNet runs production container workloads on ECS Fargate โ€” Docker, ECR, ALB, Terraform, GitHub Actions. Reach out for help designing one, or a team workshop.

โœ‰๏ธ Get in touch โ† Back to workshops