I have a lot of domains and I wanted an easy way to manage all of them without having to deal with some kind of API from a third party. For learning purposes I decided to give CoreDNS a try. All of my domains are for my own hobby projects and I decided to try running my own nameservers to learn. There are plenty of free options available and it is not recommended to run your own nameservers.
The things I will be using in this guide are mainly:
- CoreDNS
- Ansible
- Hetzner cloud – instances are in Finland, costs around 6 euro per month
- Namesilo (affiliate link, use code GOPHP to get $1 off ) – for registering our nameservers
Hosting
I went ahead and deployed 2 Hetzner cloud instances since they are cheap and easy to deploy. I am also based in Europe which puts the NameServers close to me which is good for me, not so good for my US users. You could also use AWS EC2 or Google Cloud Compute instances or anything else you prefer.
Domains
I wanted a super simple way to manage my domains so I decided to make use of Ansible to deploy my NameServers. This allows me to keep track of all configurations in one big YAML file. Here is an example of how I have defined my domains.
---
# roles/common/vars/main.yml
domains:
- domain: 'uberswe.com'
mx:
- '10 in1-smtp.messagingengine.com'
- '20 in2-smtp.messagingengine.com'
txt:
- 'v=spf1 include:spf.messagingengine.com ?all'
sub:
- domain: 'fm1._domainkey'
cname:
- 'fm1.uberswe.com.dkim.fmhosted.com'
- domain: 'fm2._domainkey'
cname:
- 'fm2.uberswe.com.dkim.fmhosted.com'
- domain: 'fm3._domainkey'
cname:
- 'fm3.uberswe.com.dkim.fmhosted.com'
- # bokföring.xyz
domain: 'xn--bokfring-q4a.xyz'
- # bolån.xyz
domain: 'xn--boln-soa.xyz'
Here we have a variable called domains
with a list of objects. Each object needs to specify a domain
and the rest is optional.
CoreDNS Configuration
I can use the domains variable to create dns zone files and a CoreDNS configuration file which our dns server will read. We can use a Jinja template to make our configuration file. Below is the template I made.
.:53 {
forward . 8.8.8.8
log
errors
cache
}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for d in domains {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{d.domain}} {{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'sub' in d {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for s in d.sub {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{s.domain}}.{{d.domain}} {{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{
file /etc/coredns/zones/{{d.domain}}.db
log
cache
errors
}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
Our nameservers will forward any requests it doesn’t match to 8.8.8.8
as a fallback. This file will take the domains
list we defined in our YAML file and loop through each domain object specifying the db file for each domain.
I decided to use files as CoreDNS has a file plugin which seemed easy to use. I would like to try using the redis plugin in the future to load zone data from a redis instance. Below is my Jinja file which takes each object in the YAML file above and uses it to generate a dns zone file.
$ORIGIN {{ item.domain }}.
@ 3600 IN SOA ns1.beubo.com. admin.beubo.com. 2017042745 7200 3600 1209600 3600
3600 IN NS ns1.beubo.com.
3600 IN NS ns2.beubo.com.
IN A 95.216.12.246
IN AAAA 2a01:4f9:2a:d09::2
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'mx' in item {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for mx in item.mx {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}} IN MX {{mx}}.
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'txt' in item {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for txt in item.txt {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}} IN TXT "{{txt}}"
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'cname' in item {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for cname in item.cname {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}} IN CNAME {{cname}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
* IN A 95.216.12.246
IN AAAA 2a01:4f9:2a:d09::2
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'sub' in item {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for d in item.sub {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'ipv4' in d {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{d.domain}} IN A {{d.ipv4}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'ipv6' in d {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{d.domain}} IN AAAA {{d.ipv6}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'mx' in d {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for mx in d.mx {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{d.domain}} IN MX {{mx}}.
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'txt' in d {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for txt in d.txt {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{d.domain}} IN TXT "{{txt}}"
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} if 'cname' in d {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} for cname in d.cname {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{d.domain}} IN CNAME {{cname}}.
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endfor {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}{{25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a} endif {25926e1ae733c694be1fca7aad2c08379e63fd61e8d02131996d4c61b85a2b0a}}
Currently the nameservers, SOA record and IP addresses for the root domains are hardcoded but could easily be replaced with variables. The basic idea here is that I check if an item has a key then I echo the value. I also added the sub
item which contains any sub domains so I use a loop for that item. Here is an example zone file which I have generated for my dns server.
$ORIGIN uberswe.com.
@ 3600 IN SOA ns1.beubo.com. admin.beubo.com. 2017042745 7200 3600 1209600 3600
3600 IN NS ns1.beubo.com.
3600 IN NS ns2.beubo.com.
IN A 95.216.12.246
IN AAAA 2a01:4f9:2a:d09::2
IN MX 10 in1-smtp.messagingengine.com.
IN MX 20 in2-smtp.messagingengine.com.
IN TXT "v=spf1 include:spf.messagingengine.com ?all"
* IN A 95.216.12.246
IN AAAA 2a01:4f9:2a:d09::2
fm1._domainkey IN CNAME fm1.uberswe.com.dkim.fmhosted.com.
fm2._domainkey IN CNAME fm2.uberswe.com.dkim.fmhosted.com.
fm3._domainkey IN CNAME fm3.uberswe.com.dkim.fmhosted.com.
We can write an ansible test to ensure that this is working as it should before we try to run this on our nameservers. Below is my test file.
---
# tests/test.yml
- hosts: 127.0.0.1
vars_files:
- ../roles/common/vars/main.yml
tasks:
- name: Create a directory
file:
path: zones
state: directory
delegate_to: localhost
- name: Test dns zone files generation
template: src=../roles/dnstier/templates/dnszone.db.j2 dest=zones/{{ item.domain }}.db
with_items: "{{ domains }}"
- name: Create a directory
file:
path: files
state: directory
delegate_to: localhost
- name: Test Corefile for coredns generation
template: src=../roles/dnstier/templates/Corefile.j2 dest=files/Corefile
You can run this with ansible-playbook tests/dnszones.yml
and it should make a folder called zones
which will contain a .db file for each domain. A files
directory with the Corefile
will also be created.
Deployment Configuration
To setup and configure CoreDNS I found ansible role which was already ready to use at https://github.com/cloudalchemy/ansible-coredns. We can install this role using the command ansible-galaxy install cloudalchemy.coredns
.
We need to generate the zone files locally so I created a role called dnstier
which will prepare these files using the following task file.
---
# file: roles/dnstier/tasks/main.yml
- name: Create a directory
file:
path: /tmp/zones
state: directory
delegate_to: localhost
- name: dns zone files generation
template: src=../templates/dnszone.db.j2 dest=/tmp/zones/{{ item.domain }}.db
with_items: "{{ domains }}"
delegate_to: localhost
Now we can set up our main playbook which will run both of these roles.
---
# file: dnsservers.yml
- hosts: dnsservers
roles:
- dnstier
- cloudalchemy.coredns
We also need to define our variables for the cloudalchemy.coredns
role. This is how I defined them.
---
# file: group_vars/all.yml
coredns_config_file: "roles/dnstier/templates/Corefile.j2"
coredns_zone_files_paths:
- "/tmp/zones/*"
coredns_system_group: "coredns"
coredns_system_user: "{{ coredns_system_group }}"
The variables I have defined here come from the readme of the coredns role we installed with galaxy. The config file is the path to the template file we defined earlier and the zone file path is the same as we defined in our dnstier task. I also have a hosts file defined like so.
---
# host_vars/all.yml
all:
children:
dnsservers:
hosts:
beubo:
ns2beubo:
Here beubo
and ns2beubo
are the host names I have defined in my ssh config for my two nameservers. We could add more hosts here and ansible will deploy this same setup to each one.
Deploying the Nameservers
Now we can finally go ahead and deploy using the command ansible-playbook dnsservers.yml
which will:
- Run the
dnstier
role, generating the zone files. - Run the
cloudalchemy.coredns
role which installs our coredns server .
After the deployment is done we can check that it is working by specifying the IP of each dns server with a @
symbol in the dig command. You can run this for example to query my domain and server dig @95.216.12.246 uberswe.com
We can now register our nameservers. I am doing this with Namesilo but most registrars should provide an easy way of doing this. I will enter ns1
and ns2
as the prefixes and enter a ipv4 and ipv6 address for each nameserver. You can find the ip addresses on your Hetzner cloud instance page.

Once you have done that and waited some time for the dns to propagate you can query a domain using your server. We can replace the ip in our previous dig query with the domain like so dig @ns1.beubo.com uberswe.com
.
If that is working like it should, you should then be ready to point your domains to your new nameservers. Feel free to leave a comment if you have questions, see something I did wrong or just want to wish me a good day.
If this stuff interests you, you may be interested in some relevant topics I found while setting up my nameservers: