👍 What we like
- ✓Automatic HTTPS with HTTP-to-HTTPS redirect, no certbot or cron job needed.
- ✓Short, readable configuration that reloads without downtime.
- ✓WebSockets and HTTP/3 work without special settings.
- ✓Free software with a maintained official Docker image.
👎 What to watch
- ✕Requires ports 80/443 reachable from the Internet and correct DNS for Let's Encrypt.
- ✕The DNS challenge (blocked ports, wildcards) needs an image built with an extra module.
- ✕On a Synology NAS, DSM already uses ports 80/443.
- ✕Ports published by Docker bypass ufw, which requires care.
🏆 Our picks
Affiliate links · same price for youSee all our recommended gear in the shop.
📑 Contents ▾
- 01 Prerequisites
- 02 Step-by-step installation
- · Step 1: Install Docker Engine and the Compose plugin
- · Step 2: Create the directory layout and the shared network
- · Step 3: Start the services to publish
- · Step 4: Write the Caddyfile
- · Step 5: Caddy’s Compose file
- · Step 6: Validate the configuration and start
- · Step 7: Reload after a change
- 10 Verification checks
- 11 Security and best practices
- 12 Troubleshooting common errors
- 13 FAQ
- · Do I need to configure X-Forwarded-* or Host headers?
- · How do I check that automatic renewal works?
- · How long are certificates valid?
- · What about services reachable only on my local network?
- · Can I use certbot to manage these certificates?
- 19 Read also
- 20 Host this yourself
Caddy is a web server written in Go. It obtains and renews its own TLS certificates (from Let’s Encrypt, with ZeroSSL as a fallback) as soon as a domain name appears in its configuration. That makes it an easy reverse proxy to put in front of your self-hosted services. This guide installs Caddy 2 with Docker Compose on a Linux server and publishes two demo services over HTTPS, each on its own subdomain. You can then replace them with your real applications (Nextcloud, Vaultwarden, Jellyfin…).
Everything uses current Caddy 2 syntax. The reverse_proxy directive already passes the original Host header through and adds X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host. It also handles WebSockets. You don’t need to set any headers by hand.
Prerequisites
- A 64-bit Linux server running Docker. Docker’s documentation currently supports Ubuntu 22.04 LTS, 24.04 LTS, 26.04 LTS and 26.10. Derivatives such as Linux Mint are not officially supported.
- A domain name with DNS
Arecords (andAAAAif you have IPv6) for each subdomain, for exampleapp1.exemple.frandapp2.exemple.fr. They must point to your connection’s public IP before Caddy starts for the first time. - Ports 80/TCP and 443/TCP reachable from the Internet, forwarded by your router to the server. Add 443/UDP if you want HTTP/3. Port 80 is used for the ACME HTTP challenge and the redirect to HTTPS; port 443 for the TLS-ALPN challenge and the traffic itself.
- An email address for the ACME account. It is optional but recommended.
About hardware: a Synology DS920+ can run containers with Container Manager, but DSM already uses ports 80 and 443 itself. To run Caddy there, you have to publish other ports and adjust your router’s forwarding. Check the procedure in your DSM version’s documentation. This guide targets a regular Linux server, VM or mini PC. Docker Desktop is fine for local practice, but it isn’t a platform for a proxy exposed 24/7, and it won’t get a public certificate unless ports are forwarded to that computer.
Step-by-step installation
Step 1: Install Docker Engine and the Compose plugin
Docker’s official script installs Docker Engine and the docker compose plugin (Compose v2, written with a space). It does not install the old docker-compose binary. Docker intends the script for testing and quick setups; for production, its documentation recommends the apt repository (see docs.docker.com, “Install Docker Engine on Ubuntu”). You can first preview what the script will do with --dry-run.
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh get-docker.sh --dry-run # shows the actions without installing anything
sudo sh get-docker.sh
sudo usermod -aG docker $USER # then log out and log back in
Check: both commands must print a version, and the test container must print “Hello from Docker!”.
docker --version
docker compose version
docker run --rm hello-world
Step 2: Create the directory layout and the shared network
Everything lives under /opt/caddy, with the Caddyfile in a conf/ subfolder. The image documentation recommends mounting the directory rather than the file alone: many editors replace the file when saving, and the container would then keep seeing the old version. An external Docker network, proxy, lets Caddy reach containers from other Compose projects by name.
sudo mkdir -p /opt/caddy/conf /opt/apps/demo
sudo chown -R $USER:$USER /opt/caddy /opt/apps
docker network create proxy
Check: the proxy network must appear in the list.
docker network ls --filter name=proxy
Step 3: Start the services to publish
Caddy needs targets that actually exist; otherwise it returns 502 errors. To test the whole chain end to end, start two small demo containers, traefik/whoami and nginx, attached to the proxy network. No ports are published, so only Caddy can reach them.
Create /opt/apps/demo/compose.yaml:
services:
whoami:
image: traefik/whoami
container_name: whoami
restart: unless-stopped
networks:
- proxy
nginx-demo:
image: nginx:alpine
container_name: nginx-demo
restart: unless-stopped
networks:
- proxy
networks:
proxy:
external: true
cd /opt/apps/demo
docker compose up -d
Check: both containers must be running.
docker compose ps
For a real application (Nextcloud, for example), follow the same pattern: add networks: [proxy] to its web service, declare the proxy network as external: true, and note the container name and its internal port. That pair is what goes after reverse_proxy.
Step 4: Write the Caddyfile
Create /opt/caddy/conf/Caddyfile. The unnamed block at the top holds global options (here, the ACME email). After it, each site gets its own block, starting with the domain name. Replace the domains and the address with your own.
{
email vous@exemple.fr
}
app1.exemple.fr {
reverse_proxy whoami:80
}
app2.exemple.fr {
reverse_proxy nginx-demo:80
}
That’s all Caddy needs. From this file it knows to get a certificate for each domain, redirect HTTP to HTTPS and forward requests to the named container. Do not put http:// in front of the names: that would turn off automatic HTTPS for that site.
Tip for your first attempts: if your ports or DNS aren’t ready yet, repeated failures can get you blocked by Let’s Encrypt for a while because of its rate limits. While you set things up, you can add acme_ca https://acme-staging-v02.api.letsencrypt.org/directory to the global block. Browsers don’t trust staging certificates. Remove the line once everything works, then reload Caddy.
Step 5: Caddy’s Compose file
Create /opt/caddy/compose.yaml. The named volumes caddy_data (certificates, keys, ACME account) and caddy_config are mounted at /data and /config, the locations the official image uses. /data must persist: without it, Caddy would request all its certificates again every time the container is recreated. The official image advises against the latest tag in production, so this pins the 2.11 branch (check the current tag on Docker Hub).
services:
caddy:
image: caddy:2.11
container_name: caddy
restart: unless-stopped
cap_add:
- NET_ADMIN
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./conf:/etc/caddy
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
networks:
proxy:
external: true
volumes:
caddy_data:
caddy_config:
cap_add: NET_ADMIN is optional; it only lets the QUIC stack enlarge its UDP buffers for HTTP/3. Caddy’s admin API (port 2019) listens only on localhost inside the container. Never publish that port.
Step 6: Validate the configuration and start
cd /opt/caddy
docker run --rm -v "$PWD/conf:/etc/caddy:ro" caddy:2.11 \
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
docker compose up -d
Check: validate must end with “Valid configuration”. After startup, the logs must show the certificates being obtained (“certificate obtained successfully” for each domain).
docker compose logs -f caddy
Step 7: Reload after a change
After every Caddyfile change, reload without interrupting the service:
cd /opt/caddy
docker compose exec -w /etc/caddy caddy caddy reload
If the new configuration is invalid, the command prints the error and Caddy keeps running the old one.
Verification checks
From a machine outside your network, such as a phone on 4G/5G or a remote server:
curl -I http://app1.exemple.fr # expected: 308 Permanent Redirect to https://
curl -I https://app1.exemple.fr # expected: HTTP/2 200
curl -s https://app1.exemple.fr | grep -i x-forwarded # whoami prints the headers it received
echo | openssl s_client -connect app1.exemple.fr:443 -servername app1.exemple.fr 2>/dev/null \
| openssl x509 -noout -issuer -dates
The openssl output must show a Let’s Encrypt (or ZeroSSL) issuer and current validity dates. The whoami page must show X-Forwarded-Proto: https, which proves the request went through Caddy.
Security and best practices
- Expose only 80 and 443. Never put SSH, Samba/SMB, the Docker socket or any admin interface (DSM, Portainer, Caddy’s API on 2019) on the Internet. For remote administration, use a VPN such as Tailscale.
- Watch the firewall: according to Docker’s documentation, ports published by a container bypass ufw/firewalld rules. So don’t publish any ports on your applications (they go through the
proxynetwork), and keepufwfor the host’s own services:
sudo ufw allow OpenSSH # before enabling, so you don't lock yourself out
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw enable
sudo ufw status verbose
- HSTS is not enabled by default. Add it per site only when you’re sure you’ll stay on HTTPS, because browsers remember it:
header Strict-Transport-Security "max-age=31536000"inside the site block. - Protect applications that have no login with the
basic_authdirective (generate the hash withdocker compose exec caddy caddy hash-password), or keep them on the local network. - Updates: now and then, run
docker compose pull && docker compose up -din/opt/caddyafter reading the release notes. Include thecaddy_datavolume in your backups. - Expiry notifications: Caddy doesn’t send emails, and Let’s Encrypt stopped its expiry emails in 2025. To get alerts, set up external monitoring (an HTTPS probe that checks the expiry date).
Troubleshooting common errors
- “timeout during connect” or “connection refused” in the ACME logs. Cause: port 80 or 443 doesn’t reach the server (router, ISP, firewall, CGNAT). Fix: check port forwarding and test from outside. If your ISP blocks these ports, use the DNS challenge. It requires a Caddy image built with your DNS provider’s module (see the Caddy documentation).
- “NXDOMAIN” or wrong IP. Cause: DNS doesn’t point to you yet, or a stale
AAAArecord is left over. Fix:dig +short app1.exemple.fr Aanddig +short app1.exemple.fr AAAAmust return your public addresses. - 502 Bad Gateway. Cause: the target container isn’t on the
proxynetwork, has a different name, or the internal port is wrong. Fix:docker network inspect proxymust list the container, and the port inreverse_proxymust be its internal port, not a published one. - Caddyfile changes aren’t applied. Cause: you forgot to reload, or mounted the file alone instead of the directory. Fix: mount
./conf:/etc/caddy, then run thecaddy reloadcommand from step 7. - “address already in use” at startup. Cause: another web server (Apache, nginx, DSM) already holds 80/443. Fix: find it with
sudo ss -tlnp | grep -E ':80|:443', then stop it or change its ports. - Let’s Encrypt rate-limit error. Cause: too many failed attempts. Fix: switch to the staging URL (step 4), fix the problem, wait for the block to expire, then switch back to production.
FAQ
Do I need to configure X-Forwarded-* or Host headers?
No. reverse_proxy keeps the original Host header and sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host. Some applications still need to be told to trust the proxy (for example trusted_proxies in Nextcloud’s configuration). Check their documentation.
How do I check that automatic renewal works?
Caddy renews in the background, well before expiry, and retries at increasing intervals if it fails. Run docker compose logs caddy | grep -i renew, and rerun the openssl command from the Verification section now and then to check the notAfter date.
How long are certificates valid?
Let’s Encrypt currently issues 90-day certificates by default. It has announced a gradual reduction: 64 days for the default profile from February 2027, then 45 days in February 2028. With Caddy you don’t need to change anything; renewal adjusts automatically.
What about services reachable only on my local network?
Local names such as nas.home.arpa, *.internal or localhost get a certificate from Caddy’s local authority, not from Let’s Encrypt. You then need to install that authority’s root (stored in /data/caddy/pki/authorities/local/) on your devices. You can also force this behavior with tls internal in a site block.
Can I use certbot to manage these certificates?
No. Caddy manages its own certificates in /data. certbot can’t see them and shouldn’t touch them.
Read also
Host this yourself
No hardware to manage? This kind of service runs on a managed VPS, ready in minutes. See PlanetHoster plans