One of the foundational services for any network is the Domain Name System (DNS), and the bigger the network, the harder DNS becomes to manage.
You may have heard the saying, “The shoemaker's son always goes barefoot”… or maybe you haven’t
If you’re anything like me, you’re always looking for ways to make your home network more “professional” but never quite get around to it.
Automating DNS is a great introduction to Infrastructure as Code if you want to dip your toes in. Provided you put safeguards in place, and ideally, you’re not editing the files directly!
I once had to manage Bind9 DNS entries for the service provider's management network. This was semi-automated. We edited the configuration file in Git, which was then synced to the DNS servers using Puppet after 30 minutes.
It worked well, until it didn’t.
The file we were editing was the Bind9 configuration file, which gets real fussy with spacing or formatting errors
Let’s say, one day I made a change (and left a trailing whitespace) which stopped the service from restarting and took down DNS for the whole management network 🙈
Let’s try not to do that again…
In this post, I’m going to run through how I set up DNS on my home network using Ansible
A quick note on Terraform
I chose to use Terraform to manage the underlying infrastructure in my local network, which in this case is a Proxmox Virtual Environment. Terraform creates the containers, installs the OS, sets the IP address, and pushes SSH keys. From this point, Ansible takes over and does the configuration.
I haven’t finished writing the post on using Terraform to manage the underlying container, but I will soon!
Why Automate DNS?
Before we dive into the how, here are some reasons you might want to automate your local DNS.
Consistency: Ensure all your servers and devices resolve names the same way, every time.
Repeatability: Easily rebuild or replicate your DNS setup in a new environment.
Version Control: Track changes to your DNS configuration using Git, allowing easy rollbacks and collaboration.
Time Savings: Automate adding, modifying, and removing DNS records.
And learning, of course!
But which DNS service?
There are so many DNS products out there to choose from. I originally had a Pi-Hole setup for this purpose, but I found it difficult to maintain the sync between package upgrades when set up in a redundant setup.
So I went back to an old friend - Bind9 (Berkeley Internet Name Domain)
This allowed me to run two lightweight DNS containers on a service I was already familiar with and automate the creation of entries within the configuration file.
Enough yapping, let’s jump in
Some of this may get complicated if you're unfamiliar with Ansible. I suggest having a look at some basic Ansible labs before proceeding.
The Ansible Workflow
Since we’re using Ansible to manage the configuration files for BIND9. Here's a high-level overview of the workflow:
Inventory: Define the server(s) where we are running BIND9.
Roles: Organise our logic into a reusable Ansible role named (unsurprisingly)
dns.Variables: Store our DNS-specific data (like domain names, server IPs, and DNS records) in organised variable files.
Templates: Jinja2 templates are used to generate the BIND9 configuration files based on our variables dynamically.
Tasks: Define the steps Ansible needs to take, such as installing BIND9, copying the configuration files, and ensuring the service runs.
Handlers: Define actions to take when specific events occur, like restarting the BIND9 service after a configuration change.
The Directory Structure
├── iac
│ ├── README
│ └── environments
│ └── production
│ ├── ansible
│ │ ├── dns_servers
│ │ │ ├── group_vars
│ │ │ │ └── dns_servers.yml
│ │ │ ├── playbooks
│ │ │ │ └── main.yml
│ │ │ └── roles
│ │ │ └── dns
│ │ │ ├── handlers
│ │ │ │ └── main.yml
│ │ │ ├── tasks
│ │ │ │ └── main.yml
│ │ │ └── templates
│ │ │ ├── db.lando.home.j2
│ │ │ ├── named.conf.local.j2
│ │ │ └── named.conf.options.j2
│ │ └── inventory.yml
The Inventory
For now, the inventory file is just a static file that contains a group of dns_servers .
---
dns_servers:
hosts:
dns1:
ansible_host: <ip_address>
ansible_user: root #default for newly created containers
dns2:
ansible_host: <ip_address>
ansible_user: root #default for newly created containers
Building the Role
A role is not needed for a simple DNS playbook; the tasks outlined in the role could be used directly in the playbook. However, I intend for this codebase to become complex pretty quickly( with multiple environments), so to save rework time, I am building the tasks into a reusable role.
Check out the documentation on Roles here 👉 Ansible Roles
Here is the role structure:
roles
└── dns
├── handlers
│ └── main.yml # For restarting the bind9 service
├── tasks
│ └── main.yml # Main tasks for configuring DNS
└── templates
├── db.lando.home.j2 # Forward zone template
├── named.conf.local.j2 # Main BIND9 configuration template
└── named.conf.options.j2 # Setting Bind9 Options
Let’s look at each file individually:
handlers/main.yml
This file contains a simple task to restart the bind9 service. It is defined in a separate handler file to ensure that the service is not restarted every time we run the playbook, just when something changes.
We use this with the notify flag in the main task to trigger when it changes.
---
- name: restart bind9
service:
name: bind9
state: restarted
tasks/main.yml
This file defines the main tasks to set up DNS on the servers.
---
- name: Set serial number
set_fact:
zone_serial: "{{ ansible_date_time.epoch }}"
- name: Install Bind9
apt:
update_cache: true
name: bind9
state: present
- name: Create named.conf.local
template:
src: named.conf.local.j2
dest: /etc/bind/named.conf.local
# notify: restart bind9
- name: Create named.conf.options
template:
src: named.conf.options.j2
dest: /etc/bind/named.conf.options
- name: Create db.example.com zone file
template:
src: db.lando.home.j2
dest: /etc/bind/db.lando.home
notify:
- restart bind9
- name: Ensure Bind9 is running and enabled
service:
name: bind9
state: started
enabled: true
templates/db.lando.home.j2
If you are familiar with bind9, you will recognise the naming of this J2 file. This file will create the db.lando.home bind9 database to hold the DNS records.
👉 Lando.home is the internal domain name here
Notice I am using variables in this file; we will define the values as part of the group_vars file later.
$TTL 86400
@ IN SOA {{ansible_hostname}}.{{domain_name}}. {{ dns_admin_email }}. (
{{ zone_serial }} ; Serial
3H ; Refresh after 3 hours
1H ; Retry after 1 hour
1W ; Expire after 1 week
1D ) ; Minimum TTL of 1 day
;
@ IN NS {{ansible_hostname}}.{{domain_name}}.
{{ansible_hostname}} IN A {{ ansible_default_ipv4.address }} ; DNS server's IP
@ IN A {{ ansible_default_ipv4.address }} ; DNS server's IP
{% if a_records %}
{% for record in a_records %}
{{ record.name }} IN A {{ record.ip }}
{% endfor %}
{% endif %}
templates/named.conf.local.j2
This is our template for the Bind9 local zone declaration file.
I could templatise this to define the zones in variables, but I don’t need to right now.
zone "lando.home" {
type master;
file "/etc/bind/db.lando.home";
};
templates/named.conf.options.j2
This is the template file for options such as setting up the allowed networks to query this DNS server.
acl allowednetworks {
{% if allowed_networks %}
{% for net in allowed_networks -%}
{{ net }};
{% endfor %}
{% endif %}
localhost;
localnets;
};
options {
directory "/var/cache/bind";
recursion yes;
allow-query { allowednetworks; };
{% if dns_forwarders %}
forwarders {
{% for forwarder_ip in dns_forwarders %}
{{ forwarder_ip }};
{% endfor %}
};
forward only;
{% endif %}
};
Defining the variables
Given we have templatised this to look for variables when we run the playbook, we need to give Ansible some variables.
Variables can be defined in several places, but I am using group variables because both my DNS servers will be configured the same way.
group_vars/dns_servers.yml
dns_server_ip: "{{ ansible_host }}"
dns_forwarders: # Choose your preferred public DNS servers
- "1.1.1.1"
- "8.8.8.8"
allowed_networks:# Set the networks that can query the DNS server.
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
dns_admin_email: hostmaster.lando.home
domain_name: lando.home
a_records:
- name: fw1
ip: <ip_address>
- name: sw1
ip: <ip_address>
- name: ap1
ip: <ip_address>
Running the Playbook
To apply this configuration to your DNS server, run the Ansible playbook:
ansible-playbook -i inventory.yml your_playbook.ymlReplace inventory.yml with your Ansible inventory file and your_playbook.yml with the playbook that applies the dns role to your DNS server(s).
Conclusion
There we have it. This creates two DNS servers with consistent configuration for high availability that can be set up with a single command.
This is just a starting point, and I plan to build my inventory dynamically.
My next task is to expand this to incorporate managing my Unifi Wireless through code, and I'll share my progress in a post!
Thanks for reading, and I hope you found this helpful.


