# Docker Compose: Nginx + Certbot for Let's Encrypt HTTPS

> Run Nginx and the official certbot/certbot image with Docker Compose: shared volumes, first issuance, HTTPS config and a host renewal timer.

Source: https://ssl.sh/guides/docker-nginx-certbot/
Last updated: 2026-11-10

Run Nginx as a long-lived container and the official `certbot/certbot` image only on demand, with the challenge and certificate directories shared through bind mounts. Certbot gets the Let's Encrypt certificate with the webroot method, and a systemd timer on the host renews it and reloads Nginx.

The steps apply to Docker Engine with the Compose plugin (`docker compose`) on any Linux host and Certbot 4.1 or later images.

## Prerequisites

Nginx publishes ports 80 and 443 on the host; Certbot only runs when needed.

- A Linux host with Docker Engine and the Compose plugin, and sudo access.
- DNS `A`/`AAAA` records for `example.com` and `www.example.com` pointing at the host, for example `203.0.113.10`.
- Inbound TCP 80 and 443 open to all sources, with nothing else bound to them on the host.
- A project directory, for example `/opt/web`.

## Step 1: Create the Compose file and directories

One Compose file defines both services; they share `./certbot/www` for challenges and `./certbot/conf` for certificates:

```bash
sudo mkdir -p /opt/web/nginx/conf.d /opt/web/certbot/www /opt/web/certbot/conf
cd /opt/web
```

```yaml
# /opt/web/compose.yaml
services:
  nginx:
    image: nginx:stable
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./certbot/www:/var/www/certbot:ro
      - ./certbot/conf:/etc/letsencrypt:ro
    restart: unless-stopped

  certbot:
    image: certbot/certbot
    volumes:
      - ./certbot/www:/var/www/certbot
      - ./certbot/conf:/etc/letsencrypt
    profiles: ["tools"]
```

The `tools` profile keeps the certbot service from starting with `docker compose up`; you run it on demand. Pin image tags in production and update them deliberately.

## Step 2: Serve the challenge directory over HTTP

Nginx must answer `/.well-known/acme-challenge/` from the shared directory on port 80 and redirect everything else:

```nginx
# /opt/web/nginx/conf.d/example.com.conf
server {
    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;
    }
}
```

```bash
cd /opt/web && sudo docker compose up -d nginx
```

## Step 3: Obtain the certificate

Run the certbot service once with the webroot method:

```bash
cd /opt/web
sudo docker compose run --rm certbot certonly --webroot -w /var/www/certbot \
  --non-interactive --agree-tos \
  -d example.com -d www.example.com
```

The certificate ends up in `./certbot/conf/live/example.com/` on the host. Let's Encrypt stopped sending expiry emails on June 4, 2025, so no email address is required.

## Step 4: Add the HTTPS server block

Add the 443 server block only after the certificate exists, because Nginx refuses to start when a certificate file is missing. Append it to the same Nginx file:

```nginx
server {
    listen 443 ssl;
    http2 on;    # nginx 1.25.1+, true for current nginx:stable images
    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;

    location / {
        root /usr/share/nginx/html;
    }
}
```

```bash
cd /opt/web && sudo docker compose exec nginx nginx -t && sudo docker compose exec nginx nginx -s reload
```

Replace the `location /` block with `proxy_pass http://app:3000;` to front another container on the same Compose network. Leave out `ssl_stapling`: Let's Encrypt certificates have no OCSP URL.

## Verify

The endpoint should present a Let's Encrypt certificate for both names, and HTTP should return a 301. Check it with the [SSL checker](https://ssl.sh/tools/ssl-checker/), or from any terminal:

```bash
curl ssl.sh/example.com
```

On the host:

```bash
cd /opt/web && sudo docker compose run --rm certbot certificates
curl -sI http://example.com | head -n 3
```

## Automatic renewal

The Certbot container does not run continuously, so schedule renewal on the host with a systemd timer. `certbot renew` checks Let's Encrypt's ARI window (Certbot 4.1+) and only renews when due; reloading Nginx afterwards is harmless when nothing changed.

```ini
# /etc/systemd/system/certbot-docker.service
[Unit]
Description=Renew Let's Encrypt certificates (Docker)

[Service]
Type=oneshot
WorkingDirectory=/opt/web
ExecStart=/usr/bin/docker compose run --rm certbot renew --quiet
ExecStartPost=/usr/bin/docker compose exec -T nginx nginx -s reload
```

```ini
# /etc/systemd/system/certbot-docker.timer
[Unit]
Description=Twice-daily certificate renewal

[Timer]
OnCalendar=*-*-* 03,15:00:00
RandomizedDelaySec=1h
Persistent=true

[Install]
WantedBy=timers.target
```

```bash
sudo systemctl daemon-reload && sudo systemctl enable --now certbot-docker.timer
cd /opt/web && sudo docker compose run --rm certbot renew --dry-run
```

This keeps working when Let's Encrypt moves to 64-day certificates on February 10, 2027. Add the domain to [ssl.sh monitoring](https://ssl.sh/monitor/) (in development) because Let's Encrypt no longer emails before expiry.

## Troubleshooting

- **cannot load certificate ... No such file or directory** when Nginx starts: the 443 block was added before the certificate existed, or only `live/` is mounted; mount the whole `./certbot/conf` directory.
- **Timeout during connect** or **404 on the challenge**: port 80 is not published, the webroot paths differ between containers, or DNS is wrong; see [certbot timeout during connect](https://ssl.sh/errors/certbot-timeout-during-connect/).
- **too many certificates already issued**: repeated test runs; use `--test-cert` and see [Let's Encrypt rate limits](https://ssl.sh/errors/lets-encrypt-rate-limit/).
- **Old certificate still served after renewal**: the reload step did not run; check `systemctl status certbot-docker.service`.
- **Apps fail with an incomplete chain**: Nginx points at `cert.pem`; see [missing intermediate certificate](https://ssl.sh/errors/missing-intermediate-certificate/).
