Docker and Nginx Reverse Proxy for Express

Advanced
15 min

Docker and Nginx Reverse Proxy for Express

"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.

A production Dockerfile

Start with .dockerignore so node_modules, .env and .git never enter the image:

bash
node_modules .env* .git tests coverage

A multi-stage build installs dependencies in one stage and copies only what production needs into a slim final image:

dockerfile
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.
  • Copying 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.
  • The exec-form 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/.

bash
docker build -t todo-api . docker run --rm -p 3000:3000 --env-file .env.production todo-api

Docker Compose for the whole stack

Compose starts the API together with MongoDB and Redis on a private network where services reach each other by name:

yaml
# 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.

Nginx as a reverse proxy

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
# 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.

Making Express proxy-aware

Behind Nginx, every request appears to come from the proxy's IP over plain HTTP. Tell Express to read the forwarded headers:

javascript
app.set("trust proxy", 1); // trust exactly one hop: the Nginx in front of us

With 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.

Tips

  • Pin base images and rebuild regularly to pick up security patches; scan them with docker scout or trivy in CI.
  • Run one Node process per container and scale with replicas rather than PM2 inside the image.
Quick Quiz
Question 1 of 3

Why copy `package*.json` and run `npm ci` before copying the source code?

Key Takeaways

  • Use a multi-stage Dockerfile with npm ci --omit=dev, a non-root user, a health check and exec-form CMD.
  • Docker Compose runs the API with MongoDB and Redis on one network; services address each other by name.
  • Nginx terminates TLS, compresses and forwards requests with X-Forwarded-* headers; keep the API port internal.
  • Set trust proxy to the number of proxy hops so IPs, protocol and secure cookies are correct.
  • Scale containers with replicas and log to stdout.

Next lesson: Capstone: Building a Production-Ready REST API — assemble everything from this course into one complete, deployable project.

Docker and Nginx Reverse Proxy for Express - Express.js | CodeYourCraft | CodeYourCraft