arthur@homelab:~$ cat ~/posts/one-certificate-three-caddy-servers.md
One Certificate, Three Caddy Servers
My homelab has three reverse-proxy roles. A public edge accepts internet traffic. An internal proxy serves devices connected through Tailscale. A local proxy keeps LAN services usable without taking a detour through the public internet.
All three answer for names under the same domain, but I did not want all three carrying AWS credentials or independently completing Route 53 DNS challenges.
ACME DNS-01
|
v
internet ----------> caddy-external
|
shared certificate storage
|
+-------+-------+
| |
caddy-internal caddy-local
tailnet LAN
The awkward middle state
The public edge could renew a wildcard certificate and place it on shared storage. The other proxies could load the PEM files with Caddy’s normal tls directive. The trouble was lifecycle: those consumers did not naturally notice when the files changed. A timer could compare certificate serial numbers and reload Caddy, but that introduced more state and another piece of automation whose only job was compensating for the configuration model.
I wanted Caddy to treat the files as a certificate source instead of static startup inputs.
A small module
caddy-tls-file-pair implements Caddy’s certificate-loader interface. It reads an existing certificate and key pair, validates that they belong together, and makes the pair available to Caddy’s TLS automation machinery.
The consumer configuration stays explicit:
tls {
get_certificate file_pair {
names *.example.com
cert /shared/certificates/wildcard.example.com.crt
key /shared/certificates/wildcard.example.com.key
}
}
The real configuration uses the homelab’s private paths and domain. The example keeps those details out of copy-and-paste territory while showing the module’s shape.
Why not give every proxy DNS credentials?
That would work, but it expands the credential boundary and allows several instances to race or coordinate around renewal. In this design, only the public edge is an issuer. The other proxies are consumers. That division is easy to explain and audit:
| Role | Route 53 credentials | Performs ACME | Serves TLS |
|---|---|---|---|
| Public edge | yes | yes | yes |
| Tailnet proxy | no | no | yes |
| LAN proxy | no | no | yes |
The shared storage is still sensitive because it contains private key material. It is mounted only where needed and protected independently of the module.
Packaging it for the lab
I build Caddy with xcaddy, pin the module version, and publish the resulting Debian package through Forgejo. The module’s development repository is mirrored to GitHub, where its tests and releases are public. That gives me a repeatable path from a small Go change to the exact binary running on each proxy.
The most satisfying result is not the custom code. It is deleting the certificate-refresh timers and their reload behavior. A good addition made the surrounding system smaller.
What I learned
- Credential placement is an architectural decision, not an environment-variable detail.
- Shared storage can simplify coordination while creating a security boundary of its own.
- A tiny extension at the right interface can remove more automation than it adds.
- Testing the certificate serial and hostname selection matters more than proving that a config file parses.
The module is available under the MIT license at github.com/art12354/caddy-tls-file-pair.