← Back to DevBytes

Docker Compose with Let's Encrypt SSL: Production Guide

Docker Compose with Let's Encrypt SSL: Production Guide

Running containerized web applications in production requires robust TLS termination. Combining Docker Compose with Let's Encrypt gives you automated, free SSL certificates that renew themselves — all while maintaining the clean orchestration Docker Compose provides. This tutorial walks through every step needed to deploy a production-ready stack with HTTPS, from initial setup through ongoing maintenance.

What You Will Build

By the end of this guide, you will have a Docker Compose stack consisting of:

Why Docker Compose + Let's Encrypt Matters

In production environments, you face several critical requirements simultaneously:

Combining these technologies gives you a self-contained, reproducible, and secure deployment that costs nothing beyond your infrastructure.

Prerequisites

Project Structure

Start by creating a clean project directory. The structure we will build looks like this:

project/
├── docker-compose.yml
├── nginx/
│   └── nginx.conf
├── certbot/
│   ├── conf/        (generated at runtime)
│   └── www/         (challenge files, generated at runtime)
├── app/
│   └── index.html   (your application lives here)
└── scripts/
    └── init-letsencrypt.sh

Create this structure now:

mkdir -p ~/docker-le-ssl/{nginx,certbot/conf,certbot/www,app,scripts}
cd ~/docker-le-ssl

Step 1: Create a Sample Application

For demonstration, we will deploy a simple static site served by Nginx. In a real project, this could be a Node.js app, a Python service, or any containerized workload. Create a minimal page:

cat > app/index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Docker + Let's Encrypt Demo</title>
  <style>
    body { font-family: system-ui; max-width: 600px; margin: 4rem auto; padding: 0 1rem; }
    h1 { color: #2d3748; }
    .badge { background: #48bb78; color: white; padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.85rem; }
  </style>
</head>
<body>
  <h1>Hello from Docker Compose with Let's Encrypt</h1>
  <p>This page is served over <span class="badge">HTTPS</span> with a free, auto-renewed certificate.</p>
  <p>Server time: <span id="time"></span></p>
  <script>
    document.getElementById('time').textContent = new Date().toISOString();
  </script>
</body>
</html>
EOF

Step 2: Write the Nginx Configuration

The Nginx reverse proxy will handle two distinct phases: the initial HTTP-only mode (used during certificate issuance) and the final HTTPS mode (used in production). We write a single configuration file that adapts to both phases.

cat > nginx/nginx.conf << 'EOF'
# nginx.conf — production reverse proxy with Let's Encrypt support
# Phase 1: HTTP only (certificate not yet obtained)
# Phase 2: HTTPS with HTTP redirect (certificate loaded)

events {
    worker_connections 1024;
}

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;

    # Optimizations
    sendfile        on;
    keepalive_timeout  65;
    gzip            on;
    gzip_types      text/plain text/css application/json application/javascript text/xml application/xml;

    # --- Upstream definition for your application ---
    upstream app_backend {
        server app:80;  # 'app' is the Docker Compose service name
    }

    # --- HTTP server (used for ACME challenge and initial redirect) ---
    server {
        listen 80;
        server_name example.com www.example.com;   # REPLACE with your domain

        # Let's Encrypt HTTP-01 challenge location
        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
            # Allow dotfiles in challenge directory
            location ~ /.well-known/acme-challenge/(.*) {
                root /var/www/certbot;
            }
            default_type text/plain;
        }

        # Redirect everything else to HTTPS (once certificate exists)
        location / {
            return 301 https://$host$request_uri;
        }
    }

    # --- HTTPS server (production) ---
    server {
        listen 443 ssl http2;
        server_name example.com www.example.com;   # REPLACE with your domain

        # SSL certificate paths — these files are symlinked by Certbot
        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:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
        ssl_prefer_server_ciphers off;
        ssl_session_cache   shared:SSL:10m;
        ssl_session_timeout 1d;
        ssl_session_tickets off;

        # OCSP stapling
        ssl_stapling on;
        ssl_stapling_verify on;
        resolver 1.1.1.1 8.8.8.8 valid=300s;
        resolver_timeout 5s;

        # Security headers
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
        add_header X-Frame-Options "SAMEORIGIN" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-XSS-Protection "1; mode=block" always;

        # Let's Encrypt challenge also accessible on HTTPS
        location /.well-known/acme-challenge/ {
            root /var/www/certbot;
            default_type text/plain;
        }

        # Proxy requests to the application backend
        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;
            proxy_set_header X-Forwarded-Host $host;
            proxy_set_header X-Forwarded-Port $server_port;

            # WebSocket support (if your app uses WebSockets)
            proxy_http_version 1.1;
            proxy_set_header Upgrade $http_upgrade;
            proxy_set_header Connection "upgrade";

            # Timeouts
            proxy_connect_timeout 60s;
            proxy_send_timeout 60s;
            proxy_read_timeout 60s;
        }
    }

    # Default catch-all for unrecognized hostnames
    server {
        listen 80 default_server;
        listen 443 ssl default_server;
        server_name _;
        # Self-signed placeholder cert to avoid connection errors
        ssl_certificate     /etc/nginx/ssl/dummy.crt;
        ssl_certificate_key /etc/nginx/ssl/dummy.key;
        return 444;  # Close connection silently
    }
}
EOF

