Docker Compose: Nginx Reverse Proxy + SSL with Let's Encrypt

Type: Software Reference Confidence: 0.93 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

Service Configuration Summary

ServiceImagePortsVolumesKey Env
Nginx (reverse proxy)nginx:1.27-alpine80:80, 443:443./nginx/conf.d:/etc/nginx/conf.d:ro, certs:/etc/letsencrypt:ro--
Certbotcertbot/certbot:v2.11.0--certs:/etc/letsencrypt, certbot-www:/var/www/certbot--webroot -w /var/www/certbot
nginx-proxy (automated)nginxproxy/nginx-proxy:1.680:80, 443:443certs:/etc/nginx/certs, html:/usr/share/nginx/html, /var/run/docker.sock:/tmp/docker.sock:ro--
acme-companionnginxproxy/acme-companion:2.4--certs:/etc/nginx/certs, html:/usr/share/nginx/html, acme:/etc/acme.sh, /var/run/docker.sock:/var/run/docker.sock:ro[email protected]
Upstream appany app imageexpose 8080--VIRTUAL_HOST=app.example.com, LETSENCRYPT_HOST=app.example.com

SSL Configuration Directives

DirectiveRecommended ValuePurpose
ssl_protocolsTLSv1.2 TLSv1.3Allow only modern TLS versions
ssl_ciphersECDHE-ECDSA-AES128-GCM-SHA256:...Strong AEAD ciphers only
ssl_prefer_server_ciphersoffLet client choose (TLSv1.3 ignores this)
ssl_session_cacheshared:SSL:10mCache ~40,000 TLS sessions
ssl_session_timeout1dSession ticket lifetime
ssl_staplingonOCSP stapling for faster handshakes
ssl_stapling_verifyonVerify OCSP responses
Strict-Transport-Security"max-age=63072000" alwaysHSTS -- 2 year max-age
X-Content-Type-Options"nosniff" alwaysPrevent MIME sniffing
X-Frame-Options"DENY" alwaysClickjacking protection

Decision Tree

START: What is your Docker reverse proxy + SSL need?
├── Do you have 1-2 backend services with fixed domains?
│   ├── YES → Use Approach A: Manual Nginx + Certbot (full control)
│   └── NO ↓
├── Do you frequently add/remove services or have 3+ backends?
│   ├── YES → Use Approach B: nginx-proxy + acme-companion (auto-discovery)
│   └── NO ↓
├── Do you need wildcard certificates?
│   ├── YES → Use DNS-01 challenge with acme-companion
│   └── NO ↓
├── Are you in local development (no public domain)?
│   ├── YES → Use mkcert for local CA + self-signed certs
│   └── NO ↓
└── DEFAULT → Start with Approach A (manual Nginx + Certbot)

Step-by-Step Guide

1. Create project directory structure

Set up the required directories for Nginx configuration, Certbot data, and the Docker Compose file. [src3]

mkdir -p nginx/conf.d certbot/conf certbot/www
touch docker-compose.yml nginx/conf.d/default.conf

Verify: ls -la nginx/conf.d certbot/ → directories exist

2. Write Nginx configuration for SSL termination

Create the Nginx server block handling HTTP-to-HTTPS redirect, SSL termination, and reverse proxying. [src1]

# nginx/conf.d/default.conf
server {
    listen 80;
    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; }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    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 ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_stapling on;
    ssl_stapling_verify on;

    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;

    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;
    }
}

Verify: nginx -t inside the container → syntax is ok

3. Write Docker Compose file (Approach A: Manual)

Define your app, Nginx reverse proxy, and Certbot services with shared volumes. [src3]

services:
  app:
    image: your-app:latest
    expose: ["8080"]
    networks: [internal]

  nginx:
    image: nginx:1.27-alpine
    ports: ["80:80", "443:443"]
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - certbot-conf:/etc/letsencrypt:ro
      - certbot-www:/var/www/certbot:ro
    depends_on: [app]
    networks: [internal, external]
    restart: unless-stopped

  certbot:
    image: certbot/certbot:v2.11.0
    volumes:
      - certbot-conf:/etc/letsencrypt
      - certbot-www:/var/www/certbot
    entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'"

