Introduction

BIND (Berkeley Internet Name Domain) is the most widely deployed DNS server software. Ansible automates DNS infrastructure — install BIND, template zone files from variables, manage A/AAAA/CNAME/MX/TXT/SRV records, configure primary-secondary replication, enable DNSSEC, and set up split-horizon DNS. All DNS records become code.

Install BIND

---
- name: Deploy BIND DNS server
  hosts: dns_servers
  become: true
  vars:
    bind_listen_ipv4: [any]
    bind_allow_query: [any]
    bind_forwarders:
      - 1.1.1.1
      - 8.8.8.8
    bind_recursion: false  # Authoritative only
  tasks:
    - name: Install BIND (Debian)
      ansible.builtin.apt:
        name: [bind9, bind9utils, bind9-dnsutils]
        state: present
      when: ansible_os_family == 'Debian'

    - name: Install BIND (RHEL)
      ansible.builtin.dnf:
        name: [bind, bind-utils]
        state: present
      when: ansible_os_family == 'RedHat'

    - name: Deploy named.conf.options
      ansible.builtin.template:
        src: named.conf.options.j2
        dest: "{{ '/etc/bind/named.conf.options' if ansible_os_family == 'Debian' else '/etc/named.conf' }}"
        mode: '0644'
      notify: restart named

    - name: Allow DNS through firewall
      ansible.posix.firewalld:
        service: dns
        permanent: true
        state: enabled
        immediate: true

    - name: Start BIND
      ansible.builtin.service:
        name: "{{ 'named' if ansible_os_family == 'RedHat' else 'bind9' }}"
        state: started
        enabled: true

  handlers:
    - name: restart named
      ansible.builtin.service:
        name: "{{ 'named' if ansible_os_family == 'RedHat' else 'bind9' }}"
        state: restarted

named.conf.options

// templates/named.conf.options.j2
options {
    directory "/var/cache/bind";

    listen-on { {{ bind_listen_ipv4 | join('; ') }}; };
    listen-on-v6 { any; };

    allow-query { {{ bind_allow_query | join('; ') }}; };
    allow-transfer { {{ bind_allow_transfer | default(['none']) | join('; ') }}; };

{% if bind_forwarders | length > 0 %}
    forwarders {
{% for fwd in bind_forwarders %}
        {{ fwd }};
{% endfor %}
    };
{% endif %}

    recursion {{ 'yes' if bind_recursion else 'no' }};

    dnssec-validation auto;
    auth-nxdomain no;

    // Rate limiting
    rate-limit {
        responses-per-second 10;
        window 5;
    };
};

// Logging
logging {
    channel default_log {
        file "/var/log/named/default.log" versions 3 size 5m;
        severity info;
        print-time yes;
    };
    category default { default_log; };
};

Forward Zone

- name: Deploy zone configuration
  ansible.builtin.template:
    src: named.conf.local.j2
    dest: /etc/bind/named.conf.local
    mode: '0644'
  notify: restart named

- name: Deploy zone files
  ansible.builtin.template:
    src: zone.j2
    dest: "/etc/bind/zones/db.{{ item.name }}"
    mode: '0644'
  loop: "{{ dns_zones }}"
  notify: reload named
// templates/named.conf.local.j2
{% for zone in dns_zones %}
zone "{{ zone.name }}" {
    type {{ zone.type | default('primary') }};
{% if zone.type | default('primary') == 'primary' %}
    file "/etc/bind/zones/db.{{ zone.name }}";
    allow-transfer {
{% for secondary in zone.secondaries | default([]) %}
        {{ secondary }};
{% endfor %}
    };
    notify yes;
{% else %}
    masters { {{ zone.masters | join('; ') }}; };
{% endif %}
};

{% endfor %}

{% for zone in dns_reverse_zones | default([]) %}
zone "{{ zone.name }}" {
    type primary;
    file "/etc/bind/zones/db.{{ zone.name }}";
};
{% endfor %}

Zone File Template

; templates/zone.j2
$TTL {{ zone.ttl | default('86400') }}
@   IN  SOA {{ zone.ns1 }}. {{ zone.admin_email | replace('@', '.') }}. (
        {{ ansible_date_time.epoch }}  ; Serial (auto-generated)
        3600        ; Refresh
        1800        ; Retry
        604800      ; Expire
        86400 )     ; Minimum TTL

; Name servers
{% for ns in zone.nameservers %}
    IN  NS  {{ ns }}.
{% endfor %}

; A records
{% for record in zone.a_records | default([]) %}
{{ "%-20s" | format(record.name) }} IN  A       {{ record.ip }}
{% endfor %}

; AAAA records
{% for record in zone.aaaa_records | default([]) %}
{{ "%-20s" | format(record.name) }} IN  AAAA    {{ record.ip }}
{% endfor %}

; CNAME records
{% for record in zone.cname_records | default([]) %}
{{ "%-20s" | format(record.name) }} IN  CNAME   {{ record.target }}.
{% endfor %}

; MX records
{% for record in zone.mx_records | default([]) %}
{{ "%-20s" | format(record.name | default('@')) }} IN  MX  {{ record.priority }}  {{ record.server }}.
{% endfor %}

; TXT records
{% for record in zone.txt_records | default([]) %}
{{ "%-20s" | format(record.name | default('@')) }} IN  TXT     "{{ record.value }}"
{% endfor %}

; SRV records
{% for record in zone.srv_records | default([]) %}
{{ record.name }}  IN  SRV {{ record.priority }} {{ record.weight }} {{ record.port }} {{ record.target }}.
{% endfor %}

Zone Variables

dns_zones:
  - name: example.com
    ns1: ns1.example.com
    admin_email: admin@example.com
    nameservers:
      - ns1.example.com
      - ns2.example.com
    secondaries:
      - 10.0.1.11
    a_records:
      - { name: "@", ip: 203.0.113.10 }
      - { name: www, ip: 203.0.113.10 }
      - { name: ns1, ip: 203.0.113.1 }
      - { name: ns2, ip: 203.0.113.2 }
      - { name: mail, ip: 203.0.113.20 }
      - { name: app, ip: 203.0.113.30 }
      - { name: api, ip: 203.0.113.31 }
    cname_records:
      - { name: blog, target: www.example.com }
      - { name: docs, target: docs.example.com }
    mx_records:
      - { priority: 10, server: mail.example.com }
      - { priority: 20, server: mail2.example.com }
    txt_records:
      - { value: "v=spf1 mx a ~all" }
      - { name: "_dmarc", value: "v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com" }
    srv_records:
      - { name: "_ldap._tcp", priority: 0, weight: 100, port: 389, target: ldap.example.com }

Reverse DNS Zone

dns_reverse_zones:
  - name: 113.0.203.in-addr.arpa
    ns1: ns1.example.com
    admin_email: admin@example.com
    nameservers:
      - ns1.example.com
    ptr_records:
      - { name: "10", target: www.example.com }
      - { name: "20", target: mail.example.com }
      - { name: "30", target: app.example.com }

Validate and Test

- name: Check zone file syntax
  ansible.builtin.command: >
    named-checkzone {{ item.name }} /etc/bind/zones/db.{{ item.name }}
  loop: "{{ dns_zones }}"
  register: zone_check
  changed_when: false

- name: Check named.conf syntax
  ansible.builtin.command: named-checkconf
  register: conf_check
  changed_when: false

- name: Test DNS resolution
  ansible.builtin.command: dig @localhost {{ item }} +short
  loop:
    - example.com
    - www.example.com
    - mail.example.com
  register: dig_results
  changed_when: false

Primary-Secondary Replication

# On secondary DNS servers
- name: Configure secondary zones
  vars:
    dns_zones:
      - name: example.com
        type: secondary
        masters: ["10.0.1.10"]

Troubleshooting

Zone Transfer Failing

- name: Test zone transfer
  ansible.builtin.command: dig @{{ primary_dns }} example.com AXFR
  register: axfr_test
  changed_when: false

Serial Number Issues

The template uses ansible_date_time.epoch as serial. If deploying multiple times per second, use a counter or date-based serial:

# YYYYMMDDNN format
serial: "{{ ansible_date_time.date | replace('-','') }}01"

Conclusion

Ansible turns DNS records into YAML variables — adding a record is adding a line to a list, and named-checkzone validates before applying. Template zone files from inventory, configure primary-secondary replication, and manage all record types (A, AAAA, CNAME, MX, TXT, SRV, PTR). DNS as code means every change is versioned, reviewed, and reproducible.