Humble Planning: a self-hosted planning app that installs with its own shared Caddy

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>.caddy file, 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>-app and <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 used 127.0.0.1:2019: localhost was 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_data volume into the new proxy. With BusyBox (Alpine image), cp -n didn’t do what I expected, so I switched to tar -k and a check of every file. If anything fails, the old Caddy is started again.

Links

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.