*Last updated: November 2026 — Let’s Encrypt HTTP-01; Caddy 2 in Docker Compose.*
For: Caddy in Compose fails ACME (Let’s Encrypt) with messages like unable to get certificate, error: ... challenge, NXDOMAIN, or browsers show invalid/self-signed after first boot.
Do not use this when: You intentionally use DNS-01 only, Cloudflare Tunnel with no public 80/443, or a private hostname with no public DNS. Different issuance path.
Requirement
Why
Public DNS name → your VPS
LE connects to your IP on port 80
Port **80/tcp** reachable from the internet
HTTP-01 challenge
Caddy (or whatever answers ACME) owns host port 80
Challenge must hit Caddy
Hostname in Caddyfile matches the DNS name
Wrong site block = wrong/missing cert
Outbound HTTPS from VPS to LE
Caddy must talk to ACME API
TLS on 443 is for serving the cert; issuance still needs 80 for classic HTTP-01 unless you switch challenge type.
#
Check
Command / action
Pass looks like
1
DNS A record
`dig +short app.example.com A`
Your VPS public IPv4
2
DNS AAAA (if any)
`dig +short app.example.com AAAA`
Empty **or** your VPS IPv6 that Caddy listens on
3
Propagation
Check from phone LTE / online dig, not only the VPS
Same IP off-network
4
ufw
`sudo ufw status`
80/tcp and 443/tcp allow (or ufw inactive with cloud SG open)
5
Cloud security group
Provider panel (Hetzner/DO/AWS)
Inbound 80, 443
6
Host port 80 owner
`sudo ss -tlnp \
grep ':80'`
`docker-proxy` / caddy container, not host nginx
7
Compose publish
`docker compose ps`
Caddy maps `0.0.0.0:80->80` and `443`
8
Caddyfile host
Open Caddyfile
Exact `app.example.com { ... }`
9
ACME email env
`.env` / Caddy global options
Valid email for CA account
10
Logs
`docker compose logs caddy --tail=100`
Obtain success, not repeated challenge fail
Symptom / log clue
Cause
Fix
`NXDOMAIN` / no such host
DNS not created or wrong zone
Create A (and optional AAAA) in the DNS panel for the **FQDN** Caddy uses; wait TTL
Challenge fails; dig from laptop shows old IP
DNS not propagated / cached
Wait TTL; lower TTL before changes; verify with external resolver `1.1.1.1`
Challenge fails; AAAA points to another host or dead IPv6
LE prefers IPv6 when AAAA exists
**Either** fix VPS IPv6 + Caddy listen on `::` **or** remove AAAA until IPv6 works
`connection refused` on :80 from outside
Firewall or nothing listening
Open ufw + security group; ensure Caddy running and published
`timeout` to :80
Provider blocks 80, or wrong IP
Confirm public IP; some networks block inbound 80 — use a VPS that allows it or DNS-01
Host nginx answers :80, Caddy in Docker also wants :80
Port conflict
Stop host nginx/apache/`certbot` standalone; only Caddy on 80
`bind: address already in use` on Caddy start
Port taken
Same as conflict — free 80/443
Works with `curl -H 'Host: ...' http://IP` but LE fails
DNS still wrong
Fix DNS to this IP; LE does not use your `/etc/hosts`
Cert for wrong name / browser name mismatch
Caddyfile site address ≠ URL you visit
Align DNS, Caddyfile, and app `DOMAIN`/`PUBLIC_URL`
Rate limit errors from Let’s Encrypt
Too many failed/successful attempts
Fix root cause first; wait out rate limit; use staging CA for tests
Staging cert trusted by mistake in prod
Testing with LE staging
Remove staging config; clear Caddy cert storage carefully; re-issue production
Fails only after reboot
Caddy not `restart: unless-stopped` or depends on network
Fix restart policy; wait for docker + network
Immich/Vaultwarden show HTTP errors after cert OK
App env still `http://` or wrong host
Set `https://app.example.com` in app env; recreate container
Corporate middlebox / CGNAT VPS without public IP
No inbound path for HTTP-01
Need public IP, or DNS-01 / tunnel — not this checklist’s default
If any AAAA exists, Let’s Encrypt may connect via IPv6.
Situation
Result
Action
AAAA → correct VPS, Caddy listens IPv6, firewall allows 80/443 v6
OK
Keep AAAA
AAAA → correct VPS, ufw allows only IPv4
Challenge fail on v6
`ufw allow 80/tcp` and `443/tcp` (ufw applies to v6 if configured); verify `ufw status` includes v6
AAAA → old host / parking / unreachable
Fail
Delete AAAA or update to current VPS IPv6
No IPv6 on VPS but AAAA present
Fail
**Remove AAAA**
Quick test from an external dual-stack network:
curl -4 -I http://app.example.com/.well-known/acme-challenge/test
curl -6 -I http://app.example.com/.well-known/acme-challenge/test
You want Caddy to answer on the families you publish in DNS (404 on fake token is OK; timeout/refused is not).
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose
If IPv6 is enabled on the host (/etc/default/ufw → IPV6=yes), confirm rules show v6 entries.
services:
caddy:
ports:
- "80:80"
- "443:443"
- "443:443/udp" # optional HTTP/3
Apps should not also publish 80/443.
Fix DNS/firewall first (checks 1–7 above).
docker compose logs -f caddy in one session.
docker compose up -d caddy (or full stack).
Watch for obtain/renew success.
curl -vI https://app.example.com — check certificate subject and issuer.
If you must experiment repeatedly, use Let’s Encrypt staging in a throwaway Caddy config, then switch back to production once the path works.
Only after DNS/firewall are fixed and Caddy keeps reusing a bad state:
Step
Action
1
`docker compose stop caddy`
2
Backup then clear the `caddy-data` volume/bind used for certificates (path depends on your compose)
3
Start Caddy again and let it re-issue
Do not wipe on every failure — fix reachability first.
External dig A/AAAA match the VPS you intend
No stale AAAA to a dead address
ufw + cloud SG allow 80/443
Only Caddy owns host 80/443
https://hostname shows a publicly trusted cert (Let’s Encrypt)
App public URL env uses the same hostname with https://
Common Compose errors (ports, OOM, volumes)
Caddy vs Traefik for multi-app