Caddy Docker Reverse Proxy: Compose Setup with Automatic HTTPS
Run Caddy as a Docker reverse proxy: one compose stack, a shared network, reverse_proxy to service names and automatic HTTPS, plus when caddy-docker-proxy labels help.
Short answer: run Caddy in its own container that publishes ports 80 and 443, attach it and your app containers to one shared Docker network, and point `reverse_proxy` at each service name and internal port (for example `reverse_proxy app:3000`). Caddy resolves the name through Docker's embedded DNS and gets a TLS certificate for every site address automatically.
This guide shows the plain Caddyfile approach first, then the label-driven caddy-docker-proxy variant, and explains when each one fits on a single VPS. If you run Caddy directly on the host instead of in Docker, see the base Caddy reverse proxy guide.
How the pieces fit together
Only the Caddy container publishes ports to the outside world. Every backend container joins a user-defined bridge network (here called `web`) and exposes its port only on that network. Docker's embedded DNS server, available on user-defined networks, lets Caddy reach `app:3000` by service or container name, so you never hard-code container IPs that change on every recreate.
Keeping backends unpublished matters more than it looks: Docker writes its own iptables rules, so a port published with `ports:` can be reachable from the internet even when ufw says it is blocked. Use `expose:` or no port mapping at all for anything that should sit behind the proxy.
| Component | Role | Published on host? |
|---|---|---|
| caddy container | TLS termination, routing by hostname, HTTP to HTTPS redirect | Yes: 80/tcp, 443/tcp, 443/udp |
| web network | Shared bridge network with built-in DNS for service names | No |
| app containers | Your services (web apps, APIs, dashboards) | No, reachable only via Caddy |
| caddy_data volume | Certificates, private keys, ACME account | No, but must persist |
Step-by-step: plain Caddyfile with docker compose
- 01.Point an A record (and an AAAA record if you use IPv6) for each hostname to the VPS before starting Caddy, or certificate issuance will fail.
- 02.Create the shared network once: `docker network create web`.
- 03.Create `/opt/proxy/compose.yaml` and `/opt/proxy/Caddyfile` as shown below, then run `docker compose up -d` in `/opt/proxy`.
- 04.Attach each app stack to the external `web` network and start it with `docker compose up -d`.
- 05.Watch the first certificate being issued with `docker compose logs -f caddy` in `/opt/proxy`.
- 06.After editing the Caddyfile, reload without downtime: `docker compose exec -w /etc/caddy caddy caddy reload`.
# /opt/proxy/compose.yaml
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data # certificates and ACME account keys
- caddy_config:/config
networks:
- web
volumes:
caddy_data:
caddy_config:
networks:
web:
external: trueAttach an app and write the Caddyfile
Each application stack declares the same external network. The service name becomes the hostname Caddy dials. The `caddy_data` volume is the one you must never lose: deleting it forces Caddy to request every certificate again, which can run into ACME rate limits.
# /opt/app/compose.yaml
services:
app:
image: ghcr.io/example/app:latest
restart: unless-stopped
expose:
- "3000" # reachable on the network, NOT published on the host
networks:
- web
networks:
web:
external: true
# /opt/proxy/Caddyfile
{
email [email protected]
}
app.example.com {
reverse_proxy app:3000
}
grafana.example.com {
reverse_proxy grafana:3000
}
api.example.com {
reverse_proxy api-1:8080 api-2:8080 {
lb_policy round_robin
health_uri /healthz
health_interval 10s
}
}Caddy backends: upstreams, load balancing and health checks
In Caddy terms a backend is an upstream of `reverse_proxy`. You can list several upstreams in one directive, choose a `lb_policy` such as `round_robin`, `least_conn` or `ip_hash`, and add active health checks with `health_uri` so failed containers drop out of rotation. Passive checks (`fail_duration`, `max_fails`) are useful too when the app has no health endpoint.
Caddy sets `X-Forwarded-For`, `X-Forwarded-Proto` and `X-Forwarded-Host` on proxied requests by default. Configure your app to trust the proxy's network range so it sees the real client IP and builds `https://` links. WebSockets and gRPC work through `reverse_proxy` without extra settings; for gRPC over cleartext, use `reverse_proxy h2c://service:50051`.
Labels instead of a Caddyfile: caddy-docker-proxy
People searching for a "Caddy proxy manager" usually want what Nginx Proxy Manager offers: add a container, get a hostname and certificate without editing a central file. Caddy has no official GUI, but the community project caddy-docker-proxy (github.com/lucaslorentz/caddy-docker-proxy) generates the Caddyfile from container labels and reloads Caddy when containers start or stop.
It needs read access to the Docker socket, which is effectively root on the host. Mount it read-only, keep the proxy image updated, and prefer the plain Caddyfile if you only run a handful of services.
services:
caddy:
image: lucaslorentz/caddy-docker-proxy:ci-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
- CADDY_INGRESS_NETWORKS=web
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- caddy_data:/data
networks:
- web
whoami:
image: traefik/whoami
networks:
- web
labels:
caddy: whoami.example.com
caddy.reverse_proxy: "{{upstreams 80}}"
volumes:
caddy_data:
networks:
web:
external: truePlain Caddyfile vs caddy-docker-proxy
| Plain Caddyfile | caddy-docker-proxy labels | |
|---|---|---|
| Configuration lives in | One file you version in git | Labels spread across compose files |
| Docker socket access | Not needed | Required (security-sensitive) |
| New service | Edit Caddyfile, run `caddy reload` | Add labels, start the container |
| Image | Official `caddy:2` | Community image built on Caddy |
| Debugging | Read the file directly | Inspect the generated config in the logs |
| Best for | Small, stable VPS setups | Many short-lived or frequently changed services |
Running it on a VPS
On a NoctisNode KVM VPS you get full root and a dedicated IPv4, so publishing 80 and 443 from one Caddy container and hiding everything else is straightforward. A Wisp (1 vCPU, 1 GB RAM) is enough for Caddy plus a couple of light containers; heavier stacks fit Pulse or Umbra. Certificate issuance details and failure modes are covered in how Caddy automatic HTTPS works, and raw TCP or SSH forwarding is in Caddy TCP and SSH proxying. Lock down host SSH with the SSH hardening guide.
FAQ
QHow do I make Caddy reverse proxy to a Docker container?
APut Caddy and the container on the same user-defined Docker network and use `reverse_proxy service-name:port` in the Caddyfile. Do not use `localhost`, because inside the Caddy container it refers to Caddy itself.
QWhy does Caddy return 502 Bad Gateway for my container?
AUsually the containers are not on a shared network, the port is wrong (use the container's internal port, not a published host port), or the app listens only on 127.0.0.1 inside its container. Make the app bind to 0.0.0.0 and check `docker network inspect web`.
QIs caddy-docker-proxy safe to use?
AIt works well, but it requires the Docker socket, which grants root-equivalent control over the host. Mount it read-only, keep the image updated and avoid exposing the Caddy admin endpoint.
QWhere does Caddy store certificates in Docker?
AIn `/data` inside the official image, so mount a named volume there. Losing it means re-issuing every certificate and possibly hitting ACME rate limits.
QIs there a Caddy proxy manager with a web UI?
ACaddy itself has no official web UI; it is configured with a Caddyfile, JSON or its admin API. Label-based tools like caddy-docker-proxy give a similar add-a-container-and-go workflow.