I turned BIND 9 configuration into YAML

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.

*NiX iS SeXy

Technical notes, systems troubleshooting, Linux fixes, infrastructure notes, and other operational scratchpad material.


Using structured YAML to manage recursive and authoritative BIND 9 servers with Ansible.

By Daniel Akulenok, 2026-08-28