Skip to main content
  1. posts/

How DNS and Caddy Work in My Homelab

 Author
Author
Christian Mohn
IT veteran, podcaster, author, and blogger from Bergen, Norway.
Table of Contents
Home Lab - This article is part of a series.
Part 9 (V4.0): This Article

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.

Home Lab - This article is part of a series.
Part 9 (V4.0): This Article

Related