Caddy Reverse Proxy Docker: Setup, Wildcard Certs & Reloads
Learn the easiest Caddy reverse proxy setup for Docker with automatic HTTPS. Avoid port binding, wildcard cert, and reload traps. Includes Caddyfile exampl
Caddy as a Reverse Proxy: The Working Setup, the Wildcard Trap, and the Reload That Won't Drop Your Users
Caddy is the closest thing to a set-and-forget reverse proxy for a homelab. You write a five-line config, it grabs HTTPS certificates automatically, and it routes traffic to your Docker containers by name. The traps are the parts everyone skips: port 80/443 conflicts, wildcard certs that need a DNS challenge, and reloading without dropping connections. Here's the working path, with the failure modes named.
Why Caddy
A reverse proxy sits in front of your services. When a request arrives for jellyfin.example.com, the proxy looks at the hostname, finds the matching rule, and forwards the request to the right container. Without one, you end up publishing every service on its own port (8096, 3000, 9000) and remembering which port goes with which app.
Caddy's trick is that HTTPS is not a plugin or a checkbox. It's the default. Point a domain at your server, put it in the Caddyfile, and Caddy talks to Let's Encrypt on your behalf. No certbot cron jobs, no nginx config fragments.
The working setup
Start with a shared Docker network. Every container that Caddy routes to needs to be on it, because Caddy reaches backends by container name, not by IP.
bash docker network create proxy
Then a compose file for Caddy itself:
services:
caddy:
image: caddy:2.8
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- proxy
networks:
proxy:
external: true
volumes:
caddy_data:
caddy_config:
Two things matter here. First, the proxy network is external: true — you created it, Caddy joins it, and so do your backends. Second, the caddy_data volume holds your certificates and ACME account key. Don't delete it casually; wiping it means re-registering with Let's Encrypt, and rate limits are real.
Now add a backend service. The key rule: do not publish ports on backend containers. They only need to be reachable on the Docker network.
services:
jellyfin:
image: jellyfin/jellyfin
restart: unless-stopped
networks:
- proxy
# no ports: section. Caddy is the only door.
networks:
proxy:
external: true
A Caddyfile that actually works
jellyfin.example.com {
reverse_proxy jellyfin:8096
}
grafana.example.com {
reverse_proxy grafana:3000
}
That's it. Caddy resolves jellyfin to the container's IP on the shared network, and 8096 is the port the service listens on inside the container. Save the file, then:
docker compose up -d
Caddy sees the new config, gets certificates for both subdomains, and starts proxying. If the DNS records for those subdomains point at your server, HTTPS works within a minute.
Wildcard certificates without the rate-limit headache
Individual subdomain certs work fine until you add a tenth service. Each one is a separate certificate, and Let's Encrypt rate limits apply per domain. A wildcard cert for *.example.com covers everything, and it has a second benefit: individual subdomain names appear in Certificate Transparency logs, which is how people discover your grafana.example.com exists. A wildcard hides the list.
Wildcards require the DNS challenge, and here's the trap: the stock caddy:2.8 image does not include any DNS provider modules. You need a custom build.
FROM caddy:2.8-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:2.8
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
Build it with docker build -t caddy-cloudflare . and swap the image in your compose file. Then create a Cloudflare API token with Zone:DNS:Edit permission for your zone — nothing more — and pass it as an environment variable:
environment:
CLOUDFLARE_API_TOKEN: "${CLOUDFLARE_API_TOKEN}"
The Caddyfile for a wildcard setup:
{
email you@example.com
acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
*.example.com, example.com {
@jellyfin host jellyfin.example.com
handle @jellyfin {
reverse_proxy jellyfin:8096
}
@grafana host grafana.example.com
handle @grafana {
reverse_proxy grafana:3000
}
handle {
respond "Nothing here" 404
}
}
The acme_dns option in the global block tells Caddy to use the DNS challenge for every certificate. The wildcard site block matches all subdomains, and the handle blocks route each hostname to its container. The catch-all returns a 404 for anything you haven't defined.
For AWS, swap the module for github.com/caddy-dns/route53 and set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_REGION instead.
Reload, don't restart
Editing the Caddyfile and running docker compose restart caddy works, but it drops every in-flight connection. Worse: if the new config has a syntax error, the container crash-loops and all your sites go down.
The right way:
docker compose exec caddy caddy validate --config /etc/caddy/Caddyfile
docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile
caddy reload validates the new config first. If it's bad, Caddy keeps serving the old one and tells you what's wrong. Zero downtime, and a bad edit can't take your sites offline.
Internal services that stay internal
Not everything belongs on the public internet. For services you only want on your LAN or between containers, use the internal directive. It tells Caddy to issue a certificate from its own local CA instead of Let's Encrypt:
portainer.internal {
internal
reverse_proxy portainer:9000
}
Caddy signs a cert for portainer.internal with its local CA. Your browser will warn about it unless you install Caddy's root certificate, which lives at /data/pki/authorities/local/root.crt inside the container.
For service-to-service traffic that should never leave the Docker network, bind a site to a port you don't publish:
http://metrics.internal:8080 {
internal
reverse_proxy prometheus:9090
}
The compose file for Caddy doesn't publish 8080, so nothing outside the proxy network can reach it. Other containers call http://caddy:8080 and get a proxied, TLS-free connection to Prometheus. That's the pattern for keeping monitoring dashboards off the LAN entirely.
What breaks (and the fix)
Port 80/443 already in use. The classic. Check before you start:
sudo ss -tlnp | grep -E ':80|:443'
If nginx or Apache is squatting on those ports, stop and disable it: sudo systemctl disable --now nginx apache2. If you're using the DNS challenge, you don't need port 80 at all — bind Caddy to 8080:80 and 8443:443 and skip the conflict entirely.
Backend unreachable. If you get 502 errors, the usual cause is Caddy and the backend on different networks. Both must join the same proxy network. Also: never use localhost or 127.0.0.1 in reverse_proxy — inside the container, that's Caddy itself, not your host.
Logs growing forever. Caddy doesn't rotate its own logs, and if you enable file logging into the caddy_data volume, it grows until your disk fills. The clean fix for Docker is to log to stdout and cap the Docker log driver:
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
Caddy vs Traefik vs Nginx Proxy Manager
| Caddy | Traefik | Nginx Proxy Manager | |
|---|---|---|---|
| First-run HTTPS | Automatic, zero config | Needs config or labels | Checkbox in the UI |
| Config style | One Caddyfile | Docker labels or YAML | Web UI clicks |
| Wildcard certs | DNS challenge, a few lines | DNS challenge, more moving parts | Supported, fiddly in the UI |
| Idle RAM | ~30–50 MB | ~80–150 MB | ~150–300 MB (Node + Nginx) |
| Reload without downtime | caddy reload, validates first |
Automatic on label change | UI applies, restarts backend nginx |
| Docker auto-discovery | No (manual Caddyfile) | Yes (labels) | No (manual UI entries) |
Traefik wins if you spin containers up and down constantly and want label-driven routing. Nginx Proxy Manager wins if you want a UI and accept its quirks. For a homelab where you edit a file anyway, Caddy is the smallest durable skill: one config file, version it in git, and you never touch a UI again.
FAQ
How do I configure Caddy as a reverse proxy for Docker containers?
Create a shared Docker network (docker network create proxy), join Caddy and every backend to it, and reference backends by container name in the Caddyfile: reverse_proxy jellyfin:8096. Don't publish ports on backend containers — Caddy is the only entry point.
Caddy vs Traefik vs Nginx Proxy Manager for a homelab?
Caddy for the simplest config and automatic HTTPS. Traefik if you want Docker label discovery and don't mind a steeper learning curve. Nginx Proxy Manager if you prefer a web UI over editing files, but it uses more RAM and wildcard certs are clunkier.
How do I get automatic HTTPS with Caddy and Cloudflare DNS?
Build a custom Caddy image with the caddy-dns/cloudflare module, set a Cloudflare API token with Zone:DNS:Edit permission, and add acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} to the global block. The stock Caddy image can't do DNS challenges.
How do I reload Caddy without downtime?
Run docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile. It validates the new config first and keeps serving the old one if something's wrong. Avoid docker compose restart caddy — it drops connections and crash-loops on bad configs.
Verdict
Caddy earns its place as the default reverse proxy for a homelab. The setup is short, the HTTPS is automatic, and the reload path is safe enough to trust with a bad edit at midnight. The wildcard cert requires a custom image build and a properly scoped DNS token — that's the one part that bites. Set it up once, test a renewal, and you're done.
Ad space · not an Umbrel endorsement