Important: Replace example.com and www.example.com with your actual domain throughout the configuration. There are four occurrences: in the server_name directives and in the ssl_certificate paths.

Step 3: Create the Docker Compose File

This is the heart of the setup. We define three services: nginx (reverse proxy), app (your application), and certbot (certificate manager).

cat > docker-compose.yml << 'EOF'
version: "3.9"

services:
  # --- Nginx Reverse Proxy ---
  nginx:
    image: nginx:1.25-alpine
    container_name: nginx-proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      # Main configuration
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      # Certbot challenge files (shared with certbot container)
      - certbot_www:/var/www/certbot:ro
      # Live certificates (shared with certbot container)
      - certbot_conf:/etc/letsencrypt:ro
      # Dummy self-signed cert for catch-all server block
      - ./nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      - app
    networks:
      - frontend

  # --- Your Application ---
  app:
    image: nginx:1.25-alpine
    container_name: app-backend
    restart: unless-stopped
    volumes:
      - ./app/index.html:/usr/share/nginx/html/index.html:ro
    networks:
      - frontend
    # Application only listens internally on port 80 — no public ports exposed

  # --- Certbot for Let's Encrypt ---
  certbot:
    image: certbot/certbot:latest
    container_name: certbot-client
    volumes:
      # Shared volume for ACME challenge files
      - certbot_www:/var/www/certbot:rw
      # Shared volume for certificates and renewal config
      - certbot_conf:/etc/letsencrypt:rw
    # Certbot runs on-demand via command, not as a persistent service
    entrypoint: ["/bin/sh", "-c"]
    command: |
      "trap : TERM INT; sleep infinity & wait"
    networks:
      - frontend

# --- Named Volumes for Certificate Data ---
volumes:
  certbot_www:
    driver: local
  certbot_conf:
    driver: local

# --- Network ---
networks:
  frontend:
    driver: bridge
EOF

Key design decisions in this compose file:

Step 4: Generate Placeholder SSL Files

Before Nginx starts, it needs the dummy certificate referenced in the catch-all server block. Create these now:

mkdir -p nginx/ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout nginx/ssl/dummy.key \
  -out nginx/ssl/dummy.crt \
  -subj "/CN=localhost" \
  -addext "subjectAltName=DNS:localhost"

Step 5: The Initialization Script

We need a bootstrap script that obtains the first certificate. This script runs the certbot container with the HTTP-01 challenge, verifies domain ownership, and obtains the certificate — all while Nginx is running in HTTP-only mode.

cat > scripts/init-letsencrypt.sh << 'EOF'
#!/bin/bash
# init-letsencrypt.sh — bootstrap Let's Encrypt certificates for the first time
# Usage: ./scripts/init-letsencrypt.sh example.com www.example.com

set -euo pipefail

# --- Configuration ---
DOMAIN="${1:-}"
ALTERNATE_DOMAINS="${2:-}"

if [ -z "$DOMAIN" ]; then
    echo "ERROR: You must provide at least one domain name."
    echo "Usage: $0 example.com [www.example.com]"
    exit 1
fi

COMPOSE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$COMPOSE_DIR"

# --- Pre-flight checks ---
echo "==> Pre-flight checks..."
if ! docker compose ps &>/dev/null; then
    echo "ERROR: Docker Compose services not running. Start them first: docker compose up -d"
    exit 1
fi

