---
# === IDENTITY ===
id: software/devops/docker-nginx-ssl/2026
canonical_question: "Docker Compose reference: Nginx Reverse Proxy + SSL"
aliases:
  - "Docker Compose Nginx SSL Let's Encrypt"
  - "nginx reverse proxy Docker SSL termination"
  - "Docker Compose HTTPS setup with Certbot"
  - "nginx-proxy acme-companion Docker setup"
  - "automated SSL certificates Docker Nginx"
  - "Let's Encrypt Docker Compose auto renewal"
  - "Docker Nginx reverse proxy TLS configuration"
  - "Docker Compose SSL termination reverse proxy"
entity_type: software_reference
domain: software > devops > Docker Nginx SSL
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === VERIFICATION ===
last_verified: 2026-02-27
confidence: 0.93
version: 1.0
first_published: 2026-02-27

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: stable
  last_breaking_change: "nginx-proxy v1.6 switched from jwilder/nginx-proxy to nginxproxy/nginx-proxy image (2023); acme-companion replaced letsencrypt-nginx-proxy-companion (2022)"
  next_review: 2026-08-26
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "Port 80 MUST remain open and publicly reachable for HTTP-01 ACME challenges -- blocking it prevents certificate issuance and renewal"
  - "NEVER store TLS private keys in Docker images or version control -- use named volumes or host-mounted secrets"
  - "ssl_protocols MUST be set to TLSv1.2 TLSv1.3 only -- TLSv1.0 and TLSv1.1 are deprecated and insecure"
  - "Let's Encrypt rate limits apply: 50 certificates per registered domain per week, 5 duplicate certificates per week"
  - "Docker socket mounting (/var/run/docker.sock) grants root-equivalent access -- use read-only mount (:ro) and restrict container privileges"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "Need Kubernetes ingress with TLS, not Docker Compose"
    use_instead: "software/devops/kubernetes-ingress-tls/2026"
  - condition: "Need Traefik as reverse proxy instead of Nginx"
    use_instead: "software/devops/traefik-docker-ssl/2026"
  - condition: "Need Caddy auto-HTTPS (simpler alternative to Nginx + Certbot)"
    use_instead: "software/devops/caddy-docker-ssl/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: "approach"
    question: "Do you want a manual setup (Nginx + Certbot containers) or an automated solution (nginx-proxy + acme-companion)?"
    type: choice
    options: ["manual (Nginx + Certbot)", "automated (nginx-proxy + acme-companion)"]
  - key: "service_count"
    question: "How many backend services do you need to proxy?"
    type: choice
    options: ["1 service", "2-5 services", "6+ services (microservices)"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/devops/docker-nginx-ssl/2026"
suggested_citation: "Source: knowledgelib.io -- AI Knowledge Library (verified 2026-02-27)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: "software/devops/docker-compose-networking/2026"
      label: "Docker Compose Networking Guide"
    - id: "software/devops/nginx-performance-tuning/2026"
      label: "Nginx Performance Tuning"
  solves: []
  alternative_to:
    - id: "software/devops/traefik-docker-ssl/2026"
      label: "Traefik Docker SSL Setup"
    - id: "software/devops/caddy-docker-ssl/2026"
      label: "Caddy Docker Auto-HTTPS"
  often_confused_with:
    - id: "software/devops/kubernetes-ingress-tls/2026"
      label: "Kubernetes Ingress TLS"

# === SOURCES (7 authoritative sources) ===
sources:
  - id: src1
    title: "NGINX SSL Termination"
    author: NGINX Inc.
    url: https://docs.nginx.com/nginx/admin-guide/security-controls/terminating-ssl-http/
    type: official_docs
    published: 2025-06-01
    reliability: authoritative
  - id: src2
    title: "acme-companion: Automated ACME SSL certificate generation for nginx-proxy"
    author: nginx-proxy project
    url: https://github.com/nginx-proxy/acme-companion
    type: official_docs
    published: 2025-09-01
    reliability: high
  - id: src3
    title: "letsencrypt-docker-compose: Nginx and Let's Encrypt with Docker Compose"
    author: Eugene Khyst
    url: https://github.com/eugene-khyst/letsencrypt-docker-compose
    type: community_resource
    published: 2024-12-01
    reliability: moderate_high
  - id: src4
    title: "Official Nginx Docker Image"
    author: Docker Inc. / NGINX Inc.
    url: https://hub.docker.com/_/nginx
    type: official_docs
    published: 2025-12-01
    reliability: authoritative
  - id: src5
    title: "Certbot Docker Image"
    author: Electronic Frontier Foundation
    url: https://hub.docker.com/r/certbot/certbot
    type: official_docs
    published: 2025-10-01
    reliability: high
  - id: src6
    title: "NGINX TLS 1.3 Hardening: A+ SSL Configuration Guide"
    author: GetPageSpeed
    url: https://www.getpagespeed.com/server-setup/nginx/nginx-tls-1-3-hardening/amp
    type: technical_blog
    published: 2025-08-01
    reliability: moderate_high
  - id: src7
    title: "How to Set Up Docker with Nginx as a Reverse Proxy"
    author: OneUptime
    url: https://oneuptime.com/blog/post/2026-01-16-docker-nginx-reverse-proxy/view
    type: technical_blog
    published: 2026-01-16
    reliability: moderate_high
---

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

## TL;DR

- **Bottom line**: Use Docker Compose to run Nginx as a TLS-terminating reverse proxy with automated Let's Encrypt certificate management via Certbot or nginx-proxy + acme-companion.
- **Key tool/command**: `docker compose up -d` with Nginx + Certbot services sharing certificate volumes, or `nginxproxy/nginx-proxy` + `nginxproxy/acme-companion` for zero-config automated SSL.
- **Watch out for**: Blocking port 80 prevents ACME HTTP-01 challenges -- certificates will fail to issue or renew silently.
- **Works with**: Docker Compose v2+, Nginx 1.25+ (TLSv1.3), Certbot 2.x+, Let's Encrypt ACME v2.

## Constraints
<!-- Agents: read this section before recommending any action from this unit.
     These are hard constraints that must not be violated. -->

- Port 80 MUST remain open and publicly reachable for HTTP-01 ACME challenges -- blocking it prevents certificate issuance and renewal
- NEVER store TLS private keys in Docker images or version control -- use named volumes or host-mounted secrets
- ssl_protocols MUST be set to TLSv1.2 TLSv1.3 only -- TLSv1.0 and TLSv1.1 are deprecated and insecure
- Let's Encrypt rate limits apply: 50 certificates per registered domain per week, 5 duplicate certificates per week
- Docker socket mounting (/var/run/docker.sock) grants root-equivalent access -- use read-only mount (:ro) and restrict container privileges

## Quick Reference

| Service | Image | Ports | Volumes | Key Env |
|---|---|---|---|---|
| Nginx (reverse proxy) | `nginx:1.27-alpine` | `80:80`, `443:443` | `./nginx/conf.d:/etc/nginx/conf.d:ro`, `certs:/etc/letsencrypt:ro` | -- |
| Certbot | `certbot/certbot:v2.11.0` | -- | `certs:/etc/letsencrypt`, `certbot-www:/var/www/certbot` | `--webroot -w /var/www/certbot` |
| nginx-proxy (automated) | `nginxproxy/nginx-proxy:1.6` | `80:80`, `443:443` | `certs:/etc/nginx/certs`, `html:/usr/share/nginx/html`, `/var/run/docker.sock:/tmp/docker.sock:ro` | -- |
| acme-companion | `nginxproxy/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` | `DEFAULT_EMAIL=you@example.com` |
| Upstream app | any app image | expose `8080` | -- | `VIRTUAL_HOST=app.example.com`, `LETSENCRYPT_HOST=app.example.com` |

**SSL Configuration Directives:**

| Directive | Recommended Value | Purpose |
|---|---|---|
| `ssl_protocols` | `TLSv1.2 TLSv1.3` | Allow only modern TLS versions |
| `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` | Strong AEAD ciphers only |
| `ssl_prefer_server_ciphers` | `off` | Let client choose preferred cipher (TLSv1.3 ignores this) |
| `ssl_session_cache` | `shared:SSL:10m` | Cache ~40,000 TLS sessions across workers |
| `ssl_session_timeout` | `1d` | Session ticket lifetime |
| `ssl_stapling` | `on` | OCSP stapling for faster handshakes |
| `ssl_stapling_verify` | `on` | Verify OCSP responses |
| `add_header Strict-Transport-Security` | `"max-age=63072000" always` | HSTS -- 2 year max-age |
| `add_header X-Content-Type-Options` | `"nosniff" always` | Prevent MIME sniffing |
| `add_header X-Frame-Options` | `"DENY" always` | Clickjacking 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, explicit config)
|   +-- NO v
|-- Do you frequently add/remove services or have 3+ backends?
|   |-- YES --> Use Approach B: nginx-proxy + acme-companion (auto-discovery)
|   +-- NO v
|-- Do you need wildcard certificates?
|   |-- YES --> Use DNS-01 challenge with acme-companion (set ACME_CA_URI + DNS provider env)
|   +-- NO v
|-- Are you in local development (no public domain)?
|   |-- YES --> Use mkcert for local CA + self-signed certs (skip Let's Encrypt)
|   +-- NO v
+-- DEFAULT --> Start with Approach A (manual Nginx + Certbot) for simplicity
```

## 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]

```bash
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 the Nginx configuration for SSL termination

Create the Nginx server block that handles HTTP-to-HTTPS redirect, SSL termination, and reverse proxying to your upstream service. [src1]

```nginx
# nginx/conf.d/default.conf

# HTTP -> HTTPS redirect + ACME challenge
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    # Let's Encrypt ACME challenge
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

# HTTPS server with SSL termination
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com www.example.com;

    # SSL certificates (managed 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 caching
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;
    ssl_session_tickets off;

    # OCSP stapling
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/letsencrypt/live/example.com/chain.pem;
    resolver 1.1.1.1 1.0.0.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 Referrer-Policy "strict-origin-when-cross-origin" always;

    # Reverse proxy to upstream service
    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;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}
```

**Verify**: `nginx -t` inside the container --> `syntax is ok`

### 3. Write Docker Compose file (Approach A: Manual Nginx + Certbot)

Define three services: your app, Nginx reverse proxy, and Certbot for certificate management. [src3]

```yaml
# docker-compose.yml -- Approach A: Manual Nginx + Certbot
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;'"
    networks:
      - external

volumes:
  certbot-conf:
  certbot-www:

networks:
  internal:
    driver: bridge
  external:
    driver: bridge
```

**Verify**: `docker compose config` --> no errors, services listed correctly

### 4. Obtain initial SSL certificate

Before Nginx can serve HTTPS, you need a valid certificate. Use a temporary HTTP-only Nginx config first, then request the certificate. [src5]

```bash
# Step 4a: Start Nginx with HTTP-only config (comment out ssl server block first)
docker compose up -d nginx

# Step 4b: Request certificate from Let's Encrypt
docker compose run --rm certbot certonly \
  --webroot \
  -w /var/www/certbot \
  -d example.com \
  -d www.example.com \
  --email you@example.com \
  --agree-tos \
  --no-eff-email

# Step 4c: Restore full SSL config and reload Nginx
docker compose exec nginx nginx -s reload
```

**Verify**: `docker compose exec nginx nginx -t && curl -I https://example.com` --> returns HTTP/2 200 with HSTS header

### 5. Set up automated certificate renewal

The Certbot container runs a renewal loop every 12 hours. Add a post-renewal hook to reload Nginx. [src5]

```bash
# Create renewal hook script
mkdir -p certbot/renewal-hooks/deploy
cat > certbot/renewal-hooks/deploy/reload-nginx.sh << 'HOOK'
#!/bin/sh
# Reload Nginx after certificate renewal
docker compose exec nginx nginx -s reload
HOOK
chmod +x certbot/renewal-hooks/deploy/reload-nginx.sh
```

Or use the simpler approach: add a cron-based sidecar to your Compose file:

```yaml
# Add to docker-compose.yml
  certbot-renew:
    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 --quiet; sleep 12h & wait $${!}; done;'"
    depends_on:
      - nginx
```

**Verify**: `docker compose exec certbot certbot certificates` --> shows certificate with expiry date, `docker compose logs certbot` --> no renewal errors

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

For multi-service setups where you frequently add/remove backends, use the automated nginx-proxy stack. [src2]

```yaml
# docker-compose.yml -- Approach B: nginx-proxy + acme-companion
services:
  nginx-proxy:
    image: nginxproxy/nginx-proxy:1.6
    container_name: nginx-proxy
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - certs:/etc/nginx/certs:ro
      - html:/usr/share/nginx/html
      - /var/run/docker.sock:/tmp/docker.sock:ro
    networks:
      - proxy
    restart: always

  acme-companion:
    image: nginxproxy/acme-companion:2.4
    container_name: acme-companion
    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: you@example.com
    networks:
      - proxy
    depends_on:
      - nginx-proxy
    restart: always

  # Example: proxied web app (add to any Compose file on this Docker host)
  webapp:
    image: your-app:latest
    expose:
      - "80"
    environment:
      VIRTUAL_HOST: app.example.com
      LETSENCRYPT_HOST: app.example.com
    networks:
      - proxy

volumes:
  certs:
  html:
  acme:

networks:
  proxy:
    driver: bridge
```

**Verify**: `docker compose up -d && sleep 30 && curl -I https://app.example.com` --> returns 200 with valid SSL certificate

## Code Examples

### Docker Compose: Production-grade Nginx + Certbot

> Full script: [docker-compose-nginx-certbot.yml](scripts/docker-compose-nginx-certbot.yml) (48 lines)

```yaml
# docker-compose.yml -- production Nginx + Certbot
# Input:  Domain name, upstream app on port 8080
# Output: TLS-terminated reverse proxy with auto-renewing certificates
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](scripts/nginx-ssl-termination.conf) (55 lines)

```nginx
# nginx/conf.d/default.conf -- SSL termination + reverse proxy
# Input:  HTTPS requests on port 443, HTTP on port 80
# Output: Proxied requests to upstream app 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](scripts/bootstrap-ssl.sh) (45 lines)

```bash
#!/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](scripts/docker-compose-nginx-proxy.yml) (40 lines)

```yaml
# docker-compose.yml -- nginx-proxy + acme-companion automated SSL
# Input:  VIRTUAL_HOST and LETSENCRYPT_HOST env vars on each service
# Output: Automatic TLS certificates 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

```dockerfile
# 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

```yaml
# GOOD -- certificates managed externally via volumes
volumes:
  - certbot-conf:/etc/letsencrypt:ro  # Nginx reads only
  # Certbot writes to the same volume with :rw
```

### Wrong: Blocking port 80 entirely

```yaml
# BAD -- no port 80 means ACME challenges fail silently
services:
  nginx:
    ports:
      - "443:443"  # Port 80 missing -- certbot cannot renew
```

### Correct: Keep port 80 open with HTTPS redirect

```yaml
# 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

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

### Correct: Modern TLS only

```nginx
# GOOD -- only TLSv1.2 and TLSv1.3
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;
```

### Wrong: Running Docker socket without read-only mount

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

### Correct: Read-only Docker socket mount

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

### Wrong: No proxy headers forwarded

```nginx
# BAD -- upstream app sees 172.x.x.x instead of real client IP
location / {
    proxy_pass http://app:8080;
}
```

### Correct: Forward all relevant proxy headers

```nginx
# GOOD -- upstream app 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

- **Certificate bootstrap chicken-and-egg**: Nginx refuses to start if SSL certificate files don't exist yet, but Certbot needs Nginx running to complete HTTP-01 challenge. Fix: Start with an HTTP-only Nginx config first, obtain the cert, then switch to full SSL config. Or use a self-signed dummy cert initially. [src3]
- **Certbot renewal runs but Nginx serves stale cert**: Certbot renews the certificate on disk, but Nginx has the old cert loaded in memory. Fix: Add a `--deploy-hook "nginx -s reload"` to Certbot, or run `docker compose exec nginx nginx -s reload` via cron after renewal. [src5]
- **Container DNS resolution fails for upstream**: Nginx resolves upstream hostnames at startup and caches the IP. If the app container restarts with a new IP, Nginx still sends traffic to the old IP. Fix: Use `resolver 127.0.0.11 valid=30s;` (Docker internal DNS) with a variable: `set $upstream http://app:8080; proxy_pass $upstream;`. [src1]
- **Let's Encrypt staging vs production**: Testing with the production ACME server burns rate limits. Fix: Always use `--staging` flag during setup (`certbot certonly --staging ...`), then switch to production with `certbot certonly --force-renewal` once verified. [src5]
- **Volume permission issues**: Certbot writes as root but Nginx worker runs as non-root. Fix: Ensure certificate directory is readable by the Nginx user (default `nginx` user, UID 101 in Alpine images). Named volumes handle this automatically. [src4]
- **OCSP stapling fails with "no OCSP responder" error**: The resolver directive is missing or cannot reach the OCSP server. Fix: Add `resolver 1.1.1.1 1.0.0.1 valid=300s; resolver_timeout 5s;` in the server block with `ssl_stapling on; ssl_stapling_verify on;`. [src6]
- **Certbot `--webroot` fails inside container**: The webroot path doesn't match between Nginx and Certbot containers. Fix: Both containers must share the same volume mounted at the ACME challenge path: Nginx at `/var/www/certbot:ro` and Certbot at `/var/www/certbot:rw`. [src3]
- **acme-companion doesn't pick up new services**: The companion container needs access to the Docker socket to detect new containers. Fix: Ensure `/var/run/docker.sock:/var/run/docker.sock:ro` is mounted and both `VIRTUAL_HOST` and `LETSENCRYPT_HOST` env vars are set on the proxied container. [src2]

## Diagnostic Commands

```bash
# Test Nginx configuration syntax
docker compose exec nginx nginx -t

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

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

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

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

# Force certificate renewal (dry run)
docker compose exec certbot certbot renew --dry-run

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

# Verify OCSP stapling is working
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

# Check Docker socket permissions (nginx-proxy approach)
docker compose exec nginx-proxy ls -la /tmp/docker.sock

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

## Version History & Compatibility

| Component | Version | Status | Key Notes |
|---|---|---|---|
| Nginx | 1.27.x (mainline) | Current | `http2 on` directive (replaces `listen 443 ssl http2`) |
| Nginx | 1.25.x | Stable | First with HTTP/2 directive change |
| Certbot | 2.11.x | Current | ACME v2, Python 3.8+ required |
| Certbot | 1.x | EOL | Python 2 support dropped |
| nginx-proxy | 1.6.x | Current | Switched to `nginxproxy/nginx-proxy` image |
| nginx-proxy | 0.x (jwilder) | Deprecated | Old image: `jwilder/nginx-proxy` |
| acme-companion | 2.4.x | Current | Replaced `letsencrypt-nginx-proxy-companion` |
| Docker Compose | v2 | Current | `docker compose` (plugin, no hyphen) |
| Docker Compose | v1 | Deprecated | `docker-compose` (standalone binary) |
| Let's Encrypt | ACME v2 | Current | Wildcard support via DNS-01 challenge |

## When to Use / When Not to Use

| Use When | Don't Use When | Use Instead |
|---|---|---|
| Running Docker Compose with 1+ web services that need HTTPS | Kubernetes cluster with Ingress controller | cert-manager + Ingress-nginx in K8s |
| Need free, auto-renewing SSL certificates (Let's Encrypt) | Internal/private network with corporate CA certificates | Mount corporate CA certs directly |
| Self-hosted VPS or dedicated server with Docker | Managed cloud load balancer (AWS ALB, GCP LB, Cloudflare) | Cloud provider's built-in SSL termination |
| Want full control over Nginx configuration and tuning | Prefer zero-config reverse proxy with auto-HTTPS | Caddy server (automatic HTTPS built-in) |
| Running multiple domains/services behind one IP | Single static site with no dynamic backend | Static hosting with CDN (Cloudflare Pages, Vercel) |

## Important Caveats

- Let's Encrypt certificates expire every 90 days -- automated renewal is not optional, it is mandatory. A missed renewal means site downtime.
- The `nginx-proxy` approach mounts the Docker socket, which is a security risk. In production, consider using a Docker socket proxy (e.g., `tecnativa/docker-socket-proxy`) to limit API access.
- HTTP/2 directive syntax changed in Nginx 1.25: use `http2 on;` as a separate directive instead of `listen 443 ssl http2;`. The old syntax still works but is deprecated.
- Wildcard certificates (`*.example.com`) require DNS-01 challenges, not HTTP-01. This needs additional DNS provider configuration in acme-companion or manual Certbot DNS plugins.
- Docker Compose v1 (`docker-compose`) is deprecated. Use Docker Compose v2 (`docker compose` as a Docker CLI plugin).
- HSTS `preload` directive is irreversible once submitted to browser preload lists. Start with `max-age=300` for testing before increasing to `63072000` (2 years).

## Related Units
<!-- Generated from related_kos frontmatter -->

- [Docker Compose Networking Guide](/software/devops/docker-compose-networking/2026)
- [Nginx Performance Tuning](/software/devops/nginx-performance-tuning/2026)
- [Traefik Docker SSL Setup](/software/devops/traefik-docker-ssl/2026)
- [Caddy Docker Auto-HTTPS](/software/devops/caddy-docker-ssl/2026)
- [Kubernetes Ingress TLS](/software/devops/kubernetes-ingress-tls/2026)
