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?