← Back to DevBytes

Docker Compose with Let's Encrypt SSL: Best Practices and Common Pitfalls

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:

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:

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

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles