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"
Related Articles
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.