Caddy reverse proxy quadlet
Automatic HTTPS in front of your other containers, with certificates kept in a named volume.
- Standalone container
- Rootful: sudo systemctl
- Tested with podman 5.0 and newer
Last updated
See where this fits on the homelab connections map, with every port it uses.
In short: Caddy needs ports 80 and 443, so this quadlet runs rootful from /etc/containers/systemd/. It mounts the /etc/caddy folder read-only, keeps certificates in the caddy-data volume, and defines ExecReload= so sudo systemctl reload caddy.service applies Caddyfile changes without a restart. Rootless apps on the same host are reached through host.containers.internal and their published port.
The quadlet files
Every quadlet file below passes podman's quadlet generator and
systemd-analyze verify on podman 5.0 and the current release. Personal values
are placeholders: replace example.com, /srv/... paths and the
time zone with your own.
caddy.container
Save as /etc/containers/systemd/caddy.container
[Unit]
Description=Caddy reverse proxy
[Container]
Image=docker.io/library/caddy:2
ContainerName=caddy
AutoUpdate=registry
PublishPort=80:80
PublishPort=443:443
# HTTP/3
PublishPort=443:443/udp
# Mount the folder, not the single Caddyfile, so edits are seen after a reload.
Volume=/etc/caddy:/etc/caddy:ro,Z
# Certificates and ACME account: losing this means requesting every certificate again.
Volume=caddy-data.volume:/data
Volume=caddy-config.volume:/config
[Service]
Restart=always
ExecReload=/usr/bin/podman exec caddy caddy reload --config /etc/caddy/Caddyfile
TimeoutStartSec=900
[Install]
WantedBy=multi-user.target
caddy-data.volume
Save as /etc/containers/systemd/caddy-data.volume
[Volume]
VolumeName=caddy-data
caddy-config.volume
Save as /etc/containers/systemd/caddy-config.volume
[Volume]
VolumeName=caddy-config
Caddyfile
Save as /etc/caddy/Caddyfile
{
email admin@example.com
}
# One block per site. Caddy gets and renews the certificate for each name.
whoami.example.com {
# localhost inside this container is the container itself:
# reach services on the host through host.containers.internal.
reverse_proxy host.containers.internal:8080
}
changes.example.com {
reverse_proxy host.containers.internal:5000
}
Why is it built this way?
Why rootful. Caddy must receive traffic on ports 80 and 443, which are privileged, and it should start at boot on its own. Only the proxy runs as root; the apps behind it stay rootless and publish high ports such as 8080.
Three kinds of storage. The Caddyfile comes from /etc/caddy on the
host, mounted read-only, so you edit it with any editor. caddy-data.volume holds
certificates and the ACME account: this is the volume you must never lose.
caddy-config.volume holds Caddy's autosaved config and can be rebuilt.
Reload instead of restart. The ExecReload= line runs
caddy reload inside the running container, so
sudo systemctl reload caddy.service applies a new Caddyfile without dropping
connections. Quadlet passes [Service] lines straight into the generated unit.
Reaching the apps. host.containers.internal resolves to the host
inside every podman container, so reverse_proxy host.containers.internal:8080 reaches
a rootless app published on port 8080, such as the
whoami quadlet. PublishPort=443:443/udp enables
HTTP/3.
Updates. caddy:2 follows Caddy 2 releases, and
AutoUpdate=registry keeps it current with rollback on failure. The Caddyfile syntax
is in the Caddy documentation.
How do I start it?
Run these with sudo. Quadlet reads /etc/containers/systemd/ for system services.
sudo mkdir -p /etc/containers/systemd /etc/caddy
# save the Caddyfile in /etc/caddy/, the three quadlet files in /etc/containers/systemd/
sudo systemctl daemon-reload
sudo systemctl start caddy.service
# after every Caddyfile change:
sudo systemctl reload caddy.service
Check the result with sudo systemctl status and
the logs with sudo journalctl -u followed by the
service name. To turn a docker-compose file of your own into files like these, use the
compose to quadlet converter.
Common mistakes
502 Bad Gateway with reverse_proxy localhost:8080
Inside the Caddy container, localhost is the container itself. Use host.containers.internal:8080 to reach a port published on the host, or put Caddy and the app on one .network and use the container name.
Caddyfile edits are ignored after a reload
A single bind-mounted file keeps pointing at the old inode when an editor saves by replacing the file. Mount the folder (/etc/caddy:/etc/caddy) instead of the file, as this quadlet does.
Let's Encrypt rate limit: too many certificates already issued
Caddy lost its /data folder and requested every certificate again. Keep the caddy-data volume across container updates and never delete it to fix a problem.
The certificate is never issued
The ACME HTTP and TLS challenges need the domain's DNS record to point at your public IP, and ports 80 and 443 forwarded to this host. Check sudo journalctl -u caddy.service for the exact challenge error.
Frequently asked questions
Why run Caddy rootful when the apps are rootless?
Ports 80 and 443 are privileged, and a reverse proxy should come up at boot without a user session. Running only the proxy rootful keeps the apps unprivileged, which limits what a compromised app can reach.
How does a rootful Caddy container reach rootless containers?
Rootless containers publish their port on the host, for example PublishPort=8080:80. Caddy reaches that port through host.containers.internal, a name podman adds to every container's /etc/hosts that points at the host.
How do I get a wildcard certificate with a DNS challenge?
Build a Caddy image that includes your DNS provider's module with xcaddy (the official caddy image has builder tags for this) and give it the API token through a podman secret: Secret=dns-token,type=env,target=DNS_API_TOKEN, referenced as {env.DNS_API_TOKEN} in the Caddyfile.
How do I reload Caddy without restarting the container?
Run sudo systemctl reload caddy.service. The ExecReload= line in the quadlet runs caddy reload inside the container, which applies the new Caddyfile without dropping open connections.