EN

Local DNS records

Work in progress — content may be incomplete.

A local DNS record answers a name from your own config — before the filter, before upstream. Split-horizon for the homelab, in a couple of lines of TOML.

What this is

A local DNS record is a static entry that tells Warden to answer a specific name itself, with an address you choose, instead of sending the query upstream. nas.home resolves to 192.168.1.50. sicuro.lab resolves to 10.10.1.15. The query never leaves your network, never touches a public resolver, and never runs through the blocklists — a local record is trusted because you wrote it.

Records live at two scope levels:

  • Global[[local_dns.records]] at the top of the config. Applies to every client.
  • Per-profile[[profiles.<id>.local_records]] inside a profile. Applies only to clients that resolve to that profile.

This page covers when to add a record, every field you can set on one, the wildcard (match_subdomains) capability, the CLI / TUI surfaces that read and write records, how a record beats the filter and upstream, and how the validator refuses the footguns — reserved-IP targets, public-suffix wildcards, CNAME loops.

When you reach for it

Add a local DNS record when you want a name answered from the LAN, not the internet:

  • Homelab hostnames. nas.home, printer.home, pbs.lab — friendly names for boxes that only exist inside your network.
  • Split-horizon. A service that has a public name but should resolve to an internal IP for clients behind Warden: git.example.com192.168.1.20 at home, its real public IP everywhere else.
  • Per-profile overrides. Point intranet.lan at the office proxy 192.168.5.50 only for the employees profile, while guests keeps resolving it (or failing) upstream.
  • One host, many names. Point an apex and every subdomain under it at a single internal box with one wildcard record — a reverse proxy that fronts grafana.apps.home, jellyfin.apps.home, and the rest.

You don’t reach for a local record when you want to:

  • Block or allow a domain by policy — that’s a blocklist or an admin rule referenced from a profile. A local record answers a name; it doesn’t express a deny rule.
  • Rewrite a queried name to a different name before resolution (domain migration, fake CNAME without pinning an address) — that’s a per-profile rewrite rule, a separate mechanism.
  • Serve MX, TXT, SRV, or NS. Only A, AAAA, and CNAME are synthesised locally; every other type falls through (see precedence).

Schema

Local DNS has a small global section plus repeatable record rows. The section carries the fallback TTL and one privacy switch; each record row is an entry in an array-of-tables.

The [local_dns] section

/etc/purge-warden/config.toml
toml
[local_dns]
ttl_secs = 3600                   # fallback TTL for records without their own
nodata_for_missing_types = true   # answer NODATA for a local name's missing types
FieldTypeDefaultPurpose
ttl_secsu32 (seconds), 1..=864003600TTL served for any record that has no ttl_secs of its own. Also covers auto-generated PTR records and the NODATA negative answer. Zero is rejected.
nodata_for_missing_typesbooltrueWhen a global local name is queried for a type it doesn’t hold, answer NODATA instead of forwarding upstream. See below.
recordsarray of records[]The global records, written as [[local_dns.records]] rows.

A record row

/etc/purge-warden/config.toml
toml
[[local_dns.records]]
domain = "nas.home"
type = "A"
value = "192.168.1.50"
FieldTypeRequiredDefaultPurpose
domainstring (FQDN)yesThe name to answer. Lowercased automatically.
typeA | AAAA | CNAMEyesRecord type. A → IPv4, AAAA → IPv6, CNAME → target name.
valuestringyesIPv4 for A, IPv6 for AAAA, a target FQDN for CNAME.
match_subdomainsboolnofalseWhen true, the record also answers every subdomain of domain (a wildcard). See below.
ttl_secsu32 (seconds), 1..=86400nofalls back to [local_dns].ttl_secsPer-record TTL override. Zero is rejected.

The TOML key for the record type is type, not record_type. Per-profile records use exactly the same row shape under [[profiles.<id>.local_records]].

match_subdomains — wildcard under an apex

By default a record matches only its exact name. Set match_subdomains = true and it also answers every subdomain, at any depth, with the same value as the apex:

/etc/purge-warden/config.toml
toml
[[local_dns.records]]
domain = "sicuro.lab"
type = "A"
value = "10.10.1.15"
match_subdomains = true

Now sicuro.lab, app.sicuro.lab, and api.v2.app.sicuro.lab all resolve to 10.10.1.15.

Longest-suffix wins. A more specific record still beats the wildcard. Declare both a wildcard on sicuro.lab (→ 10.10.1.15) and an exact record on app.sicuro.lab (→ 10.10.1.16), and a query for app.sicuro.lab gets 10.10.1.16 — the exact match is the longer suffix. Anything else under sicuro.lab still falls to the wildcard. It’s the same longest-match rule that subnets use for CIDRs.

Wildcards are refused on public suffixes
match_subdomains = true on a public suffix — a TLD or eTLD like com, org, co.uk — is rejected by the validator: it would rewrite an entire slice of the namespace. An empty domain with match_subdomains = true is refused for the same reason (it would match every query). Use wildcards on names you own, like home, lab, or sicuro.lab.

ttl_secs — per-record TTL

Every record inherits [local_dns].ttl_secs (default 3600) unless it sets its own. The valid range is 1..=86400 seconds at both levels — the section fallback and the per-record override are held to the same bound, and zero is rejected in both places.

/etc/purge-warden/config.toml
toml
[[profiles.employees.local_records]]
domain = "intranet.lan"
type = "A"
value = "192.168.5.50"
ttl_secs = 300            # clients cache this answer for 5 minutes

If an internal IP is about to change, lower the TTL a few hours ahead so clients pick up the new value quickly once you flip it.

nodata_for_missing_types — no upstream leak

Default true. When a name is defined in the global records but the query asks for a type that name doesn’t hold — the classic case is an A-only nas.lan and an IPv6 client asking for AAAA — Warden answers NODATA (NOERROR with an authority SOA, no address) instead of forwarding the query. Two reasons:

  1. Privacy. Internal hostnames (nas.lan, printer.lan) never reach the public resolver.
  2. Correctness. For a private TLD the upstream would answer NXDOMAIN, and DNS negative caching is per name, not per type — a client that asks AAAA first and gets NXDOMAIN may suppress its follow-up A query too, making your local record intermittently unreachable.
/etc/purge-warden/config.toml
toml
[local_dns]
nodata_for_missing_types = false   # opt out: restore upstream fall-through

Set nodata_for_missing_types to false only if you deliberately run split-horizon for the same name (local A record, public AAAA or TXT). The switch gates the global table only; per-profile records keep their own fall-through behaviour regardless.

Examples

A handful of homelab names, served globally to every client:

/etc/purge-warden/config.toml
toml
[[local_dns.records]]
domain = "nas.home"
type = "A"
value = "192.168.1.50"

[[local_dns.records]]
domain = "printer.home"
type = "A"
value = "192.168.1.51"

[[local_dns.records]]
domain = "media.home"
type = "CNAME"
value = "nas.home"

nas.home for everyone, but intranet.lan only for the employees profile — guests and default never see it:

/etc/purge-warden/config.toml
toml
# Global — every client.
[[local_dns.records]]
domain = "nas.home"
type = "A"
value = "192.168.1.50"

# Per-profile — only clients on profile "employees".
[[profiles.employees.local_records]]
domain = "intranet.lan"
type = "A"
value = "192.168.5.50"

A client on employees asking for intranet.lan gets 192.168.5.50. The same client asking for nas.home gets 192.168.1.50 (it falls through to the global record). A client on default asking for intranet.lan goes upstream — no local match.

A wildcard that sends an apex and every subdomain under it to one internal reverse proxy:

/etc/purge-warden/config.toml
toml
[[local_dns.records]]
domain = "apps.home"
type = "A"
value = "192.168.1.30"
match_subdomains = true

Now apps.home, grafana.apps.home, and jellyfin.apps.home all resolve to the proxy at 192.168.1.30, which routes by hostname. To block a domain rather than resolve it, reach for a blocklist or a profile deny rule — a local record’s job is to answer with a real address, and the validator refuses black-hole targets like 0.0.0.0 (see security notes).

A dual-stack service with a short TTL, ready for an IP change:

/etc/purge-warden/config.toml
toml
[[local_dns.records]]
domain = "git.lab"
type = "A"
value = "192.168.1.20"
ttl_secs = 300

[[local_dns.records]]
domain = "git.lab"
type = "AAAA"
value = "fd00::20"
ttl_secs = 300
Tip
Keep records in their own include fragment — local-dns.d/home.toml, local-dns.d/lab.toml — pulled into the master config.toml via includes. The CLI verbs write the global table in config.toml by default; hand-edit the fragment, or move the block, if you prefer to keep records grouped by site.

CLI

Every mutating subcommand validates against the merged config first, writes the TOML, then triggers a hot reload (atomic swap, no daemon restart). Read-only subcommands work without the daemon.

CommandWhat it does
warden local-dns add <domain> <A|AAAA|CNAME> <value> [--profile <id>] [--match-subdomains] [--ttl-secs <n>]Add a record. Without --profile it lands in the global [[local_dns.records]] table; with --profile <id> it lands on that profile’s local_records only.
warden local-dns remove <domain> [--profile <id>] [--record-type <A|AAAA|CNAME>]Remove records for a domain. Without --record-type, every type matching the domain in the chosen scope is dropped; with it, only that one type. Without --profile, removes from the global table.
warden local-dns list [--profile <id>] [--scope <global|profile|all>] [--record-type <A|AAAA|CNAME>]List configured records. Default scope is all (global plus every profile). When both --scope and --profile are given, --profile wins and --scope is ignored (no error).
warden local-dns show <domain> [--profile <id>]Show every record matching domain — type, value, subdomain flag, TTL, scope. Without --profile, the global table and every profile are searched.

Common patterns:

# Global record — answers for every client.
sudo warden local-dns add nas.home A 192.168.1.50

# Per-profile record.
sudo warden local-dns add intranet.lan A 192.168.5.50 --profile employees

# Wildcard under an apex, with a short TTL.
sudo warden local-dns add sicuro.lab A 10.10.1.15 \
  --match-subdomains \
  --ttl-secs 300

# Inspect what's configured.
warden local-dns list
warden local-dns list --profile employees
warden local-dns show intranet.lan --profile employees

# Remove — one type, or all types for the name.
sudo warden local-dns remove intranet.lan --profile employees --record-type A
sudo warden local-dns remove intranet.lan --profile employees
sudo warden local-dns remove nas.home        # global table

After every hot-reload-aware mutation, exactly one of four messages is printed — the same set used by every CLI write path:

  • daemon reloaded — change is live
  • daemon not running — change will take effect on next start
  • note: change landed on disk but no admin token is available to request a daemon reload. Run warden token generate or restart the daemon to activate.
  • warning: change landed on disk but the daemon rejected the reload (<msg>). Check journalctl -u purge-warden and consider systemctl restart purge-warden.

local_records.add and local_records.remove are recorded in the audit log. There is no --json flag on these verbs.

TUI

The Local DNS tab is a leaf under the Network section. It’s a master/detail view: the global [[local_dns.records]] table on top, the per-profile records below, with an audit-history side-card for the focused record.

ElementWhat it showsUseful keys
Global panelEvery global record — domain, type, value, subdomain flag, TTL, hitsj / k / / scroll
Profile panelOne profile’s records at a time, same columnso switch focus between Global and Profile; n / N next / previous profile
Add / Edit / RemoveIn-tab modals that write through the same code path as the CLI verbsa add, e edit, d remove on the focused row
RefreshRe-read the config from disk (offline view — staged edits show before reload)r

The records list is read from the on-disk config (the master plus its includes), not from the daemon — so an edit you’ve staged but not yet reloaded still shows. The hits column reads a per-record hit counter fetched over IPC on a slow tick; it shows on a boot-fresh TUI until the first poll lands, then live counts.

REST API

There is no REST CRUD for local DNS records — no POST / PATCH / DELETE against the entity. Records are authored through the CLI, a hand-edited TOML fragment, or the TUI, and the daemon picks them up on reload. This mirrors the other v1 config entities: the REST surface is read and telemetry, not declarative authoring.

IPC

The Unix socket exposes a read-only, no-token verb that returns the per-record hit counters the TUI renders in its hits column. Mutations do not have a dedicated IPC verb — they land on disk and the daemon refreshes through the standard reload path (IpcCommand::Reload { token }) that every CLI write triggers.

Decision precedence

A query is answered by the first stage that matches, in this order:

  1. Profile records — the resolved profile’s local_records.
  2. Global records[[local_dns.records]].
  3. Filter — allow / deny rules, then the blocklists.
  4. Upstream — forwarded to the configured resolver.

A profile record silently shadows a global record for the same name: if both profiles.employees.local_records and the global table define intranet.lan, an employees client gets the profile answer.

Profile resolved?Match in profile.local_records?Match in global local_dns.records?Result
nononono local match → filter, then upstream
yesnonono local match → filter, then upstream
anynoyesglobal local answer, global (or record) TTL
yesyesanyprofile local answer (shadows any global), profile TTL or fallback

Only A / AAAA / CNAME are synthesised. An MX, TXT, SRV, or NS query for a locally-defined name is never answered from a local record — it either returns NODATA (see below) or goes upstream.

