Project: Containerize an Application
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
/apito 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.
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.
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.
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.
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. 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:
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
--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:
- 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_healthywith a realhealthcheckon the db service, as shown above. - PID 1 signal handling — Node.js does not handle
SIGTERMby default if it is PID 1 in a container. It will be force-killed after the stop timeout (default 10 s). Fix: use exec formENTRYPOINT ["node", "dist/index.js"]and register aprocess.on("SIGTERM")handler in the app to drain in-flight requests before exiting. - Secrets in image layers — passing secrets as build
ARGorENVbakes them into the image and they are visible viadocker historyanddocker inspect. Fix: pass runtime secrets as environment variables (via.envor 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.