Docker & Containerization

Project: Containerize an Application

28 min Lesson 10 of 30

Project: Containerize an Application

Every concept from the previous nine lessons — base images, layer ordering, volumes, networking, Compose — converges in a single practical skill: taking a real application and making it run identically on any machine, in any environment, with a single command. This lesson walks through that end-to-end: a production-quality multi-service setup with a Node.js API, a PostgreSQL database, an Nginx reverse proxy, and a complete Docker Compose stack that you could ship to staging today.

The goal is not just a working container. The goal is a setup that a senior engineer would not need to rewrite: correct secrets handling, health checks, non-root users, build-time cache hygiene, and a Compose file that separates dev from prod concerns.

The Target Architecture

We will containerize a REST API with the following components:

  • app — Node.js 22 API (Express), multi-stage Dockerfile, runs as non-root.
  • db — PostgreSQL 16 with a named volume for data persistence.
  • proxy — Nginx 1.27 Alpine, terminates HTTP and proxies /api to the app.

All three services communicate over a private Docker network. Only the proxy is exposed on a host port. The app and database are never reachable directly from outside the Docker network.

Containerized application architecture: Nginx, Node API, PostgreSQL Docker Host app-network (bridge) Internet :80 Nginx proxy:80 nginx:1.27-alpine /api Node API app:3000 node:22-alpine :5432 PostgreSQL db:5432 postgres:16-alpine pgdata (named volume)
Three-service stack: Nginx handles public traffic and proxies to the Node API, which is the only service with a database connection. Neither the API nor the database is exposed on the host.

Step 1: Project Layout and .dockerignore

Before writing a single Dockerfile instruction, define what belongs in the build context and what does not. A correct .dockerignore is the first file you write.

myapp/ ├── .dockerignore ├── Dockerfile ├── docker-compose.yml ├── docker-compose.override.yml # dev overrides (not committed to prod) ├── nginx/ │ └── default.conf ├── src/ │ └── index.js ├── package.json └── package-lock.json
# .dockerignore — keep the build context lean node_modules npm-debug.log .env .env.* .git .gitignore *.md coverage .nyc_output dist # builder will create this; don't send stale artefacts in docker-compose*.yml nginx/
A bloated build context is one of the most common CI slowdowns. Sending node_modules/ (often hundreds of megabytes) to the Docker daemon is wasted I/O — the builder installs its own copies. Always audit your .dockerignore as carefully as you audit your .gitignore.

Step 2: Production-Grade Dockerfile

This Dockerfile uses a two-stage build: a builder stage that installs all dependencies and compiles the app, and a lean runtime stage that carries only what is needed to run it. The final image contains no build tools, no devDependencies, and no source maps.

# syntax=docker/dockerfile:1.7 # ---- Stage 1: build ---- FROM node:22.3-alpine3.20 AS builder WORKDIR /app # Copy manifests first to leverage layer caching COPY package.json package-lock.json ./ RUN npm ci --include=dev # Copy source and build COPY src/ ./src/ RUN npm run build # outputs to /app/dist # ---- Stage 2: runtime ---- FROM node:22.3-alpine3.20 AS runtime WORKDIR /app # Install only production dependencies in the runtime stage COPY package.json package-lock.json ./ RUN npm ci --omit=dev \ && npm cache clean --force # Copy compiled output from builder COPY --from=builder /app/dist ./dist # Non-root user — principle of least privilege RUN addgroup -S appgroup \ && adduser -S appuser -G appgroup USER appuser # Label for traceability in docker inspect and registry UIs ARG GIT_SHA=unknown LABEL org.opencontainers.image.revision="${GIT_SHA}" ENV NODE_ENV=production \ PORT=3000 EXPOSE 3000 HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \ CMD wget -qO- http://localhost:3000/healthz || exit 1 ENTRYPOINT ["node"] CMD ["dist/index.js"]
The HEALTHCHECK instruction tells Docker how to decide if the container is healthy. Compose and Kubernetes orchestrators use this status — a container that is running but returning 500s will be marked unhealthy and can be restarted or excluded from load balancing. Always implement a lightweight /healthz endpoint in your API and reference it here.

Step 3: Nginx Configuration

Nginx sits in front of the API. It rewrites the /api prefix and proxies to the app hostname — Docker's internal DNS resolves app to the API container's IP automatically.

