Week 14: Containerization & Deployment

Everything since Week 1 has been building toward this: a TypeScript service, backed by Postgres and Redis, that logs, caches, queues and reports its own health. This week packages it into a portable Docker image with a proper multi-stage build, wires up a GitHub Actions pipeline that runs your Week 8 test suite before it ever builds that image, and makes the running process production-grade — using every CPU core with the cluster module and shutting down cleanly on SIGTERM instead of dropping in-flight requests. This is also the last stop in the "core" half of the course — Week 15 onward moves into more advanced, closer-to-production territory (query optimization at scale, gRPC, sagas, GraphQL, serverless and profiling) before the capstone in Weeks 25–26 assembles this exact pipeline around the full project you'll have built by then.

Module 11 of 22 Week 14 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Write a multi-stage Dockerfile that builds a TypeScript service and ships a slim runtime image
  • Set up a GitHub Actions pipeline that tests before it builds and publishes an image
  • Run Node across multiple CPU cores and shut it down gracefully on SIGTERM

1. A Multi-Stage Dockerfile for TypeScript

A naive Dockerfile that installs every dependency, copies all your source, and runs tsc inside the final image ships your entire toolchain — TypeScript itself, dev dependencies, source files — into production, bloating the image and widening its attack surface for no benefit. A multi-stage build uses one throwaway stage to compile, then copies only the compiled output into a clean, minimal final stage.

Dockerfile
# ---- Stage 1: build ----
FROM node:20-alpine AS builder
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY tsconfig.json ./
COPY prisma ./prisma
COPY src ./src
RUN npx prisma generate
RUN npm run build

# ---- Stage 2: runtime ----
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production

COPY package.json package-lock.json ./
RUN npm ci --omit=dev

COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY prisma ./prisma

# Run as a non-root user -- never run a production container as root.
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000
CMD ["node", "dist/main.js"]

The builder stage has the full dependency tree, including typescript and @types/node, and produces dist/. The runtime stage starts completely fresh from node:20-alpine — a minimal Linux image — installs only production dependencies with npm ci --omit=dev, and copies in nothing but the compiled JavaScript. Docker discards everything from the builder stage that wasn't explicitly copied over, so TypeScript itself never exists in the final image.

.dockerignore
node_modules
dist
.env
.git
*.log
Copy package.json before source to cache npm ci

Docker caches each layer and only re-runs a step if the files it depends on changed. Copying package.json/package-lock.json and running npm ci before copying the rest of your source means editing application code doesn't invalidate the (usually slowest) dependency-install layer, so most rebuilds skip straight to the fast steps.

2. CI/CD with GitHub Actions

A pipeline that builds and deploys an image without running tests first will happily ship a broken build to production. The workflow below runs the Vitest/Supertest suite from Week 8 as a required, separate job, and only builds the Docker image if it passes:

.github/workflows/ci.yml
name: CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_PASSWORD: postgres
          POSTGRES_DB: test_db
        ports: ["5432:5432"]
        options: >-
          --health-cmd="pg_isready -U postgres"
          --health-interval=5s
          --health-timeout=5s
          --health-retries=5
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - run: npm ci
      - run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
      - run: npm test
        env:
          DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db

  build-and-push:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

needs: test is what enforces the ordering — build-and-push won't start until test finishes successfully, and if any test fails, the build job never runs at all. The services block spins up a real Postgres container for the test job to run integration tests against, matching the test-database strategy from Week 8 rather than mocking the database away entirely. Restricting the build-and-push job to main keeps pull-request runs fast (tests only) while still publishing an image on every merge.

Tag images by commit SHA, not just "latest"

Tagging with ${{ github.sha }} gives every build a unique, traceable identifier — you can always tell exactly which commit produced a running container, and roll back to a specific previous image by tag instead of guessing which "latest" push introduced a regression.

3. Running Node in Production: PM2 & the Cluster Module

Week 1 established that Node runs your JavaScript on a single thread. A modern server has many CPU cores, and a single Node process only ever uses one of them for your application code — leaving the rest idle unless you explicitly do something about it. Node's built-in cluster module forks multiple copies of your process, one per CPU core, with the OS load-balancing incoming connections across them:

src/cluster.ts
import cluster from "node:cluster";
import os from "node:os";

if (cluster.isPrimary) {
  const numCPUs = os.availableParallelism();
  console.log(`Primary ${process.pid} forking ${numCPUs} workers`);

  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on("exit", (worker, code, signal) => {
    console.warn(`Worker ${worker.process.pid} died (${signal ?? code}), forking a replacement`);
    cluster.fork();
  });
} else {
  // Each worker just runs the normal app entry point --
  // it doesn't know or care that it's one of several.
  await import("./main.js");
}

Each worker is a full, independent Node process with its own memory and its own event loop — they share nothing in-process, which is why Week 11's Redis cache and Week 4's database are external, shared services rather than in-memory objects: any state that needs to be consistent across workers has to live outside any single process.

PM2 is a process manager that wraps this same clustering behavior (plus automatic restarts, log management, and zero-downtime reloads) behind a simple CLI, and is a common alternative to hand-rolling the cluster module yourself:

ecosystem.config.cjs
module.exports = {
  apps: [
    {
      name: "api",
      script: "dist/main.js",
      instances: "max", // one instance per available CPU core
      exec_mode: "cluster",
      env: { NODE_ENV: "production" },
    },
  ],
};
terminal
pm2 start ecosystem.config.cjs
pm2 logs api
pm2 reload api   # zero-downtime reload, one worker at a time

In a container-orchestrated deployment (Kubernetes, ECS), it's common to skip both and instead run one Node process per container, letting the orchestrator run multiple container replicas and handle scaling and restarts — either approach is valid, and which one you pick depends on whether your platform or your application manages multi-core utilization.