# --- Build certbot arguments ---
echo "==> Preparing certificate request for domain: $DOMAIN"
CERTBOT_ARGS="certonly --webroot -w /var/www/certbot"
CERTBOT_ARGS="$CERTBOT_ARGS --email admin@${DOMAIN} --agree-tos --no-eff-email"
CERTBOT_ARGS="$CERTBOT_ARGS -d $DOMAIN"

if [ -n "$ALTERNATE_DOMAINS" ]; then
    for alt in $ALTERNATE_DOMAINS; do
        CERTBOT_ARGS="$CERTBOT_ARGS -d $alt"
        echo "   Adding alternate domain: $alt"
    done
fi

CERTBOT_ARGS="$CERTBOT_ARGS --rsa-key-size 4096"

# --- Request the certificate ---
echo "==> Requesting Let's Encrypt certificate..."
docker compose exec certbot certbot $CERTBOT_ARGS

# --- Verify certificate was obtained ---
echo "==> Verifying certificate..."
LIVE_PATH="/etc/letsencrypt/live/${DOMAIN}"
if docker compose exec certbot test -f "${LIVE_PATH}/fullchain.pem" 2>/dev/null; then
    echo "SUCCESS: Certificate obtained and stored at ${LIVE_PATH}"
else
    echo "ERROR: Certificate file not found. Check certbot logs above."
    exit 1
fi

# --- Reload nginx to pick up the new certificate ---
echo "==> Reloading Nginx to enable HTTPS..."
docker compose exec nginx nginx -s reload

echo ""
echo "============================================"
echo " Certificate successfully installed!"
echo " Visit: https://${DOMAIN}"
echo "============================================"
echo ""
echo "Next steps:"
echo "  1. Verify HTTPS works in your browser"
echo "  2. Test renewal with: docker compose exec certbot certbot renew --dry-run"
echo "  3. Set up automated renewal (see tutorial)"
EOF

chmod +x scripts/init-letsencrypt.sh

Step 6: Start the Stack and Obtain Certificates

With all pieces in place, we start the services and run the initialization script.

# Start all services in detached mode
docker compose up -d

# Verify services are healthy
docker compose ps

# Check Nginx is responding on port 80
curl -I http://your-domain.com

# Run the initialization script (REPLACE with your actual domain)
./scripts/init-letsencrypt.sh your-domain.com www.your-domain.com

What happens during initialization:

  1. The script runs certbot certonly --webroot inside the certbot container
  2. Certbot places a challenge file in /var/www/certbot/.well-known/acme-challenge/
  3. Let's Encrypt's validation servers fetch that file via your domain on port 80 (served by Nginx)
  4. Upon successful validation, Certbot stores the certificate in /etc/letsencrypt/live/your-domain.com/
  5. The script reloads Nginx, which now finds the real certificate and enables HTTPS

At this point, visiting https://your-domain.com should show your application served over a valid HTTPS connection with a padlock in the browser.

Step 7: Automated Certificate Renewal

Let's Encrypt certificates are valid for 90 days. Renewal must happen automatically. We set up a cron job on the host that runs the renewal command inside the certbot container twice daily (as recommended by Let's Encrypt).

# Create a renewal script
cat > scripts/renew-certificates.sh << 'EOF'
#!/bin/bash
# renew-certificates.sh — called by cron to renew certificates nearing expiry

set -euo pipefail

COMPOSE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
LOGFILE="${COMPOSE_DIR}/logs/renewal.log"

mkdir -p "${COMPOSE_DIR}/logs"

echo "=== Renewal attempt at $(date -u +%Y-%m-%dT%H:%M:%SZ) ===" >> "$LOGFILE"

cd "$COMPOSE_DIR"

# Run certbot renew — this only renews certificates that are due
if docker compose exec certbot certbot renew --quiet --deploy-hook "nginx -s reload" >> "$LOGFILE" 2>&1; then
    echo "Renewal completed successfully." >> "$LOGFILE"
else
    echo "ERROR: Renewal failed. Check certbot logs." >> "$LOGFILE"
fi

# Reload nginx regardless (safe operation)
docker compose exec nginx nginx -s reload >> "$LOGFILE" 2>&1 || true

echo "" >> "$LOGFILE"
EOF

chmod +x scripts/renew-certificates.sh

Now install the cron job on the host. Run it twice daily with a random minute offset to avoid thundering herd problems with Let's Encrypt servers:

# Edit the root crontab
sudo crontab -e