volumes:
  certbot-conf:
  certbot-www:

networks:
  internal:
  external:

Verify: docker compose config → no errors

4. Obtain initial SSL certificate

Bootstrap the first certificate before enabling HTTPS in Nginx. [src5]

# Start Nginx with HTTP-only config
docker compose up -d nginx

# Request certificate
docker compose run --rm certbot certonly \
  --webroot -w /var/www/certbot \
  -d example.com -d www.example.com \
  --email [email protected] --agree-tos --no-eff-email

# Reload Nginx with full SSL config
docker compose exec nginx nginx -s reload

Verify: curl -I https://example.com → HTTP/2 200 with HSTS header

5. Set up automated renewal

The Certbot container runs renewal every 12 hours. Add Nginx reload after successful renewal. [src5]

# The entrypoint in docker-compose.yml already handles renewal
# To manually test:
docker compose exec certbot certbot renew --dry-run

Verify: docker compose exec certbot certbot certificates → shows cert with expiry date

6. Alternative: nginx-proxy + acme-companion (Approach B)

For multi-service setups with automatic service discovery. [src2]

services:
  nginx-proxy:
    image: nginxproxy/nginx-proxy:1.6
    ports: ["80:80", "443:443"]
    volumes:
      - certs:/etc/nginx/certs:ro
      - html:/usr/share/nginx/html
      - /var/run/docker.sock:/tmp/docker.sock:ro
    restart: always

  acme-companion:
    image: nginxproxy/acme-companion:2.4
    volumes_from: [nginx-proxy]
    volumes:
      - certs:/etc/nginx/certs:rw
      - acme:/etc/acme.sh
      - /var/run/docker.sock:/var/run/docker.sock:ro
    environment:
      DEFAULT_EMAIL: [email protected]
    depends_on: [nginx-proxy]
    restart: always

  webapp:
    image: your-app:latest
    expose: ["80"]
    environment:
      VIRTUAL_HOST: app.example.com
      LETSENCRYPT_HOST: app.example.com

volumes:
  certs:
  html:
  acme:

Verify: docker compose up -d && curl -I https://app.example.com → valid SSL

Code Examples

Docker Compose: Production-grade Nginx + Certbot

Full script: docker-compose-nginx-certbot.yml (48 lines)

# docker-compose.yml -- production Nginx + Certbot
# Input:  Domain name, upstream app on port 8080
# Output: TLS-terminated reverse proxy with auto-renewing certs
services:
  app:
    image: your-app:latest
    expose: ["8080"]
    networks: [backend]
# ... (see full script)

Nginx Config: SSL Termination with Security Headers

Full script: nginx-ssl-termination.conf (55 lines)

