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.
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.comand 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 WebUIha.aurora.zt.example.com ; Home Assistantnas.aurora.zt.example.com ; NAS, if presentaurora 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 valueThe 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.comis signed, an unsigned local override forha.aurora.zt.example.comis 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.1ha.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.1ha.aurora.zt.example.com. A 192.168.42.43The 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:
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.comacme.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:
~/.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:
~/.acme.sh/acme.sh --list # shows the cert and its renewal datecrontab -l | grep acme.sh # confirms the renewal cron entrySurviving 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.keyIssuing 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.comdomains: - ha.aurora.zt.example.comcertfile: fullchain.pemkeyfile: privkey.pemchallenge: dnsdns: 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.pemThe 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-challengename 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#
- Let’s Encrypt: Challenge Types
- ACME: RFC 8555
- acme.sh: DNS API providers
- Cloudflare: DNSSEC
- Cloudflare: API token permissions
- Teltonika: TLS Certificates
- Teltonika: Eliminating HTTPS Warnings: Using Let’s Encrypt
- OpenWrt: uHTTPd Web Server Configuration
- Home Assistant: Let’s Encrypt add-on documentation
- Home Assistant: HTTP integration
Footnotes#
-
Written in collaboration between Claude, GPT, and Adam Sherman’s human brain; the wetware retained final veto power. ↩