Reverse DNS (auto-PTR)

For each global A / AAAA record, Warden also answers the matching reverse (PTR) lookup automatically — no separate entry needed. The PTR inherits the record’s effective TTL. Per-profile records do not generate PTR entries; reverse DNS is served from the global table only.

NODATA for missing types

With nodata_for_missing_types = true (the default), a query for a global local name asking for a type it doesn’t hold gets NODATA rather than being forwarded — keeping the internal name off the public resolver and dodging the AAAA-first NXDOMAIN trap. See the schema note. The gate applies to the global table only.

Security notes

Local records bypass the filter and the cache — an answer you write is served directly, ahead of any blocklist. That is the point, and it is also why they are operator-authored only: there is no path for an external blocklist or a remote feed to inject a local record. The validator refuses the dangerous shapes before a config with one can load:

  • Reserved-IP targets are refused. An A / AAAA value in an unspecified, loopback, multicast, broadcast, or reserved-future-use range (0.0.0.0, 127.0.0.0/8, ::1, 224.0.0.0/4, 255.255.255.255, 240.0.0.0/4, and friends) is rejected. These are never legitimate redirect targets and are common typos. To block a name, use a blocklist or a deny rule — not a local record pointed at a black-hole address.
  • Public-IP targets warn. A target outside the private ranges — RFC1918, IPv6 ULA, and link-local (169.254.0.0/16, fe80::/10) — is allowed (a self-hosted service on a static public IP is a real case) but emits a warning in the audit trail, because pointing an internal name at a public IP is how a man-in-the-middle redirect would look. Loopback and the other reserved ranges above are refused outright, not warned.
  • Public-suffix wildcards are refused. match_subdomains = true on a TLD or eTLD is rejected, as is the empty domain — neither should ever wildcard the whole namespace.
  • CNAME loops are refused. A CNAME chain that cycles back on itself (a → b → a, or a self-loop) inside a scope is caught at load and rejected.
  • Duplicate records are refused. The same domain and type declared twice in one scope fails the load.
  • A and CNAME can’t share a name. RFC 1034 forbids a name from holding both an address record (A / AAAA) and a CNAME; declaring both for the same name in one scope fails the load.
DNSSEC-strict clients see a local answer as forged
A synthesised local answer is not signed. A client doing strict DNSSEC validation will treat it as a bogus response. Local records are meant for internal names where the client trusts the resolver — not for banks, not for DNSSEC-hardened public domains you don’t control.

Record count isn’t capped. The per-query match is a linear suffix scan, so as a rule of thumb keep subdomain-matching records to a few hundred per profile for the fastest lookups — but that’s guidance, not a limit: nothing warns and nothing is rejected on size.

Troubleshooting

A reload fails with target '<ip>' is in a reserved/loopback/multicast range. The record points at a reserved address — 0.0.0.0, a 127.x loopback, a multicast or broadcast address. Fix the value to a real host IP. If your goal was to block the name rather than resolve it, use a blocklist or a deny rule instead.

A reload fails with cannot enable match_subdomains on '<domain>' — that is a public suffix. You set match_subdomains = true on a TLD or eTLD (com, co.uk, …). Wildcards are only allowed on names you own — put the wildcard one label deeper (internal.example.com, not com).

A reload fails with a CNAME-loop error. Two or more CNAME records point at each other, or one points at itself. Break the cycle — a CNAME must ultimately resolve to a name outside the local set.

An IPv6 client can’t reach a name that has only an A record. That’s nodata_for_missing_types doing its job: the AAAA query gets NODATA so the client falls back to the A record instead of caching an NXDOMAIN. If you actually serve that name publicly over IPv6 and want the upstream AAAA, add an AAAA record locally, or set nodata_for_missing_types = false.

A per-profile record isn’t answering; the global one is. Check which profile the client actually resolves to — warden local-dns list shows both scopes, and the query has to land on the profile that owns the record. A client on a different profile only ever sees the global table.

A change didn’t take effect. Confirm the reload message after the mutation. If it said the daemon wasn’t running or no token was available, the record is on disk but not live — start or reload the daemon.

For more, see troubleshooting.

See also

  • Profiles — the local_records on a profile shadow the global table for that profile’s clients.
  • Blocklists — policy filtering, which local records sit in front of.
  • Subnets — the same longest-match rule wildcards use, applied to CIDRs.
  • Server globals — where the [local_dns] section lives alongside the other global blocks.
  • TOML reference — every field, every constraint.
  • CLI reference — the full warden local-dns … surface.