Docker SSL Configuration
In production environments, HTTPS is not optional — it's mandatory. In Docker environments, the Nginx container and Certbot container share volumes to issue and automatically renew Let's Encrypt certificates. This chapter covers the complete SSL setup flow, from self-signed certificates for development to automated Let's Encrypt for production.
Let's Encrypt + Certbot Container Overview
Let's Encrypt is an authorized Certificate Authority (CA) that issues TLS certificates for free. Certbot is the official CLI tool that communicates with Let's Encrypt to automatically issue and renew certificates. Using the certbot/certbot Docker image lets you run Certbot as a container without installing it locally.
The certificate issuance flow:
- Certbot sends a domain ownership verification request to the Let's Encrypt server.
- Let's Encrypt sends a validation request to
http://<domain>/.well-known/acme-challenge/. - Nginx proxies that path to the webroot directory mounted by Certbot.
- On successful validation, the certificate is issued and saved to the shared volume.
- Nginx reads the certificate from the volume and serves HTTPS.
Directory Structure
project/
├── docker-compose.yml
├── nginx/
│ ├── conf.d/
│ │ ├── default.conf # HTTP (for initial issuance)
│ │ └── ssl.conf # HTTPS (used after certificate issuance)
│ └── nginx.conf
├── certbot/
│ └── renew.sh # Auto-renewal script
└── .env
Development Environment: HTTPS Testing with Self-Signed Certificates
In development environments without a production domain, generate a self-signed certificate with OpenSSL to test HTTPS.
# Create certificate storage directory
mkdir -p ./certs
# Generate self-signed certificate (valid for 10 years)
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
-keyout ./certs/privkey.pem \
-out ./certs/fullchain.pem \
-subj "/C=US/ST=California/L=SanFrancisco/O=Dev/CN=localhost"
Development Nginx config file nginx/conf.d/dev-ssl.conf:
server {
listen 80;
server_name localhost;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name localhost;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
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;
}
}
Development docker-compose.dev.yml:
version: "3.9"
services:
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d/dev-ssl.conf:/etc/nginx/conf.d/default.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on:
- app
app:
image: my-app:latest
expose:
- "8080"
Production Environment: Let's Encrypt Certificate Issuance
Step 1: HTTP-Only Initial Nginx Configuration
Before issuing a certificate, you need an Nginx config that allows only HTTP. Connect the ACME challenge path to webroot.
nginx/conf.d/default.conf:
server {
listen 80;
server_name example.com www.example.com;
# Certbot ACME challenge path
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect to HTTPS after certificate issuance (comment out initially)
# return 301 https://$host$request_uri;
location / {
proxy_pass http://app:8080;
}
}
Step 2: docker-compose.yml for Certificate Issuance
version: "3.9"
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
depends_on:
- app
certbot:
image: certbot/certbot:latest
container_name: certbot
volumes:
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
# Container exits after command runs (one-time)
entrypoint: >
sh -c "certbot certonly
--webroot
--webroot-path=/var/www/certbot
--email admin@example.com
--agree-tos
--no-eff-email
-d example.com
-d www.example.com"
app:
image: my-app:latest
container_name: app
restart: unless-stopped
expose:
- "8080"
environment:
- NODE_ENV=production
volumes:
certbot_webroot:
certbot_certs:
Initial certificate issuance commands:
# Start Nginx and app first
docker compose up -d nginx app
# Run Certbot to issue certificate
docker compose run --rm certbot
# Verify issuance
docker compose exec nginx ls /etc/letsencrypt/live/example.com/
# Output: cert.pem chain.pem fullchain.pem privkey.pem README
Step 3: Add HTTPS Nginx Configuration
nginx/conf.d/ssl.conf:
# 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;
}
}
# HTTPS server
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# Let's Encrypt certificate paths (inside container)
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Hardened SSL parameters
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_session_timeout 1d;
ssl_session_cache shared:MozSSL:10m;
ssl_session_tickets off;
# HSTS (force browsers to always connect via HTTPS)
add_header Strict-Transport-Security "max-age=63072000" always;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
resolver 8.8.8.8 8.8.4.4 valid=300s;
location / {
proxy_pass http://app:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
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_cache_bypass $http_upgrade;
}
}
Automatic Certificate Renewal
Let's Encrypt certificates expire every 90 days. Set up automatic renewal with a cron script.
certbot/renew.sh:
#!/bin/bash
# Certificate renewal script — run periodically via cron
set -e
COMPOSE_FILE="/opt/project/docker-compose.yml"
LOG_FILE="/var/log/certbot-renew.log"
echo "$(date '+%Y-%m-%d %H:%M:%S') Starting certificate renewal" >> "$LOG_FILE"
# Run certbot renew (only renews if expiring within 30 days)
docker compose -f "$COMPOSE_FILE" run --rm certbot renew --quiet 2>> "$LOG_FILE"
if [ $? -eq 0 ]; then
echo "$(date '+%Y-%m-%d %H:%M:%S') Renewal succeeded, reloading Nginx..." >> "$LOG_FILE"
# Zero-downtime Nginx reload (preserves existing connections)
docker compose -f "$COMPOSE_FILE" exec nginx nginx -s reload
echo "$(date '+%Y-%m-%d %H:%M:%S') Nginx reload complete" >> "$LOG_FILE"
else
echo "$(date '+%Y-%m-%d %H:%M:%S') Certificate renewal failed" >> "$LOG_FILE"
exit 1
fi
Register cron job (on the host server):
# Grant execute permission
chmod +x /opt/project/certbot/renew.sh
# Edit crontab
crontab -e
# Run at 3 AM and 3 PM daily (Let's Encrypt recommends at least twice daily)
0 3 * * * /opt/project/certbot/renew.sh >> /var/log/certbot-cron.log 2>&1
0 15 * * * /opt/project/certbot/renew.sh >> /var/log/certbot-cron.log 2>&1
Specifying renew as the certbot service command in docker-compose.yml runs only the renewal without any additional commands:
services:
certbot:
image: certbot/certbot:latest
volumes:
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
# Default entrypoint: certbot renew (renewal only)
command: renew
Complete Production docker-compose.yml
version: "3.9"
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt:ro
networks:
- frontend
depends_on:
app:
condition: service_healthy
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "5"
certbot:
image: certbot/certbot:latest
container_name: certbot
volumes:
- certbot_webroot:/var/www/certbot
- certbot_certs:/etc/letsencrypt
networks:
- frontend
app:
image: my-app:latest
container_name: app
restart: unless-stopped
expose:
- "8080"
environment:
- NODE_ENV=production
- DATABASE_URL=${DATABASE_URL}
networks:
- frontend
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
db:
image: postgres:16-alpine
container_name: db
restart: unless-stopped
environment:
- POSTGRES_DB=${DB_NAME}
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
certbot_webroot:
certbot_certs:
postgres_data:
networks:
frontend:
backend:
internal: true
Zero-Downtime Nginx Reload
Fully restarting Nginx after certificate renewal drops existing connections. Using nginx -s reload lets new worker processes load the new certificate while existing workers finish handling in-progress requests.
# Reload directly inside the container
docker exec nginx nginx -s reload
# Reload via docker compose
docker compose exec nginx nginx -s reload
# Validate config then reload
docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload
Troubleshooting
| Issue | Cause | Solution |
|---|---|---|
too many requests | Exceeded Let's Encrypt issuance rate limit | Test with --staging flag first, then issue for real |
| ACME challenge failure | Port 80 blocked by firewall | Open ports 80 and 443 in firewall |
| Certificate path error | Volume mount path mismatch | Check path with docker volume inspect certbot_certs |
| Nginx reload failure | Syntax error in ssl.conf | Pre-validate with nginx -t |
# Test with Let's Encrypt staging server (no real issuance quota consumed)
docker compose run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--staging \
--email admin@example.com \
--agree-tos \
-d example.com