TL;DR: My Ansible role for BIND 9
turns a broad slice of the named.conf grammar into structured YAML. It
installs BIND, renders the configuration, checks it with named-checkconf -z,
and only then restarts named. Backups are enabled by default. The same data
model can describe a small recursive resolver, an authoritative server, or the
foundation of a DNS cluster.
BIND has a wonderfully capable configuration language. It also has enough braces, semicolons, special cases, and nested blocks to make a generic Ansible template an entertaining weekend project.
So I made one.
The role has templates for options, ACLs, zones, logging, TLS, DNSSEC policies, primaries, statistics channels, DNSTAP, response-policy zones, and a long tail of other BIND features. The YAML deliberately stays close to the real grammar:
options:
recursion: true
allow_recursion:
- localhost
- 10.20.0.0/16
dnssec_validation: auto
becomes roughly:
options {
recursion yes;
allow-recursion {
localhost;
10.20.0.0/16;
};
dnssec-validation auto;
};
It is not trying to invent a new DNS server. It is an inventory-friendly data model for the one we already have.
The two-minute version
The examples here assume Debian or Ubuntu, Ansible 2.13 or newer, root access
through become, and the distribution packages for BIND 9.18. The role’s
main branch targets that BIND series. BIND 9.20 support is still a separate
development track; the role does not detect the installed version and swap
configuration grammars at runtime.
The role uses community.general for configuration merging and backups, so a
requirements file needs both pieces:
---
roles:
- name: valid.bind9
src: https://git.valid.dk/daniel/ansible-bind9-role.git
scm: git
version: main # Pin a tested commit or tag for production.
collections:
- name: community.general
$ ansible-galaxy role install -r requirements.yml
$ ansible-galaxy collection install -r requirements.yml
The playbook itself is pleasingly dull:
---
- name: Configure BIND
hosts: bind9
become: true
roles:
- role: valid.bind9
All the interesting bits live in inventory. Configuration can be split across four layers:
default -> group -> site -> host
They map to bind9_default_config, bind9_group_config,
bind9_site_config, and bind9_host_config. Entries with the same filename
merge recursively. Later scalar values win, while nested lists append. A group
can therefore provide sensible policy while one odd host gets the exception it
inevitably demands.
A closed recursive resolver
Here is a normal caching resolver for an internal network. It also forwards one private namespace to the DNS servers that actually know about it:
bind9_backup_config: true
bind9_group_config:
- name: named.conf.options
options:
directory: "{{ bind9_working_directory }}"
recursion: true
allow_query:
- localhost
- 10.20.0.0/16
allow_recursion:
- localhost
- 10.20.0.0/16
allow_query_cache:
- localhost
- 10.20.0.0/16
dnssec_validation: auto
- name: named.conf.local
zones:
- name: corp.example
type: forward
forward: only
forwarders:
- 10.30.0.53
- 10.30.0.54
forward: only is useful here: if both internal servers disappear, private
names fail locally instead of leaking into public recursive DNS. Remove the
forward zone and the same config is a straightforward full resolver.
Also, it is closed. An open recursive resolver is not a charming public service; it is an abuse incident waiting for a calendar slot.
Apply it to one host:
$ ansible-playbook -i inventories/production/hosts.yml \
playbooks/bind9.yml --limit dns-resolver-01
The role writes the generated files below /etc/bind, runs
named-checkconf -z /etc/bind/named.conf, and notifies the default restart
handler only when a template changed and validation passed.
An authoritative primary and its secondaries
The more interesting setup is a small authoritative cluster. I like one hidden primary and at least two secondaries:
zone data in Git, IPAM, or a DNS provisioning system
|
v
hidden primary
|
NOTIFY + AXFR/IXFR
(transfers use TSIG)
/ \
v v
secondary 1 secondary 2
^ ^
+--- clients -+
Ansible manages the topology and named.conf. The primary alone receives zone
files from their real source of truth. The secondaries get zone data through
BIND’s own transfer protocol. Copying the same writable zone file to every
server would work, right up until it became exciting.
The shared authoritative policy is small:
bind9_group_config:
- name: named.conf.options
options:
directory: "{{ bind9_working_directory }}"
recursion: false
allow_query_cache:
- none
Then the primary gets a TSIG key and a primary zone:
bind9_host_config:
- name: named.conf.local
keylist:
- name: xfr-example-net
algorithm: hmac-sha256
secret: "{{ vault_bind9_transfer_secret }}"
zones:
- name: example.net
type: primary
file: "{{ bind9_libdir }}/db.example.net"
notify: true
also_notify:
- 192.0.2.11
- 192.0.2.12
allow_transfer:
addresses:
- key xfr-example-net
And each secondary gets the mirror image:
bind9_host_config:
- name: named.conf.local
keylist:
- name: xfr-example-net
algorithm: hmac-sha256
secret: "{{ vault_bind9_transfer_secret }}"
zones:
- name: example.net
type: secondary
file: "{{ bind9_libdir }}/db.example.net"
primaries:
- address: 192.0.2.10
key: xfr-example-net
allow_notify:
- 192.0.2.10
notify: false
allow_transfer:
addresses:
- none
The addresses above are from the documentation range; replace them with real
ones. The secret belongs in Ansible Vault or another secret store, not in Git.
keylist is the role’s name for a top-level BIND key block because key
is reserved in the configuration model. The rendered file and configuration
archives contain the decrypted secret, so protect both /etc/bind and
/data/backup/bind accordingly.
The configuration model describes where a zone file lives; it does not turn
record data into that file. I keep zone delivery separate. The order matters
because named-checkconf -z tries to load primary zones: install the tools,
stage the zone, and then apply the role.
- name: Configure the authoritative primary
hosts: bind9_primary
become: true
pre_tasks:
- name: Install BIND and its validation tools
ansible.builtin.apt:
name:
- bind9
- bind9-utils
state: present
cache_valid_time: 3600
- name: Deploy example.net
ansible.builtin.copy:
src: zones/db.example.net
dest: /var/lib/bind/db.example.net
owner: root
group: bind
mode: "0640"
backup: true
validate: "named-checkzone example.net %s"
notify: Reload example.net
roles:
- role: valid.bind9
handlers:
- name: Reload example.net
ansible.builtin.command:
argv:
- rndc
- reload
- example.net
The package task is redundant after bootstrap and harmless. On a zone-file
change, the handler asks BIND to reload only example.net. If the SOA serial
increased, the secondaries should hear NOTIFY and fetch the new version.
For configuration rollouts I would use inventory groups named bind9_primary
and bind9_secondaries, run secondaries with serial: 1, verify one, and
then continue. Bootstrap is the exception: bring up the primary first so there
is something to transfer.
The final check is refreshingly low-tech:
$ for ns in ns1 ns2 ns3 ns4; do dig @"${ns}.yourdomain" example.net SOA +norecurse +short; done
Every server should report the same SOA serial. A green playbook is nice; matching serials are nicer.
A cluster that can grow past three machines
For a handful of zones, explicit primary and secondary blocks are easy to read.
For hundreds, I would add a catalog zone. On each catalog consumer, the role can
render BIND’s catalog-zones statement:
bind9_host_config:
- name: named.conf.options
options:
catalog_zones:
- zone: catalog.example.net
default_primaries:
primaries:
- address: 192.0.2.10
key: xfr-example-net
min_update_interval: 10
The catalog itself is ordinary zone data with special records describing its
members. Secondaries transfer it and learn which other zones they should serve.
That moves zone membership out of a repeated named.conf list while keeping
Ansible responsible for server policy and topology.
I would keep four sources of truth deliberately separate:
- inventory says which machines are primaries, secondaries, and resolvers;
- the role variables describe BIND policy;
- Vault or a secret manager owns TSIG material; and
- Git, IPAM, or a DNS application owns zone records and serials.
That separation makes the cluster boring to operate, which is exactly what DNS should be after the fun part of building it.
A few sharp edges
This is still BIND, and the role does not make the sharp parts disappear:
- Zone lists append rather than merging entries by zone name. Define a complete zone in one layer instead of trying to patch it across several layers.
- A zone-data rollback still needs a newer SOA serial, even when the content came from an older Git revision.
The role’s configuration grammar documents the intended YAML surface. The BIND 9.18 manual remains the source for what the generated configuration actually means, especially resolvers and authoritative servers, NOTIFY, IXFR, and catalog zones, and TSIG.
The result is a role that does not hide BIND’s range. It makes a large and useful part of that range pleasant to keep in inventory, review in Git, and roll out without hand-editing semicolons on a production DNS server. That is pretty cool.