The Centerpiece#
I recently rebuilt my home lab around Proxmox VE and a single Ansible repo I call Dojo, the how and why of that is its own post: How I GitOps’ed My Homelab. If Dojo has a centerpiece, it is DNS and Caddy. Almost everything else in the lab depends on both working correctly, so this is where I spent the most time getting the design right, and it turned out to deserve more room than a section in that post could give it. Here is that room.
DNS Is Layered, Not Flat#
Pi-hole sits in front for ad blocking, and it only forwards, it is never authoritative for anything. Behind it is CoreDNS, and behind that is Knot, which holds the actual authoritative zone data. Pi-hole runs as a primary and a replica, kept in sync with each other via Nebula-sync rather than sitting behind a shared virtual IP. CoreDNS and Knot each sit behind their own keepalived VRRP pair instead, so a single node failing at either of those layers does not take DNS down. Each pair also has anti-affinity rules set in Proxmox VE, so the cluster will not schedule both VMs in a pair onto the same physical host, which would quietly defeat the whole point of having a pair in the first place. One side effect I like a lot: pointing a client at Pi-hole versus pointing it straight at the CoreDNS VIP is, in practice, the ad blocking on/off switch for that client.
How a DNS query actually flows#
flowchart TD
clients["LAN clients"]
subgraph pihole["Pi-hole pair"]
direction LR
ph1["pihole-1 (primary)"]
ph2["pihole-2 (replica)"]
end
subgraph corend["CoreDNS + Knot"]
direction LR
cvip["coredns-vip"]
kvip["knot-vip"]
end
pub["Public resolvers"]
clients -->|"ad-blocked path"| ph1
clients -->|"ad-blocked path"| ph2
clients -->|"unfiltered, by design"| cvip
ph1 -->|"non-blocked queries"| cvip
ph2 -->|"non-blocked queries"| cvip
ph1 -.->|"Nebula-sync"| ph2
cvip -->|"core.vninja.com / apps.vninja.com"| kvip
cvip -.->|"everything else"| pub
Splitting Core from Apps#
Knot serves two internal-only zones: core.vninja.com and apps.vninja.com. This split was a deliberate naming decision, not something that just happened. core is infrastructure and platform-level services, the kind of thing everything else depends on. apps is user-facing services, the things I actually go visit or use day to day. Once I laid it out that way and thought it through, the split held up cleanly, every service I run falls clearly on one side or the other, and nothing has ever landed in an awkward middle ground I had to think twice about. The two zones live on separate VLANs as well, so the naming split is not just cosmetic, it lines up with how the network itself is actually segmented.
Caddy, Twice Over#
Caddy is the single reverse proxy in front of both zones. It handles automatic wildcard TLS for *.core.vninja.com and *.apps.vninja.com using Cloudflare DNS-01 challenges, which means every service behind it gets a real certificate without me touching a single cert file by hand. The stock Caddy image does not ship any DNS provider plugins, so this runs on a custom build with the Cloudflare DNS plugin compiled in. One Dockerfile, built once, and I have not touched a certificate by hand since.
Nothing behind that internal Caddy is port-forwarded from the outside. The internal services that need to be reachable from the internet are published through a Cloudflare Tunnel using cloudflared, with Cloudflare Access sitting in front requiring a one-time code. That means clearing Access before you even reach a service’s own authentication, two layers deep instead of one. The tunnel connector lives in its own ansible/cloudflare component, so it deploys and updates on its own.
For anything that is genuinely meant to be public, I run a second, completely separate Caddy in its own DMZ VLAN, on its own VM, with its own Cloudflare API token. The separation is deliberate rather than incidental: DMZ containers and internal containers never share a host, so a compromise out there cannot reach the credentials the internal Caddy uses to edit DNS. Public hostnames here are single-label names directly under vninja.com rather than anything under core or apps, this site’s own analytics being one example, which feels appropriately meta for a post about running infrastructure properly.
Getting to things from outside#
flowchart LR
lanuser["LAN user"]
extuser["External user"]
subgraph cf["Cloudflare"]
edge["Cloudflare edge"]
access["Cloudflare Access
one-time code gate"]
end
caddy["Caddy, ctr-core-1
wildcard TLS via DNS-01
*.core.vninja.com + *.apps.vninja.com"]
internal["Internal services
core + apps zones"]
cfd["cloudflared
ansible/cloudflare"]
tunneled["Internal services
published via tunnel"]
dmzcaddy["DMZ Caddy, ctr-dmz-1
own VLAN, own host, own Cloudflare token"]
public["Public-facing services"]
lanuser -->|"https://*.core / *.apps"| caddy --> internal
extuser --> edge --> access -->|"tunnel, no port-forward"| cfd --> tunneled
extuser --> dmzcaddy --> public
Build It Fully Before You Cut Over#
The whole DNS and Caddy stack was built and verified fully live on the new cluster before I touched a single client’s configuration. DNS, Pi-hole, Caddy, and the docsite were all confirmed working end to end first, and only then did clients actually get pointed at the new stack. That is the approach I would recommend for anyone doing this kind of cutover: build the replacement completely, prove it works under real use, and only then flip clients over. Cutting over blind and debugging DNS in production at the same time is not an experience worth repeating.

