🐛 Release + Recipe

CaddyUI v2.5.10 — Alias domains now get DNS records, plus a self-hosted encrypted-DNS recipe

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.

The bug

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:

DNS_PROBE_FINISHED_BAD_SECURE_CONFIG

This site can't be reached
Checking the proxy, firewall, and Secure DNS configuration

What v2.5.9 fixed — and what it missed

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.

The user-initiated edit path did not. In updateProxyHost the change-detection logic was still:

oldDomain := dns.FirstDomain(old.Domains)
newDomain := dns.FirstDomain(p.Domains)
domainChanged := oldDomain != newDomain

DNS_PROBE_FINISHED_BAD_SECURE_CONFIG

should

The v2.5.10 fix

The patch is six lines of logic plus a "slices" import. Compare the full DomainList() instead of just the first element:

var oldDomains []string
if old != nil {
    oldDomains = old.DomainList()
}
newDomains := p.DomainList()
domainChanged := !slices.Equal(oldDomains, newDomains)

No schema change, no migration. Existing rows with a stale alias list self-heal on the next save.


🧅 The recipe this bug unblocked

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.

Here's the complete thing, copy-pasteable.

1. Wildcard cert via Caddy + Cloudflare DNS-01

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:

*.dns.richardapplegate.io, dns.richardapplegate.io {
  tls {
    dns cloudflare {env.CF_API_TOKEN}
  }

  reverse_proxy http://adguardhome:8080
}

Gotcha:

*.dns.richardapplegate.io

phone.dns....

dns.richardapplegate.io

2. AdGuard Home as a Portainer stack

services:
  adguardhome:
    image: adguard/adguardhome:v0.107.56
    container_name: adguardhome
    restart: unless-stopped
    ports:
      # DNS - must be on host
      - "53:53/tcp"
      - "53:53/udp"
      # Optional: DoT / DoQ direct (not via Caddy)
      - "853:853/tcp"
      - "784:784/udp"
    environment:
      TZ: America/Los_Angeles
    volumes:
      - /mnt/1TB/adguard/work:/opt/adguardhome/work
      - /mnt/1TB/adguard/conf:/opt/adguardhome/conf
      - /mnt/1TB/caddy/caddy_data:/caddy-certs:ro
    networks:
      - caddy-and-ui_caddy_net

networks:
  caddy-and-ui_caddy_net:
    external: true

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.

3. AdGuardHome.yaml — the TLS block that actually works

tls:
  enabled: true
  server_name: dns.richardapplegate.io
  force_https: false
  port_https: 443
  port_dns_over_tls: 853
  port_dns_over_quic: 853
  port_dnscrypt: 0
  dnscrypt_config_file: ""
  allow_unencrypted_doh: true
  certificate_chain: ""
  private_key: ""
  certificate_path: /caddy-certs/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.dns.richardapplegate.io/wildcard_.dns.richardapplegate.io.crt
  private_key_path:  /caddy-certs/caddy/certificates/acme-v02.api.letsencrypt.org-directory/wildcard_.dns.richardapplegate.io/wildcard_.dns.richardapplegate.io.key
  strict_sni_check: false
  • 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.

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:

0 4 1 * * docker restart adguardhome

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.

Debugging tip:

strict_sni_check: false

phone.dns.richardapplegate.io

dns.richardapplegate.io

5. Routing diagram


📦 Upgrade

docker pull applegater/caddyui:v2.5.10
# or
docker pull applegater/caddyui:latest

📦 View v2.5.10 release

🐳 Docker Hub

⭐ GitHub repo


💬 Feedback

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.

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. 🙏

— Richard · @X4Applegate