# Add these two lines (runs at 03:17 and 15:17 UTC daily)
17 3 * * * /home/youruser/docker-le-ssl/scripts/renew-certificates.sh
17 15 * * * /home/youruser/docker-le-ssl/scripts/renew-certificates.sh

You can test the renewal process immediately with a dry run:

docker compose exec certbot certbot renew --dry-run

A successful dry run output looks like:

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
** DRY RUN: simulating 'certbot renew' close to cert expiry
**          (The test cert below has not been saved.)
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Congratulations, all renewal simulations succeeded.

Advanced: Using DNS-01 Challenge (Wildcard Certificates)

The HTTP-01 challenge works for most deployments, but if you need wildcard certificates (e.g., *.example.com) or your server cannot be reached on port 80, you must use the DNS-01 challenge. This requires a DNS provider plugin. Here is how to adapt the setup for Cloudflare:

# docker-compose.yml service override for DNS-based certbot
cat > docker-compose.dns.yml << 'EOF'
version: "3.9"

services:
  certbot:
    image: certbot/certbot:latest
    container_name: certbot-client
    volumes:
      - certbot_conf:/etc/letsencrypt:rw
      # Cloudflare credentials file
      - ./certbot/cloudflare.ini:/etc/cloudflare.ini:ro
    entrypoint: ["/bin/sh", "-c"]
    command: |
      "trap : TERM INT; sleep infinity & wait"
    networks:
      - frontend
EOF

Create the Cloudflare credentials file:

cat > certbot/cloudflare.ini << 'EOF'
# Cloudflare API credentials for certbot DNS plugin
dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
EOF

chmod 600 certbot/cloudflare.ini

Then obtain a wildcard certificate:

docker compose exec certbot certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/cloudflare.ini \
  --email admin@example.com \
  --agree-tos \
  --no-eff-email \
  -d "*.example.com" \
  -d "example.com" \
  --rsa-key-size 4096

Note that DNS-01 challenges take longer to propagate, but they enable wildcard certificates that cover all subdomains with a single certificate.

Production Best Practices

1. Pin Image Versions

In production, always pin specific image tags rather than using latest. This prevents unexpected changes breaking your stack:

# In docker-compose.yml, use specific versions:
nginx:
  image: nginx:1.25.3-alpine   # pinned

certbot:
  image: certbot/certbot:v2.7.4   # pinned

2. Isolate Secrets

Never commit API tokens or credentials to version control. Use Docker secrets (in Swarm) or environment files excluded from git:

# .gitignore additions
certbot/cloudflare.ini
.env
*.pem
*.key
nginx/ssl/

3. Monitor Certificate Expiry

Add a simple monitoring check that alerts you if renewal fails silently:

cat > scripts/check-expiry.sh << 'EOF'
#!/bin/bash
# Check certificate expiry and exit with non-zero if less than 30 days remain

DOMAIN="${1:-}"
THRESHOLD_DAYS=30

if [ -z "$DOMAIN" ]; then
    echo "Usage: $0 example.com"
    exit 2
fi

EXPIRY_DATE=$(docker compose exec certbot openssl x509 \
  -in "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" \
  -noout -enddate 2>/dev/null | cut -d= -f2)

if [ -z "$EXPIRY_DATE" ]; then
    echo "CRITICAL: Could not read certificate expiry for ${DOMAIN}"
    exit 2
fi

EXPIRY_EPOCH=$(date -d "$EXPIRY_DATE" +%s)
NOW_EPOCH=$(date +%s)
DAYS_REMAINING=$(( (EXPIRY_EPOCH - NOW_EPOCH) / 86400 ))

echo "Certificate for ${DOMAIN} expires in ${DAYS_REMAINING} days (${EXPIRY_DATE})"

if [ "$DAYS_REMAINING" -lt "$THRESHOLD_DAYS" ]; then
    echo "WARNING: Certificate expires soon. Renewal may have failed."
    exit 1
fi

exit 0
EOF

chmod +x scripts/check-expiry.sh

Integrate this check into your monitoring system (Nagios, Prometheus, or a simple cron alert):

# Weekly expiry check with email alert
0 9 * * 1 /home/youruser/docker-le-ssl/scripts/check-expiry.sh your-domain.com || \
  echo "Certificate expiring soon for your-domain.com" | mail -s "SSL Alert" alerts@example.com

4. Use a Staging Environment First

Let's Encrypt has rate limits. Before running against production, test your setup against their staging API:

