One of the more important things in configuration management is DNS. In home labs we often don’t have DNS out of box. Some folks do use pi-holes but often don’t configure custom domains. I often use static reservations for all my IOT devices. This means all devices on my network can use DNS names to configure and talk to each other with those respective static’ish IPs. In this post I will show you how I use Puppet to setup an internal DNS domain.

Versions tested
Software Version OS
puppet 6.21.0 ubuntu
bind 9.16.1-Ubuntu ubuntu
ubuntu 20.04 server

I run this all on a set of two raspberry pis’


mod 'dns',
  :git => 'git://',
  :commit => '15805a8a6577bea6b3dfab1d8951369c925b5e6a'

mod 'monit',
  :git => '',
  :commit => 'f94712677271ccab0fc990478e31a7f37cb9791d'

mod 'collectd',
  :git => '',
  :tag => 'v12.2.0'

mod 'hiera',
  :git => ''
  :tag => 'v2.1.1'

mod 'resolv_conf',
  :git => '',
  :tag => 'v3.0.5'

mod 'systemd',
  :git => '',
  :ref => '2.12.0'

mod 'timezone',
  :git => '',
  :ref => 'v2.1.1'

Using a monit fork here as the original author doesn’t update OS version quickly enough for me.

Configuring NTP

Given modern requirements like DNSSec , time sync is an about necessity for dns servers.

  manage_timesyncd    => true,
  ntp_server          => ['', ''],
  fallback_ntp_server => ['', ''],

class { 'timezone':
    timezone => 'UTC',

class { 'ntp':
  servers    => [ '','' ],
  autoupdate => true,

package {'ntpdate':}

While this goes most of the way, there is a chicken before the egg scenerio here, that ntp requires DNS. I work around that on boot with the systemd implementation. See below.

Installing Bind

  $dns_domain = ''

  class {'dns::server':
   enable_default_zones => false,

  dns::server::options { '/etc/bind/named.conf.options':
    check_names_master     => 'fail',
    check_names_slave      => 'warn',
    dnssec_enable          => false,
    forwarders             => [ '', '' ],
    statistic_channel_ip   => '',
    statistic_channel_port => 8053,

The code above is an example of configuring bind9 without the default zones. I’m setting this server up with forwarders as I find that media streaming and such seems to work best with the content distro networks when I use my ISP’s DNS.

I’m making an anecdotal assumption here as its hard to verify this behavior but seems like the most likely conclusion

Adding DNS Zones

 dns::zone { $dns_domain:
    soa => $::fqdn,
    soa_email => "root.${::fqdn}",
    nameservers => [$::hostname],

  # Reverse Zone
  dns::zone { '53.168.192.IN-ADDR.ARPA':
    soa => $::fqdn,
    soa_email => "root.${::fqdn}",
    nameservers => [$::hostname],

  # Apex record e.g. vs
  dns::record { "apex_alias${dns_domain}":
    zone   => $dns_domain,
    host   => '@',
    record => 'A',
    data   => ''

Here we create a new DNS zone , and set the nameservers to the hostname of the machine. is the address of a blob server that runs the rendered for my website before I push it to the web

Configuring records

Now that we have out DNS zones configured we can add record. While you could do this in code, I find the amount of DNS records over time mean something like yaml is better. Further it also means you can load this data set into other tools e.g. add automatic testing with icinga. Stay tuned for a future article on icingaweb2.

First lets setup Hiera. Hiera is a data ingestion engine builtin to puppet. If you just setup your Puppet Server using my previous article then you need to manage hiera’s configuration.

  class { '::hiera':
    backends     => [
    datadir      => '/etc/puppetlabs/code/environments/%{environment}/data',
    hierarchy    => [

Bootstrapping puppet can have some chicken before the egg scenarios.

This is an example configuration you can apply to your Puppet Master to configure hiera with support for a file called dns.yaml in your data directory of your control repo. The most important idea here is that hiera is using yaml files that are dynamically name e.g. %{::trusted.certname} or statically named e.g. dns. For our purposes we will mostly be using static entries for our DNS records.

Lets add code to pull DNS records in from hiera:

  # Resource Defaults
  Dns::Record::A {
    zone => $dns_domain,
    ptr  => true,

  Dns::Record::Cname {
    zone => $dns_domain,

  hiera('a_records').each |$key, $value| {
    dns::record::a { $key:
      data => "${value['data']}",

  hiera('cname_records').each |$key, $value| {
    dns::record::cname { $key:
      data => "${value['data']}"

This code snippet will iterate over the keys in hiera. You can create more then A and CNAME but for my purposes, these and the automatically created PTR records normally are enough. Lets look at what our dns.yaml file looks like in this configuration:


# DNS Records
    data: ''
    data: ''
    data: ''
    tag: ['linux']
    data: ''
    tag: ['iot']

The nice thing about this configuration is that to add DNS records you now simply edit the yaml.

You can safely ignore the tag key here, it will be used in future articles to classify these entries for monitoring.

Installing useful tools

  package {'dnstop':
    ensure => present,

Monitoring & Reporting

  monit::check { 'bind9':
    content => 'check process named with pidfile /var/run/named/ 
    start program = "/etc/init.d/named start"
    stop  program = "/etc/init.d/named stop"
    if failed host port 53 type tcp protocol dns then restart
    if failed host port 53 type udp protocol dns then restart

While systemd is all the rage, I like pants and suspenders when it comes to critical services.

class { 'collectd::plugin::bind':
  url    => 'http://localhost:8053/',

We can also load the metrics into collect/graphite/graphana. This is configured above in the options:

   statistic_channel_ip   => '',
   statistic_channel_port => 8053,

You can checkout my article on setting up Graphite/Grafana/Collectd here

Split DNS on VPN

  dns::acl { 'trusted':
    ensure => present,
    data   => [ '', '', ]

I use zerotier for my “vpn” in addition to swan. I need to allow these clients to access the DNS server. in this configuration I setup the hiera range of my subnet (vpn) and the .54 address space I use with zerotier.

Configure resolv.conf

class { 'resolv_conf':
   nameservers => ['', '', ''],
   domainname  => $dns_domain,

Once we have tested the DNS server we can set it to perform resolutions with itself.

Systemd Hacks

Given i normally use raspberry pi’s I can’t rely on timesync working out of the box. I work around that by waiting until after boot (and the network is online) to sync with an IP address of a known NTP server.

 package {'ntpdate':
   ensure => present,

 # Setup the users custom shell as pass through to docker
  file {"/usr/local/bin/":
    ensure  => file,
    owner   => root,
    mode    => '0755',
    content =>   @("SHELL"/L)
    #!/bin/bash -x
    ntpdate -u &&
      service bind9 restart &&
        dig @
    | SHELL

  $_timer = @(EOT)
  Description=Run DNS checks at startup
  $_service = @(EOT)
  ExecStartPre=/bin/sh -c 'until ping -c 1 ; do sleep 1; done;'
    timer_content   => $_timer,
    service_unit    => 'timesync.service',
    service_content => $_service,
    active          => true,
    enable          => true,

This should run and make sure during a reboot or outage that your server syncs time even when DNS is broken

● timesync.service
     Loaded: loaded (/etc/systemd/system/timesync.service; static; vendor preset: enabled)
     Active: inactive (dead) since Tue 2021-02-16 12:27:00 PST; 31s ago
TriggeredBy: ● atboot.timer
    Process: 248739 ExecStartPre=/bin/sh -c until ping -c 1 ; do sleep 1; done; (code=exited, status=0/SUCCESS)
    Process: 248742 ExecStart=/usr/local/bin/ (code=exited, status=0/SUCCESS)
   Main PID: 248742 (code=exited, status=0/SUCCESS)

Feb 16 12:27:00[248941]: ;; QUESTION SECTION:
Feb 16 12:27:00[248941]: ;                        IN        A
Feb 16 12:27:00[248941]: ;; ANSWER SECTION:
Feb 16 12:27:00[248941]:                89        IN        A
Feb 16 12:27:00[248941]: ;; Query time: 19 msec
Feb 16 12:27:00[248941]: ;; SERVER:
Feb 16 12:27:00[248941]: ;; WHEN: Tue Feb 16 12:27:00 PST 2021
Feb 16 12:27:00[248941]: ;; MSG SIZE  rcvd: 83
Feb 16 12:27:00 systemd[1]: timesync.service: Succeeded.
Feb 16 12:27:00 systemd[1]: Finished timesync.service.

You should see similar output to this, if your systemd item fails you can monitor its failures to determine if DNS is down due to timesync issues.