Skip to main content
  1. posts/

Managing Hosts and Zones in My Homelab's DNS

 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 11 (V4.0): This Article

The Mechanics
#

How DNS and Caddy Work in My Homelab covers why the DNS stack looks the way it does: the Pi-hole, CoreDNS, and Knot layering, the VRRP pairs, the deliberate split between the core and apps zones. None of that is repeated here. The general CI and deploy model this fits into is its own post too, How I GitOps’ed My Homelab.

This post is about the mechanics underneath both of those: what actually happens when a new host needs a DNS entry, or a whole new zone needs to exist.

The examples below use made-up names and addresses, not anything from my real network. The format is exactly what I run, the data isn’t.

The Zone File Format
#

Every zone is a single YAML file. The IP address is the primary key, not the hostname, which took a moment to get used to:

records:
  10.20.30.10:
    hosts:
      - web-01
      - www
  10.20.30.11:
    hosts:
      - db-01
  10.20.30.1:
    hosts:
      - gateway
    ptr: gateway

Each IP gets a hosts: list. The first entry becomes the canonical name, the one used for the PTR record. Any additional entries are aliases, extra A records pointing at the same IP, www in the example above rides along with web-01 for free.

The optional ptr: field overrides which name gets used for the PTR record if you want that to differ from the first hostname, gateway above is redundant since it already matches, but the option exists for cases where it wouldn’t.

The one rule that actually matters: an IP is defined in exactly one zone file, ever. Not “usually,” not “unless there’s a good reason,” ever.

Here’s why it matters in practice, not just in theory: reverse DNS gets generated by reading every forward zone file and grouping by IP, covered in the next section. Put the same address in two zone files and that process now has two different hostnames both claiming the same PTR record, with no error at write time, no warning at commit time, just a reverse zone that quietly comes out wrong the next time it deploys.

Reverse DNS Is Never Hand-Written
#

I have never once opened a reverse zone file and typed a PTR record into it, and I don’t ever want to. Knot generates every one of them automatically, by reading the same forward zone files that already exist, grouping every IP by its /24, and rendering one reverse zone per network.

The /24 isn’t a simplification for this post, it’s genuinely how the automation draws the line: reverse DNS is organized around in-addr.arpa zones, and those are conventionally /24-sized, so that’s the boundary every IP gets grouped by, not something I chose per zone or could size differently.

flowchart LR
    z1["lab.example.com.yml
10.20.30.10-19"] z2["ops.example.com.yml
10.20.30.40-49"] knot["Knot role
groups every IP by its /24"] rev["30.20.10.in-addr.arpa
one reverse zone file"] z1 --> knot z2 --> knot knot --> rev

If two separate zones happen to share a /24, both feed into the same reverse zone file without me doing anything about it. Given the lab.example.com example above, 10.20.30.10 derives a PTR of web-01.lab.example.com. in 30.20.10.in-addr.arpa, with zero effort on my part beyond having written the forward record in the first place.

This is the one part of the whole setup I’d genuinely call a small joy: reverse DNS used to be the thing I’d forget to update, and now it isn’t a thing I update at all.

Adding a Host to an Existing Zone
#

Say lab.example.com needs a new server. Open the zone file and add an IP block:

records:
  10.20.30.10:
    hosts:
      - web-01
      - www
  10.20.30.11:
    hosts:
      - db-01
  10.20.30.1:
    hosts:
      - gateway
    ptr: gateway
  10.20.30.12:            # new entry
    hosts:
      - app-02

Commit it and push:

git add group_vars/all/dns/lab.example.com.yml
git commit -m "dns: add app-02 to lab.example.com"
git push origin main

That’s the whole change. Forward record, PTR record, both handled from one edit.

Adding a Brand New Zone
#

A new zone is just a new file, svc.example.com.yml for example:

records:
  10.20.40.10:
    hosts:
      - api-01
  10.20.40.11:
    hosts:
      - worker-01

Save that in group_vars/all/dns/, commit, and push. Nothing else about the zone itself needs to change. Ansible discovers zone files dynamically via a fileglob lookup, so a new file just shows up in the next deploy, it doesn’t need to be registered anywhere by hand.

One caveat worth being precise about: that covers Knot serving the zone authoritatively. If I want it resolvable through the caching layer in front of Knot too, the zone needs one more mention, in coredns_internal_zones in group_vars/all/vars.yml:

coredns_internal_zones:
  - "lab.example.com"
  - "svc.example.com"

That list is what makes CoreDNS generate a dedicated forwarding block for the zone instead of sending those queries out to a public resolver. It lives in vars, not in the zone file itself, so adding it is a one-line edit in a different file, followed by a CoreDNS-only redeploy:

ansible-playbook dns.yml --tags coredns

Miss that step and the zone still exists, Knot will answer for it directly, but clients that go through the CoreDNS/Pi-hole path first will fall through to a public resolver instead and get NXDOMAIN. Easy to miss once, unlikely to miss twice.

Removing a Host or a Zone
#

Removal is the same operation in reverse, and just as unremarkable.

To remove a single host, delete its IP block from the zone file and push. The zone file on disk gets replaced atomically, and Knot reloads it automatically, the host and its auto-derived PTR are just gone.

To remove a whole zone, delete the zone’s YAML file from group_vars/all/dns/ entirely. Since Ansible discovers zones by globbing that directory, a file that no longer exists is a zone that no longer gets deployed.

If that zone was also listed in coredns_internal_zones, take the entry out of there too and redeploy CoreDNS with --tags coredns, the same step as adding a zone, just undone. Skip that part and CoreDNS keeps forwarding queries for a zone Knot no longer serves, which is a stranger failure mode than it sounds like, everything still resolves right up until the moment Knot actually forgets the zone.

How It Actually Goes Live
#

Both of the changes above go out the same way. A push touching group_vars/all/dns/ triggers the deploy-dns.yml CI workflow, which runs ansible-playbook dns.yml --tags zones and follows it with a smoke test. The general shape of that pipeline, the runner, the secrets, what triggers what, is covered in the GitOps post’s How changes go live section, and I’m not repeating it here.

What’s worth showing here instead is what that specific tag actually touches, since the push itself is only half the story, the runner still has to go run the playbook for any of this to be real.

flowchart LR
    dev["git push 
group_vars/all/dns/"] gh["GitHub
private repo"] wf["deploy-dns.yml
--tags zones"] runner["Self-hosted runner, ghr-1"] knot["knot-1, knot-2
reload the changed zone"] dev --> gh --> wf --> runner --> knot classDef external fill:#d97706,fill-opacity:0.18,stroke:#d97706,stroke-width:1px class gh external

The zones tag is scoped to exactly one role, on exactly the nodes that hold authoritative zone data:

- name: Deploy Knot DNS (authoritative)
  hosts: dns_knot
  serial: 1

  roles:
    - role: knot
      tags: [knot, zones]
    - role: keepalived
      tags: [keepalived]
The full playbook, both plays

The zones tag only ever runs the first play below. The second play, CoreDNS, is what a full ansible-playbook dns.yml with no tags would also touch, shown here for context, not because a zone change ever reaches it.

---
# dns.yml, DNS Infrastructure Playbook
#
# Usage:
#   ansible-playbook dns.yml              # Full deploy
#   ansible-playbook dns.yml --tags knot  # Knot DNS only
#   ansible-playbook dns.yml --tags coredns
#   ansible-playbook dns.yml --tags keepalived
#   ansible-playbook dns.yml --tags zones  # Re-push zone files only
#
# Nodes:
#   knot-1, knot-2       → Knot DNS (authoritative) + Keepalived (knot_vip)
#   coredns-1, coredns-2 → CoreDNS (resolver)       + Keepalived (coredns_vip)

- name: Deploy Knot DNS (authoritative)
  hosts: dns_knot
  become: true
  gather_facts: false
  serial: 1             # rolling: one node at a time for zero-downtime upgrades

  pre_tasks:
    - name: Verify SSH reachability
      ansible.builtin.wait_for_connection:
        timeout: 60
      tags: [always]

  roles:
    - role: knot
      tags: [knot, zones]
    - role: keepalived
      tags: [keepalived]

- name: Deploy CoreDNS (resolver/caching)
  hosts: dns_coredns
  become: true
  gather_facts: false
  serial: 1             # rolling: one node at a time for zero-downtime upgrades

  pre_tasks:
    - name: Verify SSH reachability
      ansible.builtin.wait_for_connection:
        timeout: 60
      tags: [always]

  roles:
    - role: coredns
      tags: [coredns]
    - role: keepalived
      tags: [keepalived]

A zone-only push never touches CoreDNS or keepalived, and never touches both Knot nodes at once either, serial: 1 means one node at a time, so the pair never loses authoritative service mid-deploy. Knot reloads the changed zone automatically once the new file lands, no restart, no manual knotc reload on my part.

When I Get It Wrong
#

There is no offline check that catches a bad zone file before it reaches a node. Knot doesn’t have one, knotc conf-check needs the daemon already running to validate against, so the first real test is the reload itself.

If a zone file is broken enough to matter, that reload or restart fails, and the Ansible task fails right there and reports it, loudly, in the middle of the deploy. It doesn’t fail quietly, and it doesn’t leave a node half-updated and pretending to be fine.

After a deploy actually succeeds, smoke_test.yml runs next: a forward lookup and a reverse lookup against the Knot VIP, then the same two against the CoreDNS VIP, proving the whole path end to end rather than just “the service started.” The test data isn’t hardcoded either, it’s pulled from whatever the first zone file happens to contain, so the check stays honest as zones change instead of quietly testing the same stale record forever.

If something does get through and turns out to be wrong anyway, wrong IP, wrong hostname, a zone that should never have been merged, the fix is the same as anything else in a repo like this: revert the commit and push.

The revert deploys exactly like any other change would, through the same workflow, with the same smoke test at the end, and the previous good state is back. There is no separate rollback procedure to remember, because a rollback here is just a forward change that happens to point backward.

One Edit, One Push
#

I used to dread adding a host to DNS. Not because it was hard, but because it meant logging into the Synology DNS Server package’s own web UI and typing the record in by hand, one field at a time, nothing to script against, nothing to review before it went live.

Here’s what it takes now, just to add one hostname:

  • Two layered resolvers
  • A pair of VRRP failover groups
  • A CI pipeline
  • A smoke test

That is exactly the kind of thing I already called overengineered to the point of absurdity when I wrote about the rebuild itself.

I stand by that description. If anything, this post is further evidence for the prosecution.

But the PTR record isn’t something I touch anymore. Records can be added or removed programmatically now, from a script or any other automation. A mistake reverts the same way any other change does, a git push, not a special procedure I have to remember at the worst possible time, usually at night, usually when something else is already on fire.

That’s the real payoff, not the resolvers or the VRRP pairs. Adding DNS to my own network is boring now, in exactly the way infrastructure should be.

It took a small mountain of infrastructure to get there, but the view is worth it.

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

Related