Caddy + CaddyUI + AdGuard Home DNS setup made simple

{"time":1777329991959,"blocks":[{"id":"9jr4FlRi03","type":"paragraph","data":{"text":"🐛 Release + Recipe"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"nruHR_Z9a7","type":"header","data":{"text":"CaddyUI v2.5.10 — Alias domains now get DNS records, plus a self-hosted encrypted-DNS recipe","level":1},"tunes":{"alignTune":"left"}},{"id":"bJjQ6r4Lk4","type":"paragraph","data":{"text":"Started as a bug report: \"adding an alias domain breaks with DNS_PROBE_FINISHED_BAD_SECURE_CONFIG.\" Ended as a complete AdGuard Home + Caddy DoH/DoT/DoQ stack with per-device client names."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"8thHh4tRJu","type":"paragraph","data":{"text":"April 23, 2026"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"Bl76evF1yU","type":"paragraph","data":{"text":"v2.5.10"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"FYFlfV4w7-","type":"paragraph","data":{"text":"☕ ~8 min read"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"QXgpBF2rDh","type":"header","data":{"text":"The bug","level":2},"tunes":{"alignTune":"left"}},{"id":"v62Mx8_0t3","type":"paragraph","data":{"text":"I was setting up AdGuard Home behind Caddy so my phone could use DNS-over-HTTPS at laptop.dns.richardapplegate.io. The proxy host worked on the first save. Then I added a second hostname on the same row as an alias — and Chrome started showing this on every request to the alias:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"uwwCA4oUbm","type":"code","data":{"code":"DNS_PROBE_FINISHED_BAD_SECURE_CONFIG\n\nThis site can't be reached\nChecking the proxy, firewall, and Secure DNS configuration"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"-oyq9AKYth","type":"paragraph","data":{"text":"The primary domain still worked. Only the alias broke. That's a weirdly specific failure mode, and it turned out to be a weirdly specific bug."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"x4GWzq3_EE","type":"header","data":{"text":"What v2.5.9 fixed — and what it missed","level":2},"tunes":{"alignTune":"left"}},{"id":"5VHe-fTeFR","type":"paragraph","data":{"text":"v2.5.9 (shipped an hour before v2.5.10) changed dnsCreateRecord to iterate every hostname in a proxy host's Domains list and request one A record per FQDN, instead of only the first. The create path started working correctly, and the bulk IP-retarget path (the one that fires when you change a Caddy server's public IP) got the same treatment."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"wTXi2PY16N","type":"paragraph","data":{"text":"The user-initiated edit path did not. In updateProxyHost the change-detection logic was still:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"l7p4XjeamX","type":"code","data":{"code":"oldDomain := dns.FirstDomain(old.Domains)\nnewDomain := dns.FirstDomain(p.Domains)\ndomainChanged := oldDomain != newDomain"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"CzyX0hSmvx","type":"paragraph","data":{"text":"Which means: if you take an existing proxy host with a single domain, and you add an alias to it, the first domain hasn't changed — so domainChanged is false, needCreate is false, and no record ever gets provisioned for the new alias. The row in the DB looks right. Caddy is happy to serve the alias. But there's no A record at the provider, so the recursive resolver returns NXDOMAIN."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"za1Rtoad0R","type":"paragraph","data":{"text":"DNS_PROBE_FINISHED_BAD_SECURE_CONFIG"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"G11k7L7KZH","type":"paragraph","data":{"text":"should"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"C9LtkEJl38","type":"header","data":{"text":"The v2.5.10 fix","level":2},"tunes":{"alignTune":"left"}},{"id":"tdi7CecQID","type":"paragraph","data":{"text":"The patch is six lines of logic plus a \"slices\" import. Compare the full DomainList() instead of just the first element:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"Sum8u9qGqb","type":"code","data":{"code":"var oldDomains []string\nif old != nil {\n oldDomains = old.DomainList()\n}\nnewDomains := p.DomainList()\ndomainChanged := !slices.Equal(oldDomains, newDomains)"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"9Ka2ojtmnl","type":"paragraph","data":{"text":"Same change for the raw-route edit path (updateRawRoute): compare rawRouteHosts(*old) to rawRouteHosts(*rr) instead of only the first host. Any addition, removal, rename, or reorder now triggers the same delete-all-then-create-all cycle that dnsCreateRecord / dnsCreateRecordForRaw have done since v2.5.9. All three mutation paths (create, edit, IP retarget) now agree on \"every hostname in the list gets an A record.\""},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"mplcphcMWp","type":"paragraph","data":{"text":"No schema change, no migration. Existing rows with a stale alias list self-heal on the next save."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"2MYuokUGKD","type":"delimiter","data":{},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"0IhEzXkNAF","type":"header","data":{"text":"🧅 The recipe this bug unblocked","level":2},"tunes":{"alignTune":"left"}},{"id":"x0bZ6siVdJ","type":"paragraph","data":{"text":"Once the fix was live, I put together the whole stack the bug was originally blocking: wildcard cert via Caddy's DNS-01, AdGuard Home running on the same box, DoH proxied through Caddy, DoT/DoQ direct to AdGuard using the shared cert, per-device client names showing up correctly on both protocols."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"K6LAMMILnT","type":"paragraph","data":{"text":"Here's the complete thing, copy-pasteable."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"-l6uAHLauq","type":"header","data":{"text":"1. Wildcard cert via Caddy + Cloudflare DNS-01","level":3},"tunes":{"alignTune":"left"}},{"id":"bHl2WDvT6q","type":"paragraph","data":{"text":"Dockerfile.caddy already bakes in the Cloudflare DNS provider module, so all that's needed is a Caddyfile snippet. Paste this into CaddyUI → /caddyfile-import:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"WpvdC09IuF","type":"code","data":{"code":"*.dns.richardapplegate.io, dns.richardapplegate.io {\n tls {\n dns cloudflare {env.CF_API_TOKEN}\n }\n\n reverse_proxy http://adguardhome:8080\n}"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"iuyAumenki","type":"paragraph","data":{"text":"The wildcard covers every <device>.dns.richardapplegate.io I'll ever need (phone, laptop, router, TV, …) on a single cert. The apex is included as an extra SAN so the same file covers bare dns.richardapplegate.io too. Everything — DoH at /dns-query, the admin UI, device management — all goes to AdGuard on port 8080; AdGuard routes internally."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"vbTxTiB6bK","type":"paragraph","data":{"text":"Gotcha:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"MSdSGxcbll","type":"paragraph","data":{"text":"*.dns.richardapplegate.io"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"_BBL0eBM4R","type":"paragraph","data":{"text":"phone.dns...."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"uPQnDlDWO1","type":"paragraph","data":{"text":"dns.richardapplegate.io"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"htWzexjTxJ","type":"header","data":{"text":"2. AdGuard Home as a Portainer stack","level":3},"tunes":{"alignTune":"left"}},{"id":"OVNEpMBZtR","type":"code","data":{"code":"services:\n adguardhome:\n image: adguard/adguardhome:v0.107.56\n container_name: adguardhome\n restart: unless-stopped\n ports:\n # DNS - must be on host\n - \"53:53/tcp\"\n - \"53:53/udp\"\n # Optional: DoT / DoQ direct (not via Caddy)\n - \"853:853/tcp\"\n - \"784:784/udp\"\n environment:\n TZ: America/Los_Angeles\n volumes:\n - /mnt/1TB/adguard/work:/opt/adguardhome/work\n - /mnt/1TB/adguard/conf:/opt/adguardhome/conf\n - /mnt/1TB/caddy/caddy_data:/caddy-certs:ro\n networks:\n - caddy-and-ui_caddy_net\n\nnetworks:\n caddy-and-ui_caddy_net:\n external: true"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"JXn5bkVvKu","type":"paragraph","data":{"text":"The critical piece is the read-only mount of Caddy's data volume (/caddy-certs). Caddy writes the wildcard cert there after the DNS-01 challenge completes; AdGuard reads the same files for its DoT/DoQ listener. Pin the AdGuard image tag (not :latest) so a surprise major upgrade doesn't land overnight."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"LHb5_6P851","type":"paragraph","data":{"text":"The caddy-and-ui_caddy_net network name comes from Portainer — it's the <stack-name>_<network-name> form, external because the network is owned by the CaddyUI stack and we're joining it from a separate stack."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"XKS5KOwtdM","type":"header","data":{"text":"3. AdGuardHome.yaml — the TLS block that actually works","level":3},"tunes":{"alignTune":"left"}},{"id":"WCa8SbOu6U","type":"code","data":{"code":"tls:\n enabled: true\n server_name: dns.richardapplegate.io\n force_https: false\n port_https: 443\n port_dns_over_tls: 853\n port_dns_over_quic: 853\n port_dnscrypt: 0\n dnscrypt_config_file: \"\"\n allow_unencrypted_doh: true\n certificate_chain: \"\"\n private_key: \"\"\n certificate_path: /caddy-certs/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.dns.richardapplegate.io/wildcard_.dns.richardapplegate.io.crt\n private_key_path: /caddy-certs/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.dns.richardapplegate.io/wildcard_.dns.richardapplegate.io.key\n strict_sni_check: false"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"jRsx7tVg9T","type":"paragraph","data":{"text":"Three settings matter here and are easy to miss:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"O6bYNd5ezo","type":"list","data":{"style":"unordered","items":["allow_unencrypted_doh: true — lets AdGuard serve DoH over plain HTTP on its admin port. Caddy does the TLS. Without this, Caddy proxies /dns-query and gets 502 back from AdGuard.","certificate_path / private_key_path — Caddy stores multi-subject certs under the first subject's name, with wildcards normalised to wildcard_. Both filenames are the same directory, different extensions.","strict_sni_check: false — clients connect to phone.dns.richardapplegate.io:853, not to the exact server_name. Strict check rejects those."]},"tunes":{"alignTune":"left"}},{"id":"wl_KsWsqKP","type":"paragraph","data":{"text":"AdGuard reads the cert once at startup, so after each Caddy renewal (~60-day cycle) you need to restart the container. A monthly cron on the host is plenty:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"TxF37zzfkt","type":"code","data":{"code":"0 4 1 * * docker restart adguardhome"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"juVmnU494T","type":"header","data":{"text":"4. Per-device ClientIDs","level":3},"tunes":{"alignTune":"left"}},{"id":"7n8xpNse20","type":"paragraph","data":{"text":"The whole point of a private DNS resolver is per-device filtering: \"block YouTube on the kids' tablet, not on mine.\" AdGuard Home extracts the ClientID differently for each protocol:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"TLF0IabzsP","type":"table","data":{"withHeadings":true,"content":[["Protocol","ClientID source","URL on the device"],["DoT / DoQ","TLS SNI leftmost label","phone.dns.richardapplegate.io(port 853 for DoT, 784 for DoQ)"],["DoH","URL path suffix","https://dns.richardapplegate.io/dns-query/phone"],["DoH (alternate)","Host header leftmost label","https://phone.dns.richardapplegate.io/dns-query"]]},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"wd8yKhjbkA","type":"paragraph","data":{"text":"The SNI-based path is cleanest because the ClientID lives in the hostname, not in a URL suffix that Android's private-DNS field won't accept. The DoH path-suffix form is useful when a DoH client (some browsers, some apps) locks the URL format to a specific domain."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"3e5KebmIFm","type":"paragraph","data":{"text":"Debugging tip:"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"zHdM2-evq4","type":"paragraph","data":{"text":"strict_sni_check: false"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"2yn54xdLHu","type":"paragraph","data":{"text":"phone.dns.richardapplegate.io"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"VhTua9zSYX","type":"paragraph","data":{"text":"dns.richardapplegate.io"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"Kan7kYfq5F","type":"header","data":{"text":"5. Routing diagram","level":3},"tunes":{"alignTune":"left"}},{"id":"_PmPHwpkGM","type":"table","data":{"withHeadings":true,"content":[["Request","Port","Who terminates TLS","Who answers"],["DoH — https://phone.dns.richardapplegate.io/dns-query","443/tcp","Caddy","AdGuard (via reverse_proxy)"],["DoT — phone.dns.richardapplegate.io:853","853/tcp","AdGuard","AdGuard"],["DoQ — phone.dns.richardapplegate.io:784","784/udp","AdGuard","AdGuard"],["Plain DNS (LAN only)","53/udp,tcp","n/a","AdGuard"],["Admin UI — https://dns.richardapplegate.io/","443/tcp","Caddy","AdGuard"]]},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"cRdejhAseE","type":"delimiter","data":{},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"9sbjd-Wq5f","type":"header","data":{"text":"📦 Upgrade","level":2},"tunes":{"alignTune":"left"}},{"id":"Mi6SKwkeCN","type":"code","data":{"code":"docker pull applegater/caddyui:v2.5.10\n# or\ndocker pull applegater/caddyui:latest"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"egBjt96E1t","type":"paragraph","data":{"text":"Multi-arch linux/amd64 + linux/arm64, SBOM + provenance attestations, scratch base, non-root UID 10001. No schema migration. Rows with stale alias lists self-heal on the next edit."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"hJVXpRJ4RM","type":"paragraph","data":{"text":"📦 View v2.5.10 release"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"SDm9oDbGI6","type":"paragraph","data":{"text":"🐳 Docker Hub"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"zUtqZGUnKY","type":"paragraph","data":{"text":"⭐ GitHub repo"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"SlRZsUZRwK","type":"delimiter","data":{},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"lNgu4vYtJI","type":"header","data":{"text":"💬 Feedback","level":2},"tunes":{"alignTune":"left"}},{"id":"wi1MW7xjbD","type":"paragraph","data":{"text":"The bug that kicked this off was mine — I hit it live while adding a second alias to the AdGuard proxy host and got the \"can't be reached\" screen for twenty minutes before I figured out which side of the stack was lying. If you've got a similar \"X seems set up right but Y doesn't work\" story, open an issue. A lot of the smaller fixes in the 2.5.x series started that way."},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"tH0Mym57pR","type":"paragraph","data":{"text":"Thanks to everyone running CaddyUI in anger and reporting the rough edges. The 2.5.x line has been almost entirely shaped by that feedback loop. 🙏"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}},{"id":"QNiGq-q_0d","type":"paragraph","data":{"text":"— Richard · @X4Applegate"},"tunes":{"alignTune":"left","styleTune":{},"visTune":{}}}],"version":"2.28.2"}