docker compose up -d with Nginx + Certbot services sharing certificate volumes, or nginxproxy/nginx-proxy + nginxproxy/acme-companion for zero-config automated SSL.| 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 |
| 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 |
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)
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
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
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
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
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
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
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)
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)
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)
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)
# 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/
# GOOD -- certificates managed externally via volumes
volumes:
- certbot-conf:/etc/letsencrypt:ro # Nginx reads only
# BAD -- no port 80 means ACME challenges fail silently
services:
nginx:
ports:
- "443:443" # Port 80 missing!
# GOOD -- port 80 open for ACME + redirect to HTTPS
services:
nginx:
ports:
- "80:80" # Required for ACME HTTP-01 challenges
- "443:443"
# BAD -- TLSv1.0 and TLSv1.1 are vulnerable (POODLE, BEAST)
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
# GOOD -- only TLSv1.2 and TLSv1.3
ssl_protocols TLSv1.2 TLSv1.3;
# BAD -- writable Docker socket = container escape risk
volumes:
- /var/run/docker.sock:/tmp/docker.sock
# GOOD -- read-only socket limits attack surface
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
# BAD -- upstream sees container IP instead of real client
location / {
proxy_pass http://app:8080;
}
# 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;
}
--deploy-hook "nginx -s reload" to Certbot or run docker compose exec nginx nginx -s reload via cron. [src5]resolver 127.0.0.11 valid=30s; with set $upstream http://app:8080; proxy_pass $upstream;. [src1]--staging flag during setup, switch to production with --force-renewal once verified. [src5]resolver 1.1.1.1 1.0.0.1 valid=300s; resolver_timeout 5s; with ssl_stapling on;. [src6]/var/www/certbot:ro, Certbot at /var/www/certbot:rw. [src3]/var/run/docker.sock:ro and set both VIRTUAL_HOST and LETSENCRYPT_HOST on proxied containers. [src2]# 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
| 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 |
| 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) |
http2 on; as a separate directive instead of listen 443 ssl http2;.*.example.com) require DNS-01 challenges, not HTTP-01. This needs DNS provider configuration.docker-compose) is deprecated. Use Docker Compose v2 (docker compose as a Docker CLI plugin).preload is irreversible once submitted to browser preload lists. Start with max-age=300 for testing.