Declarative configuration
Warden’s configuration is declarative: you describe the state you want the resolver to reach, not the steps to get there.
The model is inspired by NixOS modules, where instead of one giant config file, you can also split the system across many small files, each owning a single concern. Warden then imports them at load time and composes them into one in-memory picture, making the configuration modular and the system reproducible from the files alone.
File & Folder Structure
In its simplest form, Warden runs from a single config.toml and as your setup grows (more devices, profiles, different VLANs…), Warden allows you to split the whole configuration into a directory tree, where each subdirectory holds files that own one type of entity (here is an example):
The folder structure above is suggested, but you can create it at your own logic, you add a directory the moment splitting it makes a file easier to read or share with someone else. One file per concern, named for what’s in it (ex. family.toml, iot.toml…) so a git diff on devices/iot.toml immediately tells the whole story.
The Master File
The master config.toml declares which fragment files to load:
schema_version = 1
includes = [
"devices/*.toml",
"profiles/*.toml",
"groups/*.toml",
"subnets/*.toml",
"schedules/*.toml",
"blocklists/*.toml",
"rules/*.toml",
]
[server]
listen = "0.0.0.0:53"
[upstream]
servers = ["1.1.1.1", "9.9.9.9"]When Warden loads this file, it expands every glob (* wildcard pattern), sorts the matches, and reads each fragment in turn. Each File (fragment) use the same TOML schema as the master, meaning that devices/family.toml is just a file containing [[devices]] entries, nothing more.
- Globs that match zero files are fine, as empty per-entity directories don’t break anything.
- A file listed explicitly in
includesand missing from disk is a hard error.
Merge rules — three categories, one philosophy
The merge of the different configuration files, happens in memory, every time Warden loads the configuration at daemon startup, on an Hot Reload and after every CLI write, as Warden revalidates the result before accepting it.
Your files on disk are never rewritten by this step: fragments stay exactly as you wrote them, and the merge only produces the single in-memory picture the resolver actually runs on.
Any included configuration file can declare any of these — the rules key off the TOML structure, not the file’s name or folder. In practice you follow the tree below:
- singletons live in the master
config.toml - plural entries
[[devices]]live in their per-entity directorydevices/*.toml - named maps tables as
[profiles.<id>]lives in theprofiles/*.tomlBut that’s convention, not a requirement, as a[[devices]]entry inrules/misc.tomlloads exactly the same way.
How fragments combine depends on what kind of TOML structure you’re declaring:
| Structure | Where you see it | What happens across files |
|---|---|---|
| Singleton table | [server], [upstream], [cache], [security], [api] | Exactly one file may define it. |
| Array of tables | [[devices]], [[groups]], [[subnets]], [[schedules]], [[blocklists]], [[admin_rules]] | Concatenate across files. Every entry must have a unique id. |
| Named map | [profiles.<id>] | Merge by key. The same key in two files is a conflict. |
The thing tying these together is a single principle: Warden never silently picks a winner. If two files both declare [server], or two files both add a device with id = "alice-laptop", or two files both define [profiles.kids], the load fails with a clear error citing the conflict.
This is intentional as in a small home or office network, a phantom device that depends on alphabetical file order is much worse than an immediate failure that points at exactly the line to fix.
The validator can do this even after composition because Warden carries a provenance sidecar alongside the merged config — a parallel map from each value to the (file, line) it came from. So any error you see, names the file and line where the offending line lives, even though the value has already been folded into the in-memory picture.
Elements that cannot be Splitted
Only the things that mean something globally stay in config.toml:
| Elements | Why they stay in the master |
|---|---|
schema_version, includes | The contract for how to read the rest. |
[server], [upstream], [cache] | Single, system-wide settings. |
[security], [anti_bypass], [ip_blocklists], [local_dns], [tracking], [socket], [api] | Singletons — by definition one per daemon. |
Anything plural as [[devices]], [[profiles]], groups, subnets, schedules, blocklists, admin rules — is what you’d naturally want to split
The CLI writes the same files
You’re not forced to edit TOML by hand. Every warden command that changes configuration writes back into the same declarative tree:
The --into flag tells Warden which fragment file to land in and if you omit it, Warden looks for the canonical per-entity directory next to the master ( devices/ for warden device add, groups/ for warden group add…) and picks automatically. If it has exactly one file, that file is chosen; if there are several, Warden refuses and asks you to be explicit. If it doesn’t exist, the new entry goes into config.toml.
The CLI is a typing aid, not a separate source of truth. Whatever you do at the command line ends up as TOML you can read, diff, version-control, and back up with tar /etc/purge-warden/.
Hot reload — full re-read, atomic swap
When you edit a file, Warden doesn’t pick up the change automatically, but the reload can be also explicit:
On reload, Warden re-reads every file from scratch, validates the merged result, and atomically swaps the running config in if and only if the new picture is valid. If validation fails, the daemon keeps the previous config and surfaces the error via warden config show --errors. The DNS handler never sees a half-applied state — there’s no moment where one device is on the new profile and another is on the old.
A handful of changes still need a restart, not a reload — anything that binds an OS resource. Changing [server].listen (the port the daemon listens on) or [socket].path (the IPC socket) means tearing down the listener, so systemctl restart is the right tool there. Everything else — devices, profiles, lists, rules, schedules, just needs a reload.