Part 5: Trusted Certificates for Private ZeroTier Sites
Guide

Part 5: Trusted Certificates for Private ZeroTier Sites

How to use public ACME certificates and DNS-01 validation for private mobile-site names without exposing Home Assistant or router interfaces to the public internet.

Tailgate Labs · June 15, 2026

The earlier articles establish private connectivity, local DNS, managed routes, and managed resolver settings. The last piece is TLS.1

The goal is for a user to open:

https://ha.aurora.zt.example.com

and receive a normal browser-trusted certificate, even though the service is reachable only on the LAN or through ZeroTier.

The approach#

Use public Let’s Encrypt certificates, validate them with DNS-01, and issue them on each device.

  • Public CA, not a private one. Browsers and operating systems already trust Let’s Encrypt, so users get a normal padlock with no private root certificate to install on every phone and laptop. The service stays private; only domain control has to be provable in public DNS.
  • DNS-01, not HTTP-01. The CA never has to reach the service. It only reads a TXT record under _acme-challenge, which works even when the service is behind CGNAT and reachable only on the overlay.
  • Per-device issuance. Each device runs its own ACME client and installs its own certificate and key. No single host holds the whole fleet’s private keys, so a compromised vehicle does not expose the rest of the fleet.

Certificate names#

Give each device the narrowest name it needs, and issue one certificate per device:

router.aurora.zt.example.com ; RutOS WebUI
ha.aurora.zt.example.com ; Home Assistant
nas.aurora.zt.example.com ; NAS, if present

aurora is this site. Other sites get their own subdomain (borealis, zephyr, and so on) under the same fleet zone zt.example.com.

How DNS-01 validation works here#

DNS-01 proves domain control by publishing a TXT record. The CA never contacts the service:

Certificate requested for:
router.aurora.zt.example.com
CA reads this name in public DNS:
_acme-challenge.router.aurora.zt.example.com TXT
Proof:
only someone who controls the zone can publish the expected TXT value

The ACME client writes that TXT record at issuance and removes it afterward. You never create or edit _acme-challenge records by hand.

The DNS records#

zt.example.com is deliberately left unsigned (DNSSEC disabled), and that is the design rather than an oversight. The site has to keep resolving when its WAN link is down, so RutOS must answer these names locally with LAN addresses — a split-horizon override. That override is exactly what DNSSEC rejects:

The important caveat is DNSSEC. If zt.example.com is signed, an unsigned local override for ha.aurora.zt.example.com is not valid signed Internet DNS data. That usually works for ordinary LAN clients because they trust the router as a non-validating resolver, but it can fail for validating clients or validating internal resolvers that try to validate the local answer against the signed public chain.

Leaving the zone unsigned removes the conflict. With no signed chain to contradict, every resolver — validating or not, on the LAN or remote — accepts the LAN answer your router serves, so names keep resolving with the internet down. Let’s Encrypt does not require DNSSEC, so public certificate issuance is unaffected.

Confirm DNSSEC is off: in the Cloudflare dashboard open zt.example.com, go to DNS → Settings, and check that the DNSSEC card shows disabled. Do not click Enable DNSSEC.

Two places hold the records, and only the TXT records change at renewal.

1. Public zone zt.example.com holds the browser-name A records and the validation TXT records:

router.aurora.zt.example.com. A 10.147.20.1
ha.aurora.zt.example.com. A 10.147.20.43
_acme-challenge.router.aurora.zt.example.com. TXT "<written by the ACME client>"
_acme-challenge.ha.aurora.zt.example.com. TXT "<written by the ACME client>"

The A records are static and answer remote clients on the overlay. Point them at each device’s ZeroTier address, or use a placeholder if you would rather not publish overlay addresses. The TXT records are created and removed automatically by the ACME client on each device; you never touch them by hand.

2. RutOS local DNS answers the same names with LAN addresses for clients on the site network (configured in the local-DNS article earlier in this series). This is the view that keeps working when the WAN link is down:

router.aurora.zt.example.com. A 192.168.42.1
ha.aurora.zt.example.com. A 192.168.42.43

The Cloudflare API token#

Create one API token scoped to Zone:DNS:Edit on zt.example.com and nothing else. Use an API token, never the account-wide global API key.