Don't cluster your background workers the same way

The BullMQ workers from Week 12 already scale by running more container/process instances, each with its own concurrency setting — clustering them on top of that just adds complexity without a clear benefit. Reserve cluster/PM2 clustering for the HTTP-serving API process, where CPU-bound request handling is the actual bottleneck you're solving for.

4. Graceful Shutdown on SIGTERM

When Docker or Kubernetes stops a container — during a deploy, a scale-down, or a node being drained — it sends SIGTERM first, then waits a grace period (30 seconds by default in Kubernetes) before sending an unforgiving SIGKILL. If your app doesn't handle SIGTERM, requests in-flight when the signal arrives get dropped mid-response, and the process is killed without a chance to close database or Redis connections cleanly.

src/main.ts (production entry point)
import { createApp } from "./app.js";
import { prisma } from "./lib/prisma.js";
import { redis } from "./lib/redis.js";
import { logger } from "./lib/logger.js";

const app = createApp();
const server = app.listen(process.env.PORT ?? 3000, () => {
  logger.info({}, `Server listening on port ${process.env.PORT ?? 3000}`);
});

async function shutdown(signal: string): Promise<void> {
  logger.info({ signal }, "Shutdown signal received, closing gracefully");

  // Stop accepting new connections; let in-flight requests finish.
  server.close(async (err) => {
    if (err) {
      logger.error({ err }, "Error while closing HTTP server");
    }

    await Promise.allSettled([prisma.$disconnect(), redis.quit()]);

    logger.info({}, "Shutdown complete");
    process.exit(err ? 1 : 0);
  });

  // Safety net: if something hangs (a stuck connection, a slow client),
  // force-exit rather than relying on the orchestrator's SIGKILL.
  setTimeout(() => {
    logger.error({}, "Forced shutdown after timeout");
    process.exit(1);
  }, 10_000).unref();
}

process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT")); // Ctrl+C during local dev

server.close() stops the HTTP server from accepting brand-new connections but lets requests already in progress complete normally — this is the difference between a clean deploy where no client sees a dropped connection, and one where every deploy causes a small burst of failed requests. The 10-second safety-net timeout, with .unref() so it doesn't itself keep the process alive if shutdown finishes first, guards against a shutdown that never completes (a hung database connection, for instance) instead of relying entirely on the orchestrator's own kill timeout.

Readiness should flip before the server actually stops

In a fuller production setup, flip /readyz from Week 13 to unready as the very first step in your shutdown handler, before calling server.close() — that gives the load balancer or orchestrator a moment to stop routing new traffic here before the process actually stops accepting connections, closing the small gap between "marked for shutdown" and "actually stopped."

5. Hands-on Exercise

Hands-on

Containerize, pipeline, and harden your API for production

Take the app you've been building since Week 4 and make it deployable: a real image, a real pipeline, and a process that shuts down without dropping requests.

Requirements:

  1. Write the multi-stage Dockerfile and .dockerignore from Section 1, build the image locally with docker build -t my-api ., and confirm the final image does not contain node_modules/typescript.
  2. Run the container locally with docker run, pointing DATABASE_URL and REDIS_URL at services on your host, and confirm /healthz and /readyz both respond correctly from inside the container.
  3. Add the GitHub Actions workflow from Section 2 to .github/workflows/ci.yml, push a branch with a deliberately failing test, and confirm the build-and-push job never runs.
  4. Add the graceful shutdown handler from Section 4 to your app's entry point. Start the server, fire a slow request (add an artificial 3-second delay to one route for testing), send SIGTERM mid-request, and confirm the response still completes before the process exits.
  5. Add the cluster.ts entry point from Section 3 and confirm with ps or your OS's process list that multiple Node worker processes are running under one primary.
Hint

Use kill -TERM <pid> (not kill -9, which is SIGKILL and can't be caught) to test the shutdown handler locally, and watch your logs for the "Shutdown signal received" line to confirm the handler actually ran before the process exited.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why does a multi-stage Dockerfile produce a smaller and safer final image than compiling TypeScript directly in the image you ship?

The build stage needs the full dependency tree -- TypeScript, type definitions, and every dev dependency -- to compile the project, but none of that is needed to actually run the compiled JavaScript. Docker discards everything in the build stage except the files an explicit COPY --from=builder pulls into the final stage, so the shipped image contains only the compiled output and production dependencies, which is both smaller and has fewer tools available to an attacker if the container is ever compromised.

Q2

Why does the CI workflow make the build-and-push job depend on test passing, rather than running them independently?

Without needs: test, both jobs would start at the same time and a failing test suite wouldn't stop a broken image from being built and published anyway -- the whole point of running tests in CI is to prevent that. Making build-and-push explicitly depend on test guarantees the pipeline only produces and ships an image for a commit that actually passed its automated checks first.

Q3

Why does a single Node process leave most of a modern multi-core server idle, and how does the cluster module address that?

Going back to Week 1's event loop model, a single Node process runs your JavaScript on exactly one thread, so it can only ever use one CPU core no matter how many the machine has -- the rest sit idle for your application code. The cluster module forks one full Node process per core, each with its own thread and event loop, and the OS load-balances incoming connections across them, which is what actually lets a Node application use every core on the machine.

Q4

What actually goes wrong for a client if your app doesn't handle SIGTERM and just gets SIGKILLed after the grace period?

SIGKILL terminates the process immediately with no opportunity to run any cleanup code, so any request that was still in flight at that instant gets its connection dropped mid-response -- the client sees a failed or incomplete request even though nothing was actually wrong with the request itself, just unlucky timing against a deploy. A SIGTERM handler that calls server.close() lets those in-flight requests finish normally before the process exits, so routine deploys don't cause a small burst of client-visible failures.