Understanding Docker Compose with Let's Encrypt SSL
Docker Compose simplifies multi-container application orchestration, while Let's Encrypt provides free, automated TLS certificates. When combined, they create a powerful stack for deploying secure web applications with minimal manual intervention. This tutorial covers the essential patterns, production-ready configurations, and hard-learned lessons from real-world deployments.
At its core, the integration works by running an ACME client (like Certbot) alongside your application containers, or by using a reverse proxy that handles certificate lifecycle natively. The goal is always the same: automatic HTTPS provisioning and renewal without downtime.
Why SSL Automation Matters in Containerized Deployments
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Manual certificate management doesn't scale. In a containerized world where services may be ephemeral, relying on manual SSL renewal creates security gaps and operational toil. Let's Encrypt's 90-day certificate lifetime is a feature, not a bug — it forces automation. When integrated properly with Docker Compose, you get:
- Zero-touch renewal — certificates refresh automatically without human intervention
- Immutable infrastructure — certificates live on persistent volumes, not inside ephemeral containers
- Cost reduction — no more expensive commercial certificates for non-enterprise use cases
- Compliance — TLS everywhere becomes achievable for small teams
Core Architecture Patterns
There are three battle-tested patterns for integrating Let's Encrypt with Docker Compose. Each has different trade-offs in complexity, flexibility, and maintenance overhead.
Pattern 1: Nginx + Certbot (Sidecar Approach)
This is the most explicit pattern. Certbot runs as a separate container that obtains certificates via the HTTP-01 challenge, placing them on a shared volume. Nginx reads from that volume and handles TLS termination. Renewal is managed via a cron job or an external scheduler.
Here's a complete docker-compose.yml that implements this pattern:
version: '3.8'
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- certbot_www:/var/www/certbot:ro
- certbot_conf:/etc/letsencrypt:ro
- ./html:/usr/share/nginx/html:ro
depends_on:
- app
restart: unless-stopped
app:
image: your-app:latest
container_name: app-backend
expose:
- "3000"
restart: unless-stopped
certbot:
image: certbot/certbot:latest
container_name: certbot-client
volumes:
- certbot_www:/var/www/certbot
- certbot_conf:/etc/letsencrypt
entrypoint: "/bin/sh -c"
command: |
"trap : TERM INT; while :; do sleep 12h & wait $${!}; certbot renew --deploy-hook 'nginx -s reload'; done"
restart: unless-stopped
volumes:
certbot_www:
driver: local
certbot_conf:
driver: local
The accompanying Nginx configuration (nginx/nginx.conf) handles the ACME challenge path and routes traffic:
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Rate limiting for security
limit_req_zone $binary_remote_addr zone=acme_limit:10m rate=10r/m;
upstream app_backend {
server app:3000;
}
server {
listen 80;
server_name example.com www.example.com;
# Let's Encrypt HTTP-01 challenge location
location ^~ /.well-known/acme-challenge/ {
limit_req zone=acme_limit burst=5 nodelay;
root /var/www/certbot;
default_type text/plain;
}
# Redirect all other HTTP traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
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;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
location / {
proxy_pass http://app_backend;
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;
}
}
}
Before starting the stack, you need to bootstrap the initial certificate. Run this one-time command:
# Create the initial certificate (run this once before docker-compose up)
docker compose run --rm certbot certonly --webroot \
-w /var/www/certbot \
--email admin@example.com \
--agree-tos \
--no-eff-email \
-d example.com \
-d www.example.com
Then start the full stack with docker compose up -d. The certbot container will renew certificates automatically every 12 hours (only issuing a renewal when certificates are within 30 days of expiry).
Pattern 2: Traefik — Full Automation with Automatic Certificate Management
Traefik is a cloud-native reverse proxy with built-in Let's Encrypt support. It handles certificate issuance, renewal, and TLS termination automatically via Docker container labels. This eliminates the need for a separate certbot container entirely.
version: '3.8'
services:
traefik:
image: traefik:v3.1
container_name: traefik-router
command:
- "--api=false"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
- "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
- "--certificatesresolvers.letsencrypt.acme.storage=/certificates/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_certs:/certificates
restart: unless-stopped
app:
image: your-app:latest
container_name: app-backend
labels:
- "traefik.enable=true"
- "traefik.http.routers.app.rule=Host(`example.com`) || Host(`www.example.com`)"
- "traefik.http.routers.app.entrypoints=websecure"
- "traefik.http.routers.app.tls.certresolver=letsencrypt"
- "traefik.http.services.app.loadbalancer.server.port=3000"
restart: unless-stopped
volumes:
traefik_certs:
driver: local
Traefik handles everything: it obtains certificates on first request, stores them in acme.json, and renews them automatically. The TLS-ALPN-01 challenge is used by default with the tlsChallenge option, which doesn't require opening port 80 at all — but for broader compatibility, you may want to use the HTTP-01 challenge instead.
Pattern 3: Caddy — The Simplest Option
Caddy is a web server written in Go that obtains Let's Encrypt certificates automatically with zero configuration. It's arguably the simplest path to HTTPS in Docker Compose:
version: '3.8'
services:
caddy:
image: caddy:2.8-alpine
container_name: caddy-server
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
app:
image: your-app:latest
container_name: app-backend
expose:
- "3000"
restart: unless-stopped
volumes:
caddy_data:
driver: local
caddy_config:
driver: local
And the Caddyfile is beautifully minimal:
example.com, www.example.com {
reverse_proxy app:3000
encode gzip zstd
header Strict-Transport-Security "max-age=63072000"
}
Caddy handles certificate issuance, renewal, and even OCSP stapling entirely on its own. The /data volume stores certificates persistently.
Best Practices for Production Deployments
1. Always Persist Certificates on Named Volumes
Never store Let's Encrypt certificates inside the container's ephemeral filesystem. Always use named Docker volumes or bind mounts to a host directory. Losing the certificate store means re-issuing certificates, which can hit rate limits. Named volumes are preferred because Docker manages their lifecycle independently of container restarts.
volumes:
certbot_conf:
driver: local
driver_opts:
type: none
device: /opt/certbot/config
o: bind
2. Implement a Graceful Reload, Not a Hard Restart
When certificates renew, your reverse proxy needs to pick up the new files. Use signals (SIGHUP for Nginx, SIGUSR1 for Apache) rather than restarting containers. Certbot's --deploy-hook is designed for this:
# In the certbot renewal command
certbot renew --deploy-hook "docker exec nginx-proxy nginx -s reload"
Alternatively, for containers, use the --post-hook to run a script that copies certificates and reloads:
#!/bin/sh
# renewal-hook.sh — place this script on a shared volume
cp -r /etc/letsencrypt/live /certificates/
docker kill -s HUP nginx-proxy
3. Use a Staging Environment First
Let's Encrypt has rate limits: 50 certificates per registered domain per week, and 5 duplicate certificates per week. Always test against the staging API first:
# Certbot staging command
docker compose run --rm certbot certonly --webroot \
-w /var/www/certbot \
--staging \
--email admin@example.com \
--agree-tos \
-d example.com
# Traefik staging configuration
- "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory"
Once your configuration works, switch to production by removing the staging flag and clearing any staging certificates from your volume.
4. Separate Certificate Storage from Application Logic
Keep certificate volumes dedicated to TLS infrastructure. Don't mix application data with certificate data. This separation allows you to replace or upgrade your reverse proxy without touching certificates:
volumes:
# Dedicated certificate volume — never mount this into application containers
certbot_conf:
name: certbot_conf_production
driver: local
# Application data — completely separate
app_data:
name: app_data_production
driver: local
5. Lock Down the ACME Challenge Path
The .well-known/acme-challenge path must be accessible on port 80 for HTTP-01 validation. However, you should restrict what else can happen on that path. In Nginx, use a dedicated location block with rate limiting:
location ^~ /.well-known/acme-challenge/ {
# Only allow GET and HEAD
limit_except GET HEAD {
deny all;
}
root /var/www/certbot;
default_type text/plain;
# Prevent directory listing
autoindex off;
}
6. Monitor Certificate Expiry Independently
Even with automation, monitoring is essential. Set up an external check that alerts you if certificates are approaching expiry. A simple script can query the certificate file:
#!/bin/sh
# check-cert-expiry.sh
EXPIRY=$(openssl x509 -enddate -noout -in /etc/letsencrypt/live/example.com/fullchain.pem | cut -d= -f2)
EXPIRY_EPOCH=$(date -d "$EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))
if [ $DAYS_LEFT -lt 15 ]; then
echo "WARNING: Certificate expires in $DAYS_LEFT days" >&2
exit 1
fi
echo "Certificate valid for $DAYS_LEFT more days"
Run this as a periodic health check or integrate it with your monitoring system.
7. Use DNS Validation for Multi-Container or Private Deployments
When your services are not publicly accessible on port 80 (internal networks, home labs, or when using wildcard certificates), switch to DNS-01 challenge. This requires a DNS provider plugin:
# Certbot with Cloudflare DNS plugin
docker compose run --rm certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/cloudflare/credentials.ini \
--email admin@example.com \
--agree-tos \
-d example.com \
-d '*.example.com'
Store API credentials securely using Docker secrets or a restricted bind mount with chmod 600 permissions.
Common Pitfalls and How to Avoid Them
Pitfall 1: Hitting Let's Encrypt Rate Limits During Development
Symptom: You receive "too many certificates already issued" errors during testing.
Root cause: Repeatedly restarting containers or rebuilding volumes causes new certificate requests.
Solution: Always use the staging environment during development. Set up a conditional in your docker-compose that switches based on an environment variable:
# docker-compose.yml with staging/production toggle
services:
certbot:
image: certbot/certbot:latest
environment:
- STAGING=${STAGING:-0}
entrypoint: "/bin/sh -c"
command: |
"if [ $$STAGING = '1' ]; then
certbot certonly --staging --webroot -w /var/www/certbot --agree-tos -d example.com;
else
certbot certonly --webroot -w /var/www/certbot --agree-tos -d example.com;
fi"
Pitfall 2: Port 80 Not Accessible for HTTP-01 Challenge
Symptom: Certbot fails with "connection refused" or "timeout" during validation.
Root cause: A firewall, cloud security group, or ISP blocks port 80. Or the webroot path isn't correctly served.
Solution: Verify port 80 is open from Let's Encrypt's validation servers. Use curl to simulate the challenge:
# Test the challenge path locally
curl -v http://example.com/.well-known/acme-challenge/test-token
# Expected: 200 OK with "test-token.validated" or a 404 (both work for ACME)
If port 80 is blocked, switch to DNS-01 validation or use TLS-ALPN-01 on port 443 (supported by Traefik and Caddy).
Pitfall 3: Volume Permission Issues After Container Restarts
Symptom: Certificates exist but Nginx returns "Permission denied" when reading them.
Root cause: The certbot container runs as root by default, creating files with restricted permissions. The Nginx container runs as a non-root user (nginx: UID 101) and cannot read them.
Solution: Explicitly set permissions after renewal using a post-hook script, or run certbot with the same UID as Nginx:
# Post-renewal hook to fix permissions
#!/bin/sh
set -e
find /etc/letsencrypt -type d -exec chmod 755 {} \;
find /etc/letsencrypt -type f -exec chmod 644 {} \;
chmod 600 /etc/letsencrypt/live/*/privkey.pem
# Reload nginx
docker exec nginx-proxy nginx -s reload
Alternatively, run the certbot container with a user that matches Nginx:
services:
certbot:
image: certbot/certbot:latest
user: "101:101" # matches nginx user in alpine image
volumes:
- certbot_www:/var/www/certbot
- certbot_conf:/etc/letsencrypt
Pitfall 4: Renewal Failures Due to Stale Webroot Configuration
Symptom: Initial certificate issuance works, but renewal fails 60 days later.
Root cause: The webroot path changed, the Nginx configuration was updated without the challenge location block, or the container serving the challenge path was removed.
Solution: Always include the ACME challenge location block in your Nginx configuration, even if you think you're done with it. Test renewal manually before the deadline:
# Dry-run renewal test
docker compose exec certbot certbot renew --dry-run
Schedule this as a weekly cron job on the host to catch configuration drift early.
Pitfall 5: DNS Propagation Delays with DNS-01 Challenge
Symptom: DNS validation fails with "NXDOMAIN" or "record not found" errors.
Root cause: DNS changes haven't propagated to Let's Encrypt's validation nameservers yet.
Solution: Add propagation delay flags when using DNS plugins, or use a provider with fast propagation (Cloudflare, AWS Route53). For Certbot, some plugins support --dns-propagation-seconds:
certbot certonly --dns-cloudflare \
--dns-cloudflare-propagation-seconds 60 \
--dns-cloudflare-credentials /etc/cloudflare/credentials.ini \
-d example.com
For critical deployments, consider using a DNS provider with an SLA on propagation speed.
Pitfall 6: Using the Wrong Challenge Type for Your Topology
Symptom: You can't get certificates for internal services, wildcard domains, or behind load balancers.
Root cause: HTTP-01 requires public internet access to port 80 on every domain. It also doesn't support wildcards. TLS-ALPN-01 requires port 443 and doesn't work behind some cloud load balancers.
Solution: Choose the right challenge type for your use case:
- HTTP-01: Public-facing web servers, simplest setup, requires port 80
- DNS-01: Wildcard certificates, private services, no open ports required
- TLS-ALPN-01: When port 80 is blocked but 443 is open, works well with Traefik
Here's a decision table in code form:
# Challenge type selection guide
# if [ "$WILDCARD" = "true" ]; then
# CHALLENGE="dns-01"
# elif [ "$PUBLIC_PORT_80" = "true" ]; then
# CHALLENGE="http-01"
# elif [ "$PUBLIC_PORT_443" = "true" ]; then
# CHALLENGE="tls-alpn-01"
# else
# CHALLENGE="dns-01" # fallback for private networks
# fi
Complete Production Example: Nginx + Certbot with All Best Practices Applied
Here's a production-grade setup that incorporates all the best practices and avoids the pitfalls discussed above. It includes proper health checks, permission handling, staging/production toggle, and renewal monitoring.
File: docker-compose.yml
version: '3.8'
services:
nginx:
image: nginx:1.25-alpine
container_name: nginx-proxy
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- certbot_www:/var/www/certbot:ro
- certbot_conf:/etc/letsencrypt:ro
- ./html:/usr/share/nginx/html:ro
depends_on:
app:
condition: service_healthy
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
restart: unless-stopped
app:
image: your-app:latest
container_name: app-backend
expose:
- "3000"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
restart: unless-stopped
certbot:
image: certbot/certbot:latest
container_name: certbot-client
user: "101:101"
volumes:
- certbot_www:/var/www/certbot
- certbot_conf:/etc/letsencrypt
- ./scripts/renewal-hook.sh:/usr/local/bin/renewal-hook.sh:ro
environment:
- STAGING=${STAGING:-0}
- DOMAINS=${DOMAINS:-example.com,www.example.com}
- EMAIL=${EMAIL:-admin@example.com}
entrypoint: "/bin/sh -c"
command: |
"
# Fix permissions on existing certs
find /etc/letsencrypt -type d -exec chmod 755 {} \; 2>/dev/null || true
find /etc/letsencrypt -type f -exec chmod 644 {} \; 2>/dev/null || true
# Check if certificates exist
if [ ! -f /etc/letsencrypt/live/$$(echo $$DOMAINS | cut -d, -f1)/fullchain.pem ]; then
echo 'Obtaining initial certificate...'
STAGING_FLAG=''
if [ \"$$STAGING\" = '1' ]; then STAGING_FLAG='--staging'; fi
DOMAIN_ARGS=''
for DOMAIN in $$(echo $$DOMAINS | tr ',' '\n'); do
DOMAIN_ARGS=\"$$DOMAIN_ARGS -d $$DOMAIN\"
done
certbot certonly $$STAGING_FLAG --webroot -w /var/www/certbot \
--email $$EMAIL --agree-tos --no-eff-email $$DOMAIN_ARGS
chmod 755 /etc/letsencrypt/live
chmod 755 /etc/letsencrypt/archive
chmod 644 /etc/letsencrypt/live/*/fullchain.pem
chmod 600 /etc/letsencrypt/live/*/privkey.pem
echo 'Initial certificate obtained.'
else
echo 'Certificates already exist, skipping initial issuance.'
fi
# Start renewal loop
trap : TERM INT
while :; do
sleep 12h & wait $${!}
echo 'Running certificate renewal...'
certbot renew --deploy-hook '/usr/local/bin/renewal-hook.sh'
done
"
restart: unless-stopped
volumes:
certbot_www:
name: certbot_www_production
driver: local
certbot_conf:
name: certbot_conf_production
driver: local
File: scripts/renewal-hook.sh
#!/bin/sh
set -e
echo "Running renewal deploy hook at $(date)"
# Fix permissions for nginx readability
find /etc/letsencrypt -type d -exec chmod 755 {} \;
find /etc/letsencrypt -type f -exec chmod 644 {} \;
chmod 600 /etc/letsencrypt/live/*/privkey.pem
# Reload nginx to pick up new certificates
# Using docker exec from within the container (requires docker socket)
# Alternative: send SIGHUP via a sidecar or shared PID namespace
if command -v docker >/dev/null 2>&1; then
docker exec nginx-proxy nginx -s reload 2>/dev/null || true
fi
echo "Renewal hook completed successfully"
File: nginx/nginx.conf (production version with security hardening)
user nginx;
worker_processes auto;
pid /var/run/nginx.pid;
events {
worker_connections 2048;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log warn;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# Security
server_tokens off;
limit_req_zone $binary_remote_addr zone=acme_limit:10m rate=10r/m;
limit_req_zone $binary_remote_addr zone=general_limit:10m rate=30r/s;
upstream app_backend {
server app:3000 max_fails=3 fail_timeout=10s;
keepalive 16;
}
# HTTP server — ACME challenge + HTTPS redirect
server {
listen 80;
server_name example.com www.example.com;
# ACME challenge path with strict restrictions
location ^~ /.well-known/acme-challenge/ {
limit_req zone=acme_limit burst=5 nodelay;
limit_except GET HEAD {
deny all;
}
root /var/www/certbot;
default_type text/plain;
autoindex off;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location / {
limit_req zone=general_limit burst=10 nodelay;
return 301 https://$host$request_uri;
}
}
# HTTPS server — application traffic
server {
listen 443 ssl http2;
server_name example.com www.example.com;
# Certificate paths
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# Modern TLS configuration
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 on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;
# OCSP stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 1.1.1.1 valid=300s;
resolver_timeout 5s;
# Security headers
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;
add_header X-XSS-Protection "1; mode=block" always;
location / {
limit_req zone=general_limit burst=20 nodelay;
proxy_pass http://app_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
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;
proxy_send_timeout 60s;
}
# Health check endpoint (no logging)
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain