Skip to main content

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:

  1. Certbot sends a domain ownership verification request to the Let's Encrypt server.
  2. Let's Encrypt sends a validation request to http://<domain>/.well-known/acme-challenge/.
  3. Nginx proxies that path to the webroot directory mounted by Certbot.
  4. On successful validation, the certificate is issued and saved to the shared volume.
  5. 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

IssueCauseSolution
too many requestsExceeded Let's Encrypt issuance rate limitTest with --staging flag first, then issue for real
ACME challenge failurePort 80 blocked by firewallOpen ports 80 and 443 in firewall
Certificate path errorVolume mount path mismatchCheck path with docker volume inspect certbot_certs
Nginx reload failureSyntax error in ssl.confPre-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