Why Everything Runs Behind Caddy Now
For a long time my homelab had a reverse proxy the way some houses have wiring added one socket at a time over decades — functional, but a tangle nobody fully understood, including the person who built it. Every new service meant another hand-edited rule, another certificate to remember to renew, another chance to typo a hostname and spend an evening wondering why one container was unreachable while everything else was fine. It worked. It did not scale, and more importantly it did not explain itself.
Moving everything behind Caddy fixed that, and the reason it fixed it is the same reason I like every tool I keep: the configuration lives in one declared place, and the right behaviour is the default.
A reverse proxy you have to remember to update is a reverse proxy that is one forgotten step away from a broken service. The fix is to stop remembering and start declaring.
The problem with the old way
The previous setup was a separate proxy with its own management UI, holding its own list of rules in its own database, entirely disconnected from the services it routed to. When I added a container, I added it in two places: the compose file that defined the service, and the proxy that exposed it. Those two places had no knowledge of each other, so they drifted. A renamed service here, a changed port there, and the proxy would happily keep routing to something that no longer existed.
Certificates were worse. They were issued and renewed as a manual-ish process I half-trusted, and the failure mode was silent — the cert would lapse and I would find out when a browser threw a warning, which is to say when it was already a problem. The whole arrangement violated the principle I care about most: there should be one source of truth, and everything else should be a view onto it. Here there were two sources of truth that quietly disagreed.
What Caddy changes
Caddy collapses all of that into one file. Instead of a UI holding routes in a database, the entire routing intent for the lab lives in a single Caddyfile, committed to Git alongside everything else. The file is the configuration — readable top to bottom, diffable, reviewable — and there is no second place for it to drift from. And the feature that made me switch: Caddy does HTTPS automatically. It obtains and renews Let’s Encrypt certificates on its own, by default, with no ACME plumbing to wire up. You declare a hostname and you get a valid certificate, full stop.
# Caddyfile — the whole routing intent, in Git
cosmos.example.co.uk {
reverse_proxy cosmos:8000
}
chat.example.co.uk {
reverse_proxy open-webui:8080
}
That is the entire thing. Each block is a hostname and where to send its traffic; Caddy reaches the upstreams by container name over the Docker network, terminates TLS, and keeps the certificates valid without being asked. Add a block and commit it and the service is routed and served over HTTPS. Delete the block and the route is gone. The drift that plagued the old setup is structurally impossible, because there is only one list and it is the one in Git.
The DNS-challenge gotcha that cost me an evening
I will not pretend it was frictionless, because the part that bit me is the part that bites everyone moving services that are not publicly reachable, and writing it down is exactly the second-brain discipline I keep banging on about. Caddy would not issue a certificate for an internal-only service, and the logs were unhelpful in that specific way that makes you doubt your own competence.
The cause was the ACME challenge. Caddy’s automatic HTTPS proves you control a domain by answering a challenge over the public internet — and for a service that is not reachable from outside, that challenge can never succeed, so no certificate is ever issued. The fix is to switch that host to the DNS challenge, which proves control by writing a TXT record instead of needing an inbound connection. But the default Caddy binary does not include any DNS-provider modules, so the DNS challenge silently does nothing until you run a build of Caddy with your provider’s plugin compiled in and give it an API token.
{
# global option: prove control via DNS, for internal-only hosts
acme_dns cloudflare {env.CF_API_TOKEN}
}
internal.example.co.uk {
reverse_proxy some-internal-service:9000
}
The fix was a different Caddy image and one global directive. The lesson was larger: automatic HTTPS is genuinely automatic right up until the assumption underneath it — “the world can reach this host” — stops holding, and then the skill is knowing which assumption broke rather than assuming the tool is misbehaving. Caddy was behaving perfectly; it simply could not answer a challenge for a door the internet cannot knock on. I now keep a working Caddyfile and the custom-build notes in my second brain precisely so future-me has a known-good version to diff against instead of re-deriving this at midnight.
Why this fits everything else
The deeper reason Caddy stuck is that it matches how I want the whole homelab to work. Configuration as declared intent, living in Git. The running system as a view onto that intent rather than a separate state that has to be kept in sync by hand. The right behaviour — valid certs, correct routing — as the automatic consequence of declaring what you want, not a manual chore you have to remember.
It is the same philosophy as keeping the running machine rebuildable and treating plain text as the source of truth. A new service is now genuinely a few lines in the Caddyfile and a compose up, and I have stopped thinking about the proxy at all, which is the highest compliment I can pay a piece of infrastructure. The best plumbing is the kind you forget you have.
What I would tell past-me
If I could send one note back to the version of me hand-editing proxy rules, it would be this: the effort of migrating is real, and it is a fraction of the cumulative effort of maintaining the old way for another year. Put the routing intent in one file, commit it to Git, and let the certificates issue and renew themselves. Then check, once, that any host the public internet cannot reach is set up for the DNS challenge with a Caddy build that actually includes the provider — because it probably is not, and that one piece of plumbing is the difference between an afternoon of triumph and an evening of doubt.