Skip to content
Chif3n
5 min read

Production Reverse Proxying with Nginx and Automated SSL in Docker

Configuring hardened Nginx reverse proxy headers, rate-limiting rules, and certbot auto-renewals for containerised microservices.

Every service I deploy for clinical use goes through the same checklist before it touches real patient data: reverse proxy hardened, SSL automated, rate limits on, sensitive headers stripped. Here is the exact setup I use.

Why Nginx in Front of Docker

Docker Compose gives you inter-service networking for free, but it does not give you TLS termination, rate limiting, or the security headers that stop browsers screaming at your users. Nginx sits in front of everything, handles certificates, and forwards clean HTTP to the containers behind it.

The alternative — letting each service manage its own TLS — means separate certbot configs, separate renewal crons, and separate failure modes. One Nginx proxy means one renewal job.

The Stack

Three files do all the work.

nginx-proxynginx/nginx.conf
# /etc/nginx/nginx.conf
 
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
 
events {
worker_connections 1024;
}
 
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
 
# ── Rate limiting zones ──
# 30 req/min per IP on the API; burst of 10 queued without error.
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/m;
 
# ── Upstream services ──
upstream api_service {
server api:8000; # Docker service name
keepalive 32;
}
 
upstream frontend_service {
server frontend:3000;
keepalive 32;
}
 
# ── HTTP → HTTPS redirect ──
server {
listen 80;
server_name example.com www.example.com;
 
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
 
location / {
return 301 https://$host$request_uri;
}
}
 
# ── Main HTTPS server ──
server {
listen 443 ssl http2;
server_name example.com www.example.com;
 
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
 
# ── Security headers ──
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
 
# Strip headers that leak server info.
server_tokens off;
more_clear_headers Server;
more_clear_headers X-Powered-By;
 
# ── Frontend ──
location / {
proxy_pass http://frontend_service;
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_read_timeout 60s;
}
 
# ── API (rate-limited) ──
location /api/ {
limit_req zone=api_limit burst=10 nodelay;
limit_req_status 429;
 
proxy_pass http://api_service/;
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_read_timeout 30s;
}
}
}

Initial Certificate Issuance

Before the proxy can serve HTTPS, you need the certificate. Run this once with Docker down:

# 1. Start only nginx on port 80 (comment out the 443 server block first)
docker compose up -d nginx

# 2. Issue the certificate via the webroot challenge
docker compose run --rm certbot certonly \
  --webroot -w /var/www/certbot \
  -d example.com -d www.example.com \
  --email you@example.com \
  --agree-tos --no-eff-email

# 3. Uncomment the 443 block and reload
docker compose exec nginx nginx -s reload

After that, the certbot container’s renewal loop handles everything. Certificates renew automatically when they have fewer than 30 days left.

Rate Limiting That Does Not Break Legitimate Users

The burst=10 nodelay combination is deliberate. It allows a burst of 10 requests to pass without queuing delay (useful for page loads that fire several parallel API calls), then enforces the 30 r/m zone for sustained traffic. Without nodelay, the first burst would queue and your SPA would feel slow on first load.

For the health services I build, the numbers are conservative by design — a clinic worker refreshing a donor dashboard should never hit a 429. Adjust the zone rate for your actual expected traffic.

What This Buys You

Without this setup With this setup
Self-signed certs or manual renewal Automated 90-day Let’s Encrypt renewals
No rate limiting 30 req/min per IP, burst-tolerant
Server version leaked in headers server_tokens off + headers stripped
HTTP serving mixed content Forced HTTPS redirect with HSTS preload
Each service manages its own TLS One Nginx, one renewal job, one failure mode

The Docker multi-stage build in the Dockerfile is equally important — the final image copies only the installed packages, not the build toolchain, keeping it under 150 MB and shrinking the attack surface.

All writing

Comments

Comments coming soon. Set up Giscus on the repo to enable them.