Giving Internal Homelab Apps Real DNS Names and HTTPS
Builds on Part 2 of the Remote Access series (Caddy + Cloudflare Tunnel) and Part 1 (the public-vs-private decision). This post doesn't re-explain either. It connects them to a third piece: local DNS.
Getting real HTTPS at home for anything comes down to three ingredients: a domain you control, Caddy, and a Cloudflare account with an API token for the DNS-01 challenge. That's the whole requirement: whether an app also needs to be reachable from the internet is a separate decision layered on top, and needs exactly one more piece: Cloudflare Tunnel (from Part 2). This post lays out what you need for each case, and how the pieces combine so public and private apps both get the same clean hostname and valid HTTPS from a single setup.
Summary
- For HTTPS at all: a domain, Caddy, and a Cloudflare API token. That's it. Nothing here requires the hostname to be reachable from the internet.
- For a public app: add Cloudflare Tunnel (Part 2) and a real public DNS record. Anyone can reach it.
- For a private, LAN-only app: add nothing extra: just a local DNS rewrite pointing at the same Caddy box. No tunnel route, no public record, same valid HTTPS. The local DNS server doesn't matter: AdGuard Home, Pi-hole, Unbound, your router's own resolver all do the same thing: answer with an IP you choose instead of asking upstream.
- The one trap: a rewrite makes a name resolve. It does not make a route exist.
Why this even works: DNS-01 doesn't care if anyone can reach the hostname
Part 2 covered getting Caddy's Cloudflare DNS plugin built in and using it to fetch a certificate via DNS-01. What's worth pulling out separately here: DNS-01 proves domain ownership by writing a TXT record through the Cloudflare API. It never asks Let's Encrypt to connect to your hostname over HTTP. That's the mechanism most people miss the implication of.
Compare that to the older HTTP-01 challenge, which requires Let's Encrypt's servers to reach
http://yourhostname/.well-known/acme-challenge/..., which means the hostname has to be
publicly resolvable and publicly reachable, full stop. DNS-01 has no such requirement. It works
identically whether the hostname:
- has a public
Arecord pointing at your Cloudflare Tunnel, or - has no public DNS record whatsoever, and only exists inside your own network's DNS.
Caddy doesn't know or care which case it's in. It just asks Cloudflare's API "can you prove you
control example.com," gets a yes, and issues the cert. That's the whole trick this post is
built on.
The setup: one wildcard cert, two kinds of hostname
A single wildcard certificate for *.example.com covers every single-level subdomain under
that zone, public and private alike, issued the same way, renewed the same way, by the same
Caddy instance:
(cloudflare_dns) {
tls {
dns cloudflare {$CLOUDFLARE_API_TOKEN}
}
}
*.example.com, example.com {
import cloudflare_dns
encode zstd gzip
@public host blog.example.com
handle @public {
reverse_proxy host.docker.internal:8080
}
@private host dash.example.com
handle @private {
reverse_proxy host.docker.internal:9090
}
handle {
respond "Not found" 404
}
}
blog.example.com is the public one (a blog, in this example, but any app you've decided
should be reachable by anyone). dash.example.com is an internal admin dashboard, never meant
to leave the LAN. Both get their certificate from the exact same *.example.com wildcard, the
exact same DNS-01 challenge, the exact same renewal cycle. Nothing about the certificate
mechanism knows or cares which one is "public."
What actually makes one public and the other private happens outside Caddy entirely:
blog.example.comgets a Cloudflare Tunnel public hostname route (Part 2, Step 4) and a real public DNS record. Anyone on the internet can resolve it and reach it.dash.example.comgets no tunnel route and no public DNS record at all. The only way it resolves to anything is a rewrite in your local DNS server, used only by clients on your own network.
graph TB
subgraph "Public internet"
PUB[Public client]
end
subgraph "Your LAN"
LAN[LAN client]
DNS[Local DNS resolver<br/>rewrite: dash.example.com to 192.168.1.10]
end
subgraph "Cloudflare"
EDGE[Cloudflare edge]
end
subgraph "Your server, 192.168.1.10"
CFD[cloudflared<br/>outbound only]
CADDY[Caddy<br/>*.example.com wildcard cert<br/>DNS-01 via Cloudflare API]
APPPUB[Public app<br/>blog.example.com]
APPPRIV[Private app<br/>dash.example.com]
end
PUB -->|"blog.example.com<br/>real public DNS record"| EDGE
EDGE -->|relayed through tunnel| CFD
CFD --> CADDY
CADDY -->|host match| APPPUB
LAN -->|"dash.example.com?"| DNS
DNS -->|"192.168.1.10<br/>local rewrite only"| LAN
LAN -->|direct, no tunnel| CADDY
CADDY -->|host match| APPPRIV
Same box, same cert, same Caddyfile. One hostname has a path in from the public internet; the
other only has a path in from your own network's DNS. Caddy just answers whichever Host
header shows up. It has no opinion on how the request found it.
What a DNS rewrite actually is
A rewrite (AdGuard Home calls it that; Pi-hole calls it a "Local DNS Record"; Unbound uses
local-data; most consumer routers bury it under "DNS overrides" or similar) is just your local
resolver answering a specific hostname with an IP address you chose, instead of forwarding the
query upstream. Every client on your network that uses that resolver (which is normally
whatever your DHCP server hands out) gets your answer instead of whatever the public internet
would say (or nothing, if the name doesn't exist publicly at all).
That's what turns dash.example.com from a name nobody outside your house has ever heard of
into something every device on your LAN resolves straight to Caddy. Compare the two ways of
reaching the same dashboard:
https://192.168.1.10:9090works, but it's an IP and a port. It changes if the box's IP ever changes, and unless you've separately set up a cert for that raw IP, your browser is going to complain.https://dash.example.com, same destination, memorable, and Caddy already holds a valid cert for it because of the wildcard above. No warning, no IP to remember, no port to guess.
The rewrite is the only thing making that second URL resolve at all. It's not sent anywhere public, it's answered entirely inside your network.
The trap: resolving isn't routing
Here's the mistake this setup makes easy to fall into. A rewrite that points a hostname at a
machine only reachable over a private overlay network (Tailscale, WireGuard, any mesh VPN)
resolves correctly for any client that happens to also be a member of that overlay. For a
client that's LAN-only, on a subnet the overlay never touches, the same lookup still succeeds.
dig and nslookup report the right answer instantly. The connection afterward just hangs,
because nothing in that client's routing table knows how to reach the overlay's address space.
It reads like a DNS problem because DNS is the last thing you checked before it broke. It isn't one. DNS did its job correctly. The rewrite promised a route it doesn't actually control. If you're pointing a rewrite at Caddy's plain LAN IP (as in the diagram above), this doesn't apply: every LAN client can reach a LAN IP by definition. It only bites if you ever repoint a rewrite at something living on a separate private network instead.
Where to actually configure the rewrite
This post deliberately doesn't pick a local DNS server. The concept above works with any of
them. For a concrete, working example with a real docker-compose.yml, the port-53 conflict
that trips up almost everyone on Synology, and the host-vs-bridge networking tradeoff for
seeing real client IPs, see Running AdGuard Home in Docker on
Synology.
What this buys you
Every app in the homelab gets the same clean-hostname, valid-HTTPS experience, regardless of whether it's meant for the public internet or only for people already on your couch. The decision of "should this be public" (Part 1) stays completely separate from "does this get a real hostname and a real cert" (this post), one Caddyfile, one cert mechanism, and the only thing that changes between a public and a private app is whether you gave it a tunnel route and a public DNS record.
Related
Part 2: Cloudflare Tunnel
cloudflared connects outbound-only to Cloudflare's edge; Caddy reverse-proxies apps behind it with automatic TLS: compose files, and a DNS-challenge trap.
Part 1: Cloudflare vs Tailscale
Cloudflare Tunnel publishes an app without opening a port; Tailscale puts your devices on one private network. The real difference, and the hybrid I run.
Part 3: Tailscale
Tailscale runs once per host, not per container: every app on it becomes reachable through one mesh IP. The setup, a DNS trap, and the sidecar exception.
If this saved you some time, a coffee keeps the lights on and the posts coming.
☕ Buy me a coffee