I have a split-horizon DNS setup at home: example.internal is served by my local resolver with internal IPs; example.net is public. For a while I used HTTP-01 for Let’s Encrypt, which needs port 80 open and is fiddly for internal-only services. Moving to DNS-01 was the right answer; getting there had a few snags.

What DNS-01 needs

To prove you own service.example.net, Let’s Encrypt asks you to place a TXT record at _acme-challenge.service.example.net. A CA-verifiable TXT record proves DNS control. If your ACME client can add and remove TXT records on your public zone, you are set.

On the internal side, I do not have a public zone for example.internal. That is the whole point of split-horizon. So I was initially uncertain what DNS-01 would even look like for internal names.

The trick: use a public subdomain for internal things

I gave up on using example.internal for anything that wants a real cert. I use a subdomain of my public zone for internal-only hostnames, say home.example.net. Public DNS for home.example.net points to RFC1918 addresses (yes, this is allowed and common; it just cannot be reached from the internet). My internal resolver answers these authoritatively, and my split-horizon is just “public resolver returns 192.168 address, internal resolver returns same 192.168 address”. Effectively no split for this subtree.

This means home.example.net can get Let’s Encrypt certs via DNS-01 because the public DNS has real authority, even though the addresses in the records are only routable internally.

The ACME setup

I use acme.sh for this because it has good plugin support for a lot of DNS providers. My DNS lives at Cloudflare. The relevant invocation:

export CF_Token='<api token scoped to DNS edit for this zone>'
acme.sh --issue --dns dns_cf \
  -d '*.home.example.net' \
  -d 'home.example.net' \
  --server letsencrypt

Once issued, certs go under ~/.acme.sh/home.example.net_ecc/. I symlink them into Caddy’s expected path. Renewal is a cron managed by acme.sh:

acme.sh --install-cronjob

The pitfall with Cloudflare API tokens

The first token I created had permissions for “Zone:Edit” on all zones. That is overly broad. I scoped to just DNS:Edit on the specific zone:

  • Permissions: Zone > DNS > Edit
  • Zone Resources: Include > Specific zone > example.net

If that token leaks, it cannot edit other zones or do anything but DNS on this one. Smaller blast radius.

The pitfall with the internal resolver

When Let’s Encrypt validates a DNS-01 challenge, it queries authoritative public DNS from its own infrastructure. It does not query my local resolver. But I had pointed my local resolver to be authoritative for home.example.net so that internal clients get the RFC1918 addresses. When acme.sh added a TXT record via the Cloudflare API, that record existed in the public zone but not on my internal resolver.

This was fine for validation because Let’s Encrypt queries public DNS. It was not fine for me running dig locally to verify, because my internal resolver would say NXDOMAIN for the challenge record. I spent half an hour convinced the API call had failed before remembering that dig @1.1.1.1 _acme-challenge.home.example.net TXT would give me the real public view.

The pitfall with CAA records

Not a pitfall but a check I wish I had done earlier. I set a CAA record restricting my zones to Let’s Encrypt only:

example.net.    3600    IN    CAA    0 issue "letsencrypt.org"
example.net.    3600    IN    CAA    0 iodef "mailto:security@example.net"

If any other CA ever tries to issue a cert for my zone, they are supposed to refuse. This does not prevent typo-squat attacks on unrelated domains, but it is cheap insurance against certain misissuance.

Caddy integration

Because my cert lives at a file path, Caddy just needs to be told:

nas.home.example.net {
    tls /path/to/home.example.net.pem /path/to/home.example.net.key
    reverse_proxy 192.168.30.12:5000
}

But actually, Caddy has native DNS-01 support via the same Cloudflare plugin. I ended up switching to Caddy-managed DNS-01 instead of acme.sh for the services that Caddy proxies, because it is one fewer moving piece:

{
    email ops@example.net
    acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

*.home.example.net {
    reverse_proxy {labels.3}.home.example.net:80
}

With the Cloudflare DNS plugin, Caddy requests and renews the wildcard itself. Saves one cronjob.

What I would do differently

Start with a subdomain pattern: home.example.net for internal-only services, example.net for public. Keep them on the same zone for simplicity. Put CAA records in from day one. Use scoped API tokens. Let Caddy handle DNS-01 directly if Caddy is your proxy; avoid manual cert-management for anything routine.

The worst versions of split-horizon DNS are when you have the same name resolving to different addresses inside and outside, because cert validation and reachability get tangled. If you can avoid that pattern by using a subdomain for internal-only, do.

Reflection

For a homelab, the value of internal TLS is mostly:

  • Phones and laptops not yelling about self-signed certs.
  • Ability to use HTTP/2 and HTTP/3 consistently.
  • Future-proofing against services that require HTTPS (some of mine do).

The cost is one zone subtree and one API token. Letting Caddy manage DNS-01 makes the cost nearly zero. Related: see my post on why I finally switched from nginx to Caddy.