Hi everyone,
I’d like to share a project I built where Caddy plays a big role.
Humble Planning is an open source (AGPL-3.0) planning app for field teams. You put people, vehicles and equipment on a planning grid, and everyone sees the changes in real time. It is self-hosted: on a Linux server with Docker, one command installs everything.
From the start, I didn’t want users to deal with nginx, certbot and renewal cron jobs. So the installer ships Caddy and writes the config for them. You type your domain, and a minute later you have a working HTTPS site. Thank you to the Caddy team for making this so simple!
Here is how it works, in case it helps someone.
One Caddy for all the instances of a server
You can run several instances on the same server, for example planning.company-a.com and planning.company-b.com. Each one has its own database and files, but they all sit behind one Caddy container:
- the main Caddyfile is generated, and it ends with
import sites/*.caddy - each instance gets its own
sites/<name>.caddyfile, made from a template when the instance is created. After that, the file is never overwritten, so manual edits survive updates. - the app containers join a shared Docker network, with aliases like
<name>-appand<name>-socket
When something changes (new instance, removed instance, new URL), the script runs caddy validate first, then caddy reload. If the validation fails, it puts the old files back and the running config stays as it is. The other sites never go down.
A generated site looks like this:
planning.example.com {
import /etc/caddy/humble-maintenance*.caddy
encode zstd gzip
tls admin@example.com
header {
?X-Content-Type-Options "nosniff"
?X-Frame-Options "SAMEORIGIN"
?Referrer-Policy "strict-origin-when-cross-origin"
?Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
-X-Powered-By
}
reverse_proxy /socket.io/* team1-socket:3001
reverse_proxy team1-app:3000 {
header_up X-Real-IP {client_ip}
header_up -CF-Connecting-IP
}
}
A custom image with the Cloudflare DNS module
The image is Caddy 2.11.7 built with xcaddy and caddy-dns/cloudflare, for amd64 and arm64. It is published on GHCR with the same version number as the app.
If you choose the Cloudflare DNS challenge during the install, each site has its own API token. The token is stored in a file that only the proxy can read, and the site reads it with a placeholder:
tls admin@example.com {
dns cloudflare {file./etc/caddy/humble/secrets/team1.cloudflare-token}
}
I really like this: the app containers never see the token, and adding a new site is just a reload. No environment variable to change, no need to recreate the Caddy container.
When a site is behind the Cloudflare proxy (orange cloud), the Cloudflare IP ranges are in the image as trusted_proxies, with client_ip_headers CF-Connecting-IP. The app only reads X-Real-IP, which Caddy sets from {client_ip}, and CF-Connecting-IP is removed. So someone who connects to the server directly can’t fake their IP address. A small GitHub workflow checks every week if Cloudflare has changed its IP list.
A maintenance page during updates
When an app container restarts (update, crash…), Caddy gets a 502, 503 or 504. A handle_errors snippet included in the image then shows a small maintenance page (status 503, in 4 languages). The page checks every 5 seconds and reloads itself when the app is back. API and Socket.io requests just get an empty 503, never HTML.
Simplified, it looks like this:
handle_errors 502 503 504 {
@humble_page {
method GET HEAD
header Accept *text/html*
}
handle @humble_page {
root * /usr/share/humble
rewrite * /maintenance.html
file_server {
status 503
}
}
handle {
respond 503
}
}
Sites import it with a glob: import /etc/caddy/humble-maintenance*.caddy. With an older image that doesn’t have the file, the glob matches nothing and the config is still valid. That made the upgrade painless.
What I learned on the way
- Headers set in the site block are not applied to the responses of
handle_errors. I had to set the security headers again inside the error handler, with a strict CSP for the maintenance page. - My health check on the admin endpoint (
localhost:2019) failed inside the container until I used127.0.0.1:2019:localhostwas resolved to IPv6 first. - Version 1.0 had one Caddy per instance. To move to the shared Caddy without asking Let’s Encrypt for new certificates, the installer copies the old
caddy_datavolume into the new proxy. With BusyBox (Alpine image),cp -ndidn’t do what I expected, so I switched totar -kand a check of every file. If anything fails, the old Caddy is started again.
Links
- Code: GitHub - Malfhas/HumblePlanning: Open-source scheduling for field teams: people, vehicles and equipment on one real-time schedule, with an AI assistant that works under your control. Self-hosted, AGPL-3.0. · GitHub (the install guide is in
docs/installation.md, see “Shared reverse proxy”) - Demo: https://demo.humbleplanning.app (user
humbledemo, passwordhumblepass)
If you see something I could do better in the Caddy config, I’m happy to hear it. Thanks for reading!
Disclosure: Humble Planning was built with the help of AI coding assistants. I lead the project, and every change is reviewed and tested; this is also explained in the README. I’m French: I wrote this post in French and translated it with a translator.