# nginx/conf.d/default.conf -- SSL termination + reverse proxy
# Input:  HTTPS on 443, HTTP on 80
# Output: Proxied requests to upstream with TLS terminated
server {
    listen 80;
    server_name example.com;
    location /.well-known/acme-challenge/ { root /var/www/certbot; }
    location / { return 301 https://$host$request_uri; }
}
# ... (see full script)

Bash: Initial Certificate Bootstrapping Script

Full script: bootstrap-ssl.sh (45 lines)

#!/usr/bin/env bash
# Input:  DOMAIN, EMAIL environment variables
# Output: Valid Let's Encrypt certificate, running Nginx with SSL
set -euo pipefail
DOMAIN="${DOMAIN:?Set DOMAIN env var}"
EMAIL="${EMAIL:?Set EMAIL env var}"
# ... (see full script)

Docker Compose: nginx-proxy + acme-companion Multi-Service

Full script: docker-compose-nginx-proxy.yml (40 lines)

# docker-compose.yml -- nginx-proxy + acme-companion
# Input:  VIRTUAL_HOST and LETSENCRYPT_HOST env vars per service
# Output: Automatic TLS for all proxied containers
services:
  nginx-proxy:
    image: nginxproxy/nginx-proxy:1.6
    ports: ["80:80", "443:443"]
# ... (see full script)

Anti-Patterns

Wrong: Storing certificates in the Docker image

# BAD -- certificates baked into image expire, leak private keys
FROM nginx:1.27-alpine
COPY certs/fullchain.pem /etc/nginx/certs/
COPY certs/privkey.pem /etc/nginx/certs/

Correct: Use named volumes for certificate storage

# GOOD -- certificates managed externally via volumes
volumes:
  - certbot-conf:/etc/letsencrypt:ro  # Nginx reads only

Wrong: Blocking port 80 entirely

# BAD -- no port 80 means ACME challenges fail silently
services:
  nginx:
    ports:
      - "443:443"  # Port 80 missing!

Correct: Keep port 80 open with HTTPS redirect

# GOOD -- port 80 open for ACME + redirect to HTTPS
services:
  nginx:
    ports:
      - "80:80"    # Required for ACME HTTP-01 challenges
      - "443:443"

Wrong: Using deprecated TLS versions

# BAD -- TLSv1.0 and TLSv1.1 are vulnerable (POODLE, BEAST)
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;

Correct: Modern TLS only

# GOOD -- only TLSv1.2 and TLSv1.3
ssl_protocols TLSv1.2 TLSv1.3;

Wrong: Writable Docker socket mount

# BAD -- writable Docker socket = container escape risk
volumes:
  - /var/run/docker.sock:/tmp/docker.sock

Correct: Read-only Docker socket mount

# GOOD -- read-only socket limits attack surface
volumes:
  - /var/run/docker.sock:/tmp/docker.sock:ro

Wrong: No proxy headers forwarded

# BAD -- upstream sees container IP instead of real client
location / {
    proxy_pass http://app:8080;
}

Correct: Forward all relevant proxy headers

# GOOD -- upstream receives real client IP and protocol
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;
}

Common Pitfalls

Diagnostic Commands

# Test Nginx config syntax
docker compose exec nginx nginx -t

# Check certificate details and expiry
docker compose exec nginx openssl x509 -in /etc/letsencrypt/live/example.com/fullchain.pem -noout -dates -subject

# Test SSL from outside
openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates

# Check TLS version and cipher
curl -vI https://example.com 2>&1 | grep -E 'SSL connection|TLS'

# List Certbot-managed certificates
docker compose exec certbot certbot certificates

# Dry-run certificate renewal
docker compose exec certbot certbot renew --dry-run

# Check Nginx SSL error logs
docker compose logs nginx 2>&1 | grep -iE 'ssl|cert|tls|error'

# Verify OCSP stapling
openssl s_client -connect example.com:443 -status 2>/dev/null | grep -A 3 "OCSP Response"

# Test HTTP-to-HTTPS redirect
curl -I http://example.com 2>&1 | grep -i location

# Verify ACME challenge path
curl http://example.com/.well-known/acme-challenge/test

Version History & Compatibility

ComponentVersionStatusKey Notes
Nginx1.27.x (mainline)Currenthttp2 on directive (replaces listen 443 ssl http2)
Nginx1.25.xStableFirst with HTTP/2 directive change
Certbot2.11.xCurrentACME v2, Python 3.8+ required
Certbot1.xEOLPython 2 support dropped
nginx-proxy1.6.xCurrentSwitched to nginxproxy/nginx-proxy
nginx-proxy0.x (jwilder)DeprecatedOld image: jwilder/nginx-proxy
acme-companion2.4.xCurrentReplaced letsencrypt-nginx-proxy-companion
Docker Composev2Currentdocker compose (plugin, no hyphen)
Docker Composev1Deprecateddocker-compose (standalone binary)
Let's EncryptACME v2CurrentWildcard via DNS-01 challenge

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Docker Compose with 1+ web services needing HTTPSKubernetes cluster with Ingress controllercert-manager + Ingress-nginx in K8s
Need free, auto-renewing SSL (Let's Encrypt)Internal network with corporate CAMount corporate CA certs directly
Self-hosted VPS or dedicated serverManaged cloud load balancer (AWS ALB, GCP LB)Cloud provider's SSL termination
Want full control over Nginx configurationPrefer zero-config auto-HTTPSCaddy server (automatic HTTPS built-in)
Multiple domains/services behind one IPSingle static site with no dynamic backendStatic hosting with CDN (Cloudflare, Vercel)

Important Caveats

Related Units