# Test with staging (add --staging flag)
docker compose exec certbot certbot certonly --staging --webroot \
  -w /var/www/certbot \
  -d your-domain.com \
  --email admin@your-domain.com \
  --agree-tos \
  --no-eff-email \
  --rsa-key-size 4096

Staging certificates are not trusted by browsers but behave identically to production certificates. Once your workflow works on staging, remove the --staging flag and run against production.

5. Implement Graceful Nginx Reloads

Nginx supports hot reload via signals. Always use nginx -s reload rather than restarting the container. This reloads certificates without dropping active connections:

# Correct: hot reload (zero downtime)
docker compose exec nginx nginx -s reload

# Avoid: full container restart (drops connections)
docker compose restart nginx

6. Configure Log Rotation

Nginx and Certbot logs grow over time. Configure Docker's log driver or use host-level log rotation:

# In docker-compose.yml, under the nginx service:
logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

7. Secure the Host Firewall

Ensure only ports 80, 443, and your SSH port are open. Use ufw or your cloud provider's firewall:

sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw default deny incoming
sudo ufw enable

8. Backup Certificate Data

The certbot_conf volume contains your live certificates and renewal configuration. Back it up regularly:

# Backup script
docker compose run --rm \
  -v certbot_conf:/backup/source:ro \
  -v $(pwd)/backups:/backup/dest:rw \
  alpine tar czf /backup/dest/certbot-backup-$(date +%Y%m%d).tar.gz -C /backup/source .

Complete Production docker-compose.yml (Annotated)

Here is the final, production-ready compose file incorporating all best practices discussed:

version: "3.9"

services:
  nginx:
    image: nginx:1.25.3-alpine
    container_name: nginx-proxy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - certbot_www:/var/www/certbot:ro
      - certbot_conf:/etc/letsencrypt:ro
      - ./nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      app:
        condition: service_started
    networks:
      - frontend
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "5"
    healthcheck:
      test: ["CMD", "nginx", "-t"]
      interval: 30s
      timeout: 10s
      retries: 3

  app:
    image: nginx:1.25.3-alpine
    container_name: app-backend
    restart: unless-stopped
    volumes:
      - ./app/index.html:/usr/share/nginx/html/index.html:ro
    networks:
      - frontend
    logging:
      driver: "json-file"
      options:
        max-size: "5m"
        max-file: "3"

  certbot:
    image: certbot/certbot:v2.7.4
    container_name: certbot-client
    volumes:
      - certbot_www:/var/www/certbot:rw
      - certbot_conf:/etc/letsencrypt:rw
    entrypoint: ["/bin/sh", "-c"]
    command: |
      "trap : TERM INT; sleep infinity & wait"
    networks:
      - frontend
    logging:
      driver: "json-file"
      options:
        max-size: "5m"
        max-file: "3"

volumes:
  certbot_www:
    driver: local
  certbot_conf:
    driver: local

networks:
  frontend:
    driver: bridge

Troubleshooting Common Issues

Issue: "Connection refused" on port 443

Cause: Nginx cannot find the certificate files. Fix: Verify the symlinks in /etc/letsencrypt/live/your-domain.com/ point to valid files in the archive directory. Run docker compose exec certbot ls -la /etc/letsencrypt/live/your-domain.com/ to check.

Issue: "Too many certificates issued" error

Cause: You have hit Let's Encrypt's rate limit (5 certificates per domain per week). Fix: Use the staging environment for testing, or wait for the rate limit window to reset.

Issue: ACME challenge fails with 404

Cause: The challenge file is not being served correctly. Fix: Ensure the certbot_www volume is mounted in both nginx and certbot services. Test with: docker compose exec certbot touch /var/www/certbot/test.txt and then curl http://your-domain.com/.well-known/acme-challenge/test.txt.

Issue: Nginx starts but immediately exits

Cause: The dummy SSL files are missing or the configuration has syntax errors. Fix: Run docker compose exec nginx nginx -t to validate the configuration. Check that nginx/ssl/dummy.crt and nginx/ssl/dummy.key exist on the host.

Issue: Renewal cron job runs but certificates don't renew

Cause: Certbot's renewal configuration may have incorrect webroot path. Fix: Check /etc/letsencrypt/renewal/your-domain.com.conf inside the certbot container and ensure the webroot path matches the volume mount.

Security Considerations

🚀 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