# nginx/default.conf server { listen 80; server_name _; # Pass real client IP to the upstream app proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; location /api/ { # Strip /api prefix before forwarding rewrite ^/api(/.*)$ $1 break; proxy_pass http://app:3000; proxy_http_version 1.1; proxy_set_header Connection ""; # enable keep-alive to upstream proxy_read_timeout 30s; } # Health endpoint for load-balancer checks (not proxied — handled by Nginx itself) location /nginx-health { access_log off; return 200 "ok\n"; } }

Step 4: Docker Compose — Production File

The Compose file wires the three services together, defines the private network, declares the named volume, and enforces dependency ordering via health checks — not just depends_on with no condition, which only waits for the container to start, not for the service inside it to be ready.

# docker-compose.yml services: db: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: ${DB_NAME} POSTGRES_USER: ${DB_USER} POSTGRES_PASSWORD: ${DB_PASSWORD} volumes: - pgdata:/var/lib/postgresql/data networks: - app-network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] interval: 10s timeout: 5s retries: 5 start_period: 15s app: build: context: . target: runtime args: GIT_SHA: ${GIT_SHA:-dev} image: myapp:${GIT_SHA:-dev} restart: unless-stopped environment: DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} NODE_ENV: production env_file: .env # load secrets from .env (never committed to git) depends_on: db: condition: service_healthy networks: - app-network healthcheck: test: ["CMD-SHELL", "wget -qO- http://localhost:3000/healthz || exit 1"] interval: 15s timeout: 5s retries: 3 start_period: 10s proxy: image: nginx:1.27-alpine restart: unless-stopped ports: - "80:80" volumes: - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro depends_on: app: condition: service_healthy networks: - app-network networks: app-network: driver: bridge volumes: pgdata:
Never hardcode database passwords in docker-compose.yml. Use a .env file (which must be in .gitignore) to supply DB_PASSWORD and similar secrets. In CI/CD pipelines, inject them as masked environment variables from your secrets manager (GitHub Actions secrets, Vault, AWS Secrets Manager). A leaked credential in a public repository history is an incident, not an inconvenience.

Step 5: Dev Overrides

The base docker-compose.yml is production-shaped. For local development, use an override file that adds hot-reload and skips the multi-stage build:

# docker-compose.override.yml (dev only — do NOT deploy this) services: app: build: target: builder # use the builder stage with devDependencies command: ["node", "--watch", "src/index.js"] volumes: - ./src:/app/src:ro # mount source for live reload environment: NODE_ENV: development db: ports: - "5432:5432" # expose Postgres to host for local tools (TablePlus, psql)

Compose automatically merges docker-compose.override.yml when you run docker compose up locally. In CI and production, pass -f docker-compose.yml explicitly so the override is never applied.

Step 6: Running the Stack

# First run: build images and start all services docker compose up --build -d # Watch logs from all services docker compose logs -f # Check health status of every container docker compose ps # Run database migrations (example using a one-off container) docker compose exec app node dist/migrate.js # Rebuild only the app image after a code change, then do a zero-downtime restart docker compose build app docker compose up -d --no-deps app # Tear down (volumes preserved) docker compose down # Tear down AND delete all data (destructive — dev only) docker compose down -v
The --no-deps flag on docker compose up restarts only the named service without touching its dependencies. This is the pattern for deploying a new API image without restarting the database — essential when you cannot afford downtime to the persistence layer.

Production Failure Modes to Know

A container that starts is not the same as a service that is ready. The three most common production failures when first containerizing an application:

  1. Race condition at boot — the app starts and immediately tries to connect to the database before Postgres has finished its initialisation sequence. Fix: use depends_on: condition: service_healthy with a real healthcheck on the db service, as shown above.
  2. PID 1 signal handling — Node.js does not handle SIGTERM by default if it is PID 1 in a container. It will be force-killed after the stop timeout (default 10 s). Fix: use exec form ENTRYPOINT ["node", "dist/index.js"] and register a process.on("SIGTERM") handler in the app to drain in-flight requests before exiting.
  3. Secrets in image layers — passing secrets as build ARG or ENV bakes them into the image and they are visible via docker history and docker inspect. Fix: pass runtime secrets as environment variables (via .env or a secrets manager), not at build time.

Summary

Containerizing an application well means three things working in concert: a production-hardened Dockerfile (multi-stage, non-root, HEALTHCHECK, exec-form entrypoint), a Compose file that enforces correct startup ordering and separates secrets from configuration, and operational hygiene around volumes, networks, and override files. Build this once and it becomes the repeatable template for every service your team ships.