Docker Compose: Nginx Reverse Proxy + SSL with Let's Encrypt
Docker Compose reference: Nginx Reverse Proxy + SSL
TL;DR
- Bottom line: Use Docker Compose to run Nginx as a TLS-terminating reverse proxy with automated Let's Encrypt certificate management via Certbot or nginx-proxy + acme-companion.
- Key tool/command:
docker compose up -dwith Nginx + Certbot services sharing certificate volumes, ornginxproxy/nginx-proxy+nginxproxy/acme-companionfor zero-config automated SSL. - Watch out for: Blocking port 80 prevents ACME HTTP-01 challenges -- certificates will fail to issue or renew silently.
- Works with: Docker Compose v2+, Nginx 1.25+ (TLSv1.3), Certbot 2.x+, Let's Encrypt ACME v2.
Constraints
- Port 80 MUST remain open and publicly reachable for HTTP-01 ACME challenges -- blocking it prevents certificate issuance and renewal
- NEVER store TLS private keys in Docker images or version control -- use named volumes or host-mounted secrets
- ssl_protocols MUST be set to TLSv1.2 TLSv1.3 only -- TLSv1.0 and TLSv1.1 are deprecated and insecure
- Let's Encrypt rate limits apply: 50 certificates per registered domain per week, 5 duplicate certificates per week
- Docker socket mounting (/var/run/docker.sock) grants root-equivalent access -- use read-only mount (:ro) and restrict container privileges
Quick Reference
Service Configuration Summary
| Service | Image | Ports | Volumes | Key Env |
|---|---|---|---|---|
| Nginx (reverse proxy) | nginx:1.27-alpine | 80:80, 443:443 | ./nginx/conf.d:/etc/nginx/conf.d:ro, certs:/etc/letsencrypt:ro | -- |
| Certbot | certbot/certbot:v2.11.0 | -- | certs:/etc/letsencrypt, certbot-www:/var/www/certbot | --webroot -w /var/www/certbot |
| nginx-proxy (automated) | nginxproxy/nginx-proxy:1.6 | 80:80, 443:443 | certs:/etc/nginx/certs, html:/usr/share/nginx/html, /var/run/docker.sock:/tmp/docker.sock:ro | -- |
| acme-companion | nginxproxy/acme-companion:2.4 | -- | certs:/etc/nginx/certs, html:/usr/share/nginx/html, acme:/etc/acme.sh, /var/run/docker.sock:/var/run/docker.sock:ro | [email protected] |
| Upstream app | any app image | expose 8080 | -- | VIRTUAL_HOST=app.example.com, LETSENCRYPT_HOST=app.example.com |
SSL Configuration Directives
| Directive | Recommended Value | Purpose |
|---|---|---|
ssl_protocols | TLSv1.2 TLSv1.3 | Allow only modern TLS versions |
ssl_ciphers | ECDHE-ECDSA-AES128-GCM-SHA256:... | Strong AEAD ciphers only |
ssl_prefer_server_ciphers | off | Let client choose (TLSv1.3 ignores this) |
ssl_session_cache | shared:SSL:10m | Cache ~40,000 TLS sessions |
ssl_session_timeout | 1d | Session ticket lifetime |
ssl_stapling | on | OCSP stapling for faster handshakes |
ssl_stapling_verify | on | Verify OCSP responses |
Strict-Transport-Security | "max-age=63072000" always | HSTS -- 2 year max-age |
X-Content-Type-Options | "nosniff" always | Prevent MIME sniffing |
X-Frame-Options | "DENY" always | Clickjacking protection |
Decision Tree
START: What is your Docker reverse proxy + SSL need?
├── Do you have 1-2 backend services with fixed domains?
│ ├── YES → Use Approach A: Manual Nginx + Certbot (full control)
│ └── NO ↓
├── Do you frequently add/remove services or have 3+ backends?
│ ├── YES → Use Approach B: nginx-proxy + acme-companion (auto-discovery)
│ └── NO ↓
├── Do you need wildcard certificates?
│ ├── YES → Use DNS-01 challenge with acme-companion
│ └── NO ↓
├── Are you in local development (no public domain)?
│ ├── YES → Use mkcert for local CA + self-signed certs
│ └── NO ↓
└── DEFAULT → Start with Approach A (manual Nginx + Certbot)
Step-by-Step Guide
1. Create project directory structure
Set up the required directories for Nginx configuration, Certbot data, and the Docker Compose file. [src3]
mkdir -p nginx/conf.d certbot/conf certbot/www
touch docker-compose.yml nginx/conf.d/default.conf
Verify: ls -la nginx/conf.d certbot/ → directories exist
2. Write Nginx configuration for SSL termination
Create the Nginx server block handling HTTP-to-HTTPS redirect, SSL termination, and reverse proxying. [src1]
# nginx/conf.d/default.conf
server {
listen 80;
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; }
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
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 ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
location / {
proxy_pass http://app:8080;
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;
}
}
Verify: nginx -t inside the container → syntax is ok
3. Write Docker Compose file (Approach A: Manual)
Define your app, Nginx reverse proxy, and Certbot services with shared volumes. [src3]
services:
app:
image: your-app:latest
expose: ["8080"]
networks: [internal]
nginx:
image: nginx:1.27-alpine
ports: ["80:80", "443:443"]
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- certbot-conf:/etc/letsencrypt:ro
- certbot-www:/var/www/certbot:ro
depends_on: [app]
networks: [internal, external]
restart: unless-stopped
certbot:
image: certbot/certbot:v2.11.0
volumes:
- certbot-conf:/etc/letsencrypt
- certbot-www:/var/www/certbot
entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"
volumes:
certbot-conf:
certbot-www:
networks:
internal:
external:
Verify: docker compose config → no errors
4. Obtain initial SSL certificate
Bootstrap the first certificate before enabling HTTPS in Nginx. [src5]
# Start Nginx with HTTP-only config
docker compose up -d nginx
# Request certificate
docker compose run --rm certbot certonly \
--webroot -w /var/www/certbot \
-d example.com -d www.example.com \
--email [email protected] --agree-tos --no-eff-email
# Reload Nginx with full SSL config
docker compose exec nginx nginx -s reload
Verify: curl -I https://example.com → HTTP/2 200 with HSTS header
5. Set up automated renewal
The Certbot container runs renewal every 12 hours. Add Nginx reload after successful renewal. [src5]
# The entrypoint in docker-compose.yml already handles renewal
# To manually test:
docker compose exec certbot certbot renew --dry-run
Verify: docker compose exec certbot certbot certificates → shows cert with expiry date
6. Alternative: nginx-proxy + acme-companion (Approach B)
For multi-service setups with automatic service discovery. [src2]
services:
nginx-proxy:
image: nginxproxy/nginx-proxy:1.6
ports: ["80:80", "443:443"]
volumes:
- certs:/etc/nginx/certs:ro
- html:/usr/share/nginx/html
- /var/run/docker.sock:/tmp/docker.sock:ro
restart: always
acme-companion:
image: nginxproxy/acme-companion:2.4
volumes_from: [nginx-proxy]
volumes:
- certs:/etc/nginx/certs:rw
- acme:/etc/acme.sh
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
DEFAULT_EMAIL: [email protected]
depends_on: [nginx-proxy]
restart: always
webapp:
image: your-app:latest
expose: ["80"]
environment:
VIRTUAL_HOST: app.example.com
LETSENCRYPT_HOST: app.example.com
volumes:
certs:
html:
acme:
Verify: docker compose up -d && curl -I https://app.example.com → valid SSL
Code Examples
Docker Compose: Production-grade Nginx + Certbot
Full script: docker-compose-nginx-certbot.yml (48 lines)
# docker-compose.yml -- production Nginx + Certbot
# Input: Domain name, upstream app on port 8080
# Output: TLS-terminated reverse proxy with auto-renewing certs
services:
app:
image: your-app:latest
expose: ["8080"]
networks: [backend]
# ... (see full script)
Nginx Config: SSL Termination with Security Headers
Full script: nginx-ssl-termination.conf (55 lines)
# nginx/conf.d/default.conf -- SSL termination + reverse proxy
# Input: HTTPS on 443, HTTP on 80
# Output: Proxied requests to upstream with TLS terminated
server {
listen 80;
server_name example.com;
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 301 https://$host$request_uri; }
}
# ... (see full script)
Bash: Initial Certificate Bootstrapping Script
Full script: bootstrap-ssl.sh (45 lines)
#!/usr/bin/env bash
# Input: DOMAIN, EMAIL environment variables
# Output: Valid Let's Encrypt certificate, running Nginx with SSL
set -euo pipefail
DOMAIN="${DOMAIN:?Set DOMAIN env var}"
EMAIL="${EMAIL:?Set EMAIL env var}"
# ... (see full script)
Docker Compose: nginx-proxy + acme-companion Multi-Service
Full script: docker-compose-nginx-proxy.yml (40 lines)
# docker-compose.yml -- nginx-proxy + acme-companion
# Input: VIRTUAL_HOST and LETSENCRYPT_HOST env vars per service
# Output: Automatic TLS for all proxied containers
services:
nginx-proxy:
image: nginxproxy/nginx-proxy:1.6
ports: ["80:80", "443:443"]
# ... (see full script)
Anti-Patterns
Wrong: Storing certificates in the Docker image
# BAD -- certificates baked into image expire, leak private keys
FROM nginx:1.27-alpine
COPY certs/fullchain.pem /etc/nginx/certs/
COPY certs/privkey.pem /etc/nginx/certs/
Correct: Use named volumes for certificate storage
# GOOD -- certificates managed externally via volumes
volumes:
- certbot-conf:/etc/letsencrypt:ro # Nginx reads only
Wrong: Blocking port 80 entirely
# BAD -- no port 80 means ACME challenges fail silently
services:
nginx:
ports:
- "443:443" # Port 80 missing!
Correct: Keep port 80 open with HTTPS redirect
# GOOD -- port 80 open for ACME + redirect to HTTPS
services:
nginx:
ports:
- "80:80" # Required for ACME HTTP-01 challenges
- "443:443"
Wrong: Using deprecated TLS versions
# BAD -- TLSv1.0 and TLSv1.1 are vulnerable (POODLE, BEAST)
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
Correct: Modern TLS only
# GOOD -- only TLSv1.2 and TLSv1.3
ssl_protocols TLSv1.2 TLSv1.3;
Wrong: Writable Docker socket mount
# BAD -- writable Docker socket = container escape risk
volumes:
- /var/run/docker.sock:/tmp/docker.sock
Correct: Read-only Docker socket mount
# GOOD -- read-only socket limits attack surface
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
Wrong: No proxy headers forwarded
# BAD -- upstream sees container IP instead of real client
location / {
proxy_pass http://app:8080;
}
Correct: Forward all relevant proxy headers
# GOOD -- upstream receives real client IP and protocol
location / {
proxy_pass http://app:8080;
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;
}
Common Pitfalls
- Certificate bootstrap chicken-and-egg: Nginx refuses to start if certificate files don't exist, but Certbot needs Nginx running for HTTP-01 challenge. Fix: Start with HTTP-only config first or use a self-signed dummy cert. [src3]
- Nginx serves stale cert after renewal: Certbot renews on disk but Nginx has old cert in memory. Fix: Add
--deploy-hook "nginx -s reload"to Certbot or rundocker compose exec nginx nginx -s reloadvia cron. [src5] - Container DNS resolution failure: Nginx caches upstream IP at startup. If app container restarts with new IP, traffic goes to old address. Fix: Use
resolver 127.0.0.11 valid=30s;withset $upstream http://app:8080; proxy_pass $upstream;. [src1] - Rate limit exhaustion with production ACME: Testing burns Let's Encrypt production rate limits (50/domain/week). Fix: Use
--stagingflag during setup, switch to production with--force-renewalonce verified. [src5] - Volume permission issues: Certbot writes as root, Nginx runs as non-root worker. Fix: Named volumes handle this automatically; for host mounts, ensure certificate directory is readable by UID 101. [src4]
- OCSP stapling "no responder" error: Missing resolver directive prevents Nginx from fetching OCSP status. Fix: Add
resolver 1.1.1.1 1.0.0.1 valid=300s; resolver_timeout 5s;withssl_stapling on;. [src6] - Certbot webroot path mismatch: ACME challenge path differs between Nginx and Certbot containers. Fix: Both containers must share the same volume: Nginx at
/var/www/certbot:ro, Certbot at/var/www/certbot:rw. [src3] - acme-companion ignores new containers: Missing Docker socket or environment variables. Fix: Mount
/var/run/docker.sock:roand set bothVIRTUAL_HOSTandLETSENCRYPT_HOSTon proxied containers. [src2]
Diagnostic Commands
# Test Nginx config syntax
docker compose exec nginx nginx -t
# Check certificate details and expiry
docker compose exec nginx openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -dates -subject
# Test SSL from outside
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates
# Check TLS version and cipher
curl -vI https://example.com 2>&1 | grep -E 'SSL connection|TLS'
# List Certbot-managed certificates
docker compose exec certbot certbot certificates
# Dry-run certificate renewal
docker compose exec certbot certbot renew --dry-run
# Check Nginx SSL error logs
docker compose logs nginx 2>&1 | grep -iE 'ssl|cert|tls|error'
# Verify OCSP stapling
openssl s_client -connect example.com:443 -status 2>/dev/null | grep -A 3 "OCSP Response"
# Test HTTP-to-HTTPS redirect
curl -I http://example.com 2>&1 | grep -i location
# Verify ACME challenge path
curl http://example.com/.well-known/acme-challenge/test
Version History & Compatibility
| Component | Version | Status | Key Notes |
|---|---|---|---|
| Nginx | 1.27.x (mainline) | Current | http2 on directive (replaces listen 443 ssl http2) |
| Nginx | 1.25.x | Stable | First with HTTP/2 directive change |
| Certbot | 2.11.x | Current | ACME v2, Python 3.8+ required |
| Certbot | 1.x | EOL | Python 2 support dropped |
| nginx-proxy | 1.6.x | Current | Switched to nginxproxy/nginx-proxy |
| nginx-proxy | 0.x (jwilder) | Deprecated | Old image: jwilder/nginx-proxy |
| acme-companion | 2.4.x | Current | Replaced letsencrypt-nginx-proxy-companion |
| Docker Compose | v2 | Current | docker compose (plugin, no hyphen) |
| Docker Compose | v1 | Deprecated | docker-compose (standalone binary) |
| Let's Encrypt | ACME v2 | Current | Wildcard via DNS-01 challenge |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Docker Compose with 1+ web services needing HTTPS | Kubernetes cluster with Ingress controller | cert-manager + Ingress-nginx in K8s |
| Need free, auto-renewing SSL (Let's Encrypt) | Internal network with corporate CA | Mount corporate CA certs directly |
| Self-hosted VPS or dedicated server | Managed cloud load balancer (AWS ALB, GCP LB) | Cloud provider's SSL termination |
| Want full control over Nginx configuration | Prefer zero-config auto-HTTPS | Caddy server (automatic HTTPS built-in) |
| Multiple domains/services behind one IP | Single static site with no dynamic backend | Static hosting with CDN (Cloudflare, Vercel) |
Important Caveats
- Let's Encrypt certificates expire every 90 days -- automated renewal is mandatory, not optional. A missed renewal means downtime.
- The nginx-proxy approach mounts the Docker socket, which is a security risk. In production, consider a Docker socket proxy (e.g., tecnativa/docker-socket-proxy).
- HTTP/2 directive syntax changed in Nginx 1.25: use
http2 on;as a separate directive instead oflisten 443 ssl http2;. - Wildcard certificates (
*.example.com) require DNS-01 challenges, not HTTP-01. This needs DNS provider configuration. - Docker Compose v1 (
docker-compose) is deprecated. Use Docker Compose v2 (docker composeas a Docker CLI plugin). - HSTS
preloadis irreversible once submitted to browser preload lists. Start withmax-age=300for testing.