hectoday
DocsCoursesChangelog GitHub
DocsCoursesChangelog GitHub

Access Required

Enter your access code to view courses.

Invalid code

← All courses Deploying Node.js Apps with Docker

Why Docker

  • The "Works on My Machine" Problem
  • Docker Concepts

Your First Dockerfile

  • Writing a Dockerfile
  • Multi-Stage Builds
  • The .dockerignore File

Running Containers

  • Running and Managing Containers
  • Environment Variables and Secrets
  • Health Checks

Multi-Container Apps

  • Docker Compose
  • Adding a Reverse Proxy
  • Persistent Data

Deploying to Production

  • Deploying to a VPS
  • HTTPS with Let's Encrypt
  • Zero-Downtime Deploys

CI/CD

  • Building Images in CI
  • Automated Deployment

Production Hardening

  • Container Security
  • Logging and Monitoring
  • Deployment Checklist and Capstone

Adding a Reverse Proxy

Why not expose the app directly

Your Node.js app can listen on port 80 and serve traffic directly. But production apps put a reverse proxy (usually Nginx) in front for several reasons.

SSL termination. Nginx handles HTTPS (TLS certificates, encryption). Your app only speaks HTTP internally. Simpler app code, better SSL performance.

Static files. Nginx serves static files (images, CSS, JavaScript) much faster than Node.js. It is built for this.

Request buffering. Nginx buffers slow client connections. Your Node.js app gets the complete request quickly instead of tying up a connection for a slow upload.

Load balancing. Nginx can distribute traffic across multiple app containers.

Nginx in Docker Compose

# docker-compose.yml
services:
  app:
    build: .
    expose:
      - "3000" # Internal only — not published to the host
    environment:
      - NODE_ENV=production
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - ./certbot/conf:/etc/letsencrypt:ro
      - ./certbot/www:/var/www/certbot:ro
    depends_on:
      - app
    restart: unless-stopped

Notice: the app uses expose (internal, not published to the host) instead of ports (published). Only Nginx is reachable from outside.

The Nginx configuration

# nginx.conf
server {
    listen 80;
    server_name yourdomain.com;

    # Redirect HTTP to HTTPS (uncomment when you have SSL)
    # return 301 https://$server_name$request_uri;

    location / {
        proxy_pass http://app:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        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_pass http://app:3000 — Forward requests to the app service (resolved by Docker’s DNS) on port 3000.

proxy_set_header Upgrade and Connection — Required for WebSocket support. Without these, WebSocket upgrade requests fail.

X-Real-IP and X-Forwarded-For — Pass the client’s real IP to the app. Without these, the app sees Nginx’s IP as the client.

X-Forwarded-Proto — Tells the app whether the original request was HTTP or HTTPS. Important for generating correct redirect URLs.

Reading forwarded headers in your app

The app receives X-Forwarded-For and X-Real-IP headers from Nginx:

function getClientIp(request: Request): string {
  return (
    request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
    request.headers.get("x-real-ip") ??
    "unknown"
  );
}

[!WARNING] Only trust forwarded headers if you know the request came through your proxy. In production, configure your app to only accept these headers from trusted sources (the Nginx container’s IP range).

Try it

docker compose up -d

# Access via Nginx (port 80)
curl http://localhost/health
# { "status": "ok" }

# The app is NOT directly accessible on port 3000 from outside
# (it only uses expose, not ports)

Exercises

Exercise 1: Add Nginx to your docker-compose.yml. Create the nginx.conf. Run docker compose up -d. Access the app via port 80.

Exercise 2: Check that port 3000 is not accessible from outside the Docker network. Only port 80 (Nginx) should respond.

Exercise 3: Add the WebSocket upgrade headers. Test with a WebSocket connection through Nginx.

Why does the app use 'expose' instead of 'ports' in docker-compose.yml?

← Docker Compose Persistent Data →

© 2026 hectoday. All rights reserved.