arthur@homelab:~$ whoami

Arthur Sommer

/home/arthur · homelab operator · software builder · perpetual tinkerer

arthur@homelab:~$ cat ~/posts/one-certificate-three-caddy-servers.md

One Certificate, Three Caddy Servers

Why I wrote a Caddy module to let one edge renew certificates for several private proxies.

mtime 2026-08-15 · 3 min read

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:

RoleRoute 53 credentialsPerforms ACMEServes TLS
Public edgeyesyesyes
Tailnet proxynonoyes
LAN proxynonoyes

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

The module is available under the MIT license at github.com/art12354/caddy-tls-file-pair.