"It works on my machine" stops being a problem when the machine ships with the code. A Docker image bundles Node, your dependencies and your source into one artefact that runs identically on a laptop, a CI runner and a cloud host, and an Nginx reverse proxy in front of it handles TLS, compression and routing. After this lesson you will be able to write a production-grade Dockerfile for an Express app, run it with its database through Docker Compose, put Nginx in front, and configure Express to behave correctly behind a proxy.
Start with .dockerignore so node_modules, .env and .git never enter the image:
node_modules
.env*
.git
tests
coverageA multi-stage build installs dependencies in one stage and copies only what production needs into a slim final image:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "src/server.js"]Key decisions:
npm ci --omit=dev installs exactly the lock file, without test tooling.package*.json before the source lets Docker cache the dependency layer; a code change no longer re-runs npm ci.USER node drops root privileges inside the container.CMD ["node", ...] runs Node as PID 1 so it receives SIGTERM directly and your graceful shutdown handler works.HEALTHCHECK lets orchestrators restart or drain a container whose /health route stops answering.For a TypeScript project add a build stage that runs npm ci && npm run build and copy dist/ instead of src/.
docker build -t todo-api .
docker run --rm -p 3000:3000 --env-file .env.production todo-apiCompose starts the API together with MongoDB and Redis on a private network where services reach each other by name:
# compose.yaml
services:
api:
build: .
ports: ["3000:3000"]
environment:
MONGO_URI: mongodb://mongo:27017/todos
REDIS_URL: redis://redis:6379
JWT_SECRET: ${JWT_SECRET}
depends_on: [mongo, redis]
restart: unless-stopped
mongo:
image: mongo:7
volumes: [mongo-data:/data/db]
redis:
image: redis:7-alpine
volumes:
mongo-data:docker compose up --build builds the image and starts everything; docker compose logs -f api follows the logs. Because MONGO_URI points at the service name mongo, the same config module from the environment lesson works unchanged, and named volumes keep database files across restarts.
Node should not face the internet directly. Nginx terminates TLS, serves static assets, compresses responses and forwards API traffic to one or more app containers:
# nginx/default.conf
server {
listen 80;
server_name api.example.com;
client_max_body_size 5m;
gzip on;
gzip_types application/json text/css application/javascript;
location / {
proxy_pass http://api:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade; # WebSocket support
proxy_set_header Connection "upgrade";
}
}Add it to Compose as an nginx:alpine service mounting this file at /etc/nginx/conf.d/default.conf, publish port 80 (and 443) on Nginx instead of the API, and remove the API's ports entry so it is only reachable internally. For HTTPS, Certbot can obtain and renew a certificate automatically, or Caddy can replace Nginx and handle certificates with a two-line config.
Behind Nginx, every request appears to come from the proxy's IP over plain HTTP. Tell Express to read the forwarded headers:
app.set("trust proxy", 1); // trust exactly one hop: the Nginx in front of usWith this setting, req.ip becomes the client's address, req.protocol reflects X-Forwarded-Proto, secure cookies are set correctly and rate limiting counts real clients. Never set trust proxy to true on a server that is also reachable directly, because a client could then forge X-Forwarded-For.
docker scout or trivy in CI.Why copy `package*.json` and run `npm ci` before copying the source code?
npm ci --omit=dev, a non-root user, a health check and exec-form CMD.X-Forwarded-* headers; keep the API port internal.trust proxy to the number of proxy hops so IPs, protocol and secure cookies are correct.Next lesson: Capstone: Building a Production-Ready REST API — assemble everything from this course into one complete, deployable project.