← All TILs · tls

A Let's Encrypt certificate for a LAN-only service: Caddy, DNS-01 and deSEC

tls - 2026-10-08

A follow-up to ntfy behind Caddy. Caddy's default way of getting a certificate, the HTTP-01 challenge, needs Let's Encrypt to reach the server on port 80 from the Internet. A homelab service that stays on the LAN can't do that. The DNS-01 challenge proves you control the domain instead: the ACME client (Caddy here) creates a TXT record _acme-challenge.<name> through the DNS provider's API, Let's Encrypt checks it, and the record is removed. Nothing needs to be reachable from outside, and it's the only challenge that allows wildcard certificates.

deSEC is a free, non-profit DNS host with an API, and it gives out free subdomains under dedyn.io. Caddy talks to it through the caddy-dns/desec module.

1. A domain and a token at deSEC

Register at desec.io, create a domain such as myhome.dedyn.io, and create an API token. A plain token can change any record in the account. deSEC's token policies restrict it per record: a deny-by-default policy, plus write access to _acme-challenge.ntfy of type TXT only (see deSEC's Token Scoping: Policies).

Point ntfy.myhome.dedyn.io at the proxy's LAN address (an A record such as 192.168.1.20). Let's Encrypt never connects to it; LAN clients resolve it and reach Caddy.

2. Caddy with the deSEC module

The official Caddy image has no DNS provider modules. Build one with xcaddy, the tool that compiles Caddy with extra modules:

FROM caddy:2.11.7-builder AS builder
RUN xcaddy build v2.11.7 --with github.com/caddy-dns/desec@v1.1.0

FROM caddy:2.11.7
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
docker build -t caddy-desec:2.11.7 .
docker run --rm caddy-desec:2.11.7 caddy list-modules | grep desec   # dns.providers.desec

3. The Caddyfile

{
    acme_dns desec {
        token {env.DESEC_TOKEN}
    }
}

ntfy.myhome.dedyn.io {
    reverse_proxy http://NTFY_LXC_IP:80
}

acme_dns in the global block applies DNS-01 to every site; for one site only, put dns desec { token ... } inside that site's tls { } block. {env.DESEC_TOKEN} reads the token from the environment, so it stays out of the file:

docker run -d --name caddy -p 443:443 -e DESEC_TOKEN \
  -v ./Caddyfile:/etc/caddy/Caddyfile:ro -v caddy_data:/data caddy-desec:2.11.7

Test against Let's Encrypt's staging CA first, which has much higher rate limits: add acme_ca https://acme-staging-v02.api.letsencrypt.org/directory to the global block, then remove it once a staging certificate comes through. Keep the /data volume: it holds the account and certificates, and Caddy renews them from there.

What a failure looks like

With a wrong token, Caddy's log shows each step: the staging order, trying to solve challenge with challenge_type: dns-01, then deSEC's refusal:

presenting for challenge: adding temporary record for zone "example.dedyn.io.":
retrieving RRSet: unexpected status code 401: {"detail":"Invalid token."}

Caddy retries on its own, so fix the token and restart.

Without Caddy: the Proxmox ACME client

Proxmox VE has its own ACME client (Datacenter, ACME). It reuses acme.sh's DNS plugins, and the admin guide lists desec among them: add an ACME account, a DNS challenge plugin of type desec with the token, then the domain on the node (Certificates). That route gets a certificate for the Proxmox web UI itself, not for the services behind Caddy.

Sources

Created 2026-10-08T18:16:04+02:00 · Edit