This is the only credential a device needs to issue and renew, and Zone:DNS:Edit is the smallest scope the Cloudflare DNS plugins support. Be clear-eyed about its one limit: because every site shares zt.example.com, a token that can edit the zone can edit any record in it, so a stolen device could tamper with sibling records. It still cannot reach your other zones or the account itself. Keep that blast radius in mind for customer fleets, and never put the global API key on a router installed in a boat or RV.

Issuing on the RutOS router#

The native RutOS Let’s Encrypt workflow ties the certificate to the device’s public IP address, so it does not fit a ZeroTier-only, DNS-01 name. Run acme.sh on the router instead, using the Cloudflare DNS plugin against zt.example.com:

Terminal window
export CF_Token="<token scoped to zt.example.com>"
export CF_Account_ID="<your-account-id>"
~/.acme.sh/acme.sh --issue --dns dns_cf \
-d router.aurora.zt.example.com

acme.sh writes and removes the TXT record at _acme-challenge.router.aurora.zt.example.com for you. Install the certificate where the WebUI expects it and reload the server:

Terminal window
~/.acme.sh/acme.sh --install-cert -d router.aurora.zt.example.com \
--key-file /etc/ssl/private/router-aurora-zt.key \
--fullchain-file /etc/ssl/certs/router-aurora-zt.crt \
--reloadcmd "/etc/init.d/uhttpd restart"

Confirm the actual web server and paths on your firmware before relying on the reload command. Current RutOS still inherits much of the OpenWrt service model, but do not assume uhttpd versus nginx without checking the device. Teltonika’s TLS certificate documentation covers the certificate UI.

acme.sh installs its own renewal cron entry on issuance. Confirm it exists:

Terminal window
~/.acme.sh/acme.sh --list # shows the cert and its renewal date
crontab -l | grep acme.sh # confirms the renewal cron entry

Surviving a firmware upgrade#

Embedded router upgrades can wipe files outside the configured persistence set. Preserve at least these paths (add them to /etc/sysupgrade.conf if the firmware uses it) and test a sysupgrade on a non-customer router before relying on automatic renewal:

/root/.acme.sh/
/etc/ssl/certs/router-aurora-zt.crt
/etc/ssl/private/router-aurora-zt.key

Issuing on Home Assistant#

Run the official Let’s Encrypt add-on on the Home Assistant OS node. It supports DNS-01 with Cloudflare. A typical raw configuration is:

email: you@example.com
domains:
- ha.aurora.zt.example.com
certfile: fullchain.pem
keyfile: privkey.pem
challenge: dns
dns:
provider: dns-cloudflare
cloudflare_api_token: "<token scoped to zt.example.com>"
propagation_seconds: 120
dns_multi_nameservers: "1.1.1.1,8.8.8.8"

dns_multi_nameservers is the add-on’s recommended setting for split-DNS sites: it forces public-nameserver lookups so validation does not get the LAN answer. In the add-on’s UI edit mode, paste only the fields under dns: into the DNS provider field, not the dns: key itself.

Point Home Assistant at the issued files:

http:
server_port: 443
ssl_certificate: /ssl/fullchain.pem
ssl_key: /ssl/privkey.pem

The add-on does not renew on its own; per its renewal documentation it has to be started again so it can check whether renewal is due. Automate that restart on the HAOS node and test it before relying on the certificate.

Renewal checklist#

Certificate automation is finished when renewal has been tested, not when the first certificate has been issued. Before turning this into a customer procedure, confirm:

  • DNSSEC is disabled on zt.example.com.
  • Each _acme-challenge name resolves from public DNS.
  • The site names resolve to LAN addresses from RutOS with the WAN link down.
  • Each device issues its own certificate with its own scoped token.
  • Each service loads the certificate it issued and serves it on the LAN and over ZeroTier.
  • Renewal survives a device reboot, and on RutOS a firmware upgrade.
  • Alerting fires before expiry for every device.

Reference documentation#

Footnotes#

  1. Written in collaboration between Claude, GPT, and Adam Sherman’s human brain; the wetware retained final veto power.

TLS ACME Let's Encrypt Cloudflare ZeroTier