Self-hosting a recursive DNS with unbound
With the recent AWS outage cause by a “DNS resolver” with AWS hosted services I wanted to share some knowledge I recently acquired during my self-hosting experimentations. While DNS entries are hosted by authoritative sources and applications hosted by cloud provider is a choice, as self-hosters we have a lot of power in our hands to control the data and services we use.
Authoritative DNS
One area that most people rely upon externally, which is rarely talked about, are DNS queries. Even people who are familiar with pihole rely upon either Cloudflare’s (1.1.1.1) or Google’s (8.8.8.8) for their filtered DNS requests. Relying on Cloudflare purports to provide a fast and private* DNS request, their infrastructure has proven to be vulnerable recently. The benevelent tech giants obscure from behind their marketing and VC funding the underlying infrastructure that powers the internet which is pubically accessible by all, root DNS servers!
The root DNS server infrastructure are hosted by IANA (Internet Assigned Naming Authority). This authority manages the root DNS requests which can be used to traverse from the top-level domains down to the exact domain’s IP address that you wish to access. The authority of IANA and ICANN, the parent organisation, moved away from US control to global stakeholder authority as recently as 2016.
With all of this context in mind, I recently started experimenting with hosting a recursive DNS on my home server with unbound. As mentioned above this won’t solve issues of resolving services hosted on cloud infrastructure that might experience outages ranging from tech-monopolies to tech layoffs, it does give you a little more decentralised ability to access the internet uncensored* and private* in the process if configured correctly. I will cover the basics in this article and in the future improve this setup.
Deployment
I’m a big fan of containerisation so will be using docker for this example but podman will work just as well.
The docker image is quite simple using the latest debian trixie as the baseline with the unbound application which installs with it the root.hints and root.key files.
The following Dockerfile should work to build a docker image containing unbound:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
FROM docker.io/debian:trixie-slim
RUN apt update && \
apt install -y unbound
RUN mkdir /keys && \
cp /usr/share/dns/root.* /keys/ && \
chown -R unbound:unbound /keys
RUN mkdir /conf && \
chown -R unbound:unbound /conf
RUN touch /var/log/unbound.log && \
chown unbound:unbound /var/log/unbound.log
RUN touch /docker-entrypoint.sh && \
chmod +x /docker-entrypoint.sh
RUN echo "#!/bin/bash" >> /docker-entrypoint.sh && \
echo "/usr/sbin/unbound -c /conf/unbound.conf -vvvv" >> /docker-entrypoint.sh && \
echo "tail -f /var/log/unbound.log" >> /docker-entrypoint.sh
ENTRYPOINT ["/docker-entrypoint.sh"]
Next you can build this image and push it to a container repository of your choice with the following commands:
1
2
sudo docker build -t unbound . --load
sudo docker push unbound
Alternatively you can just use a docker-compose.yml file to build and store locally the image being deployed.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
services:
unbound:
build:
context: .
container_name: unbound
image: unbound
pull_policy: build
environment:
PUID: 1000
PGID: 1000
TZ: UTC
ports:
- 53:53
volumes:
- ./unbound/unbound.conf:/conf/unbound.conf:ro
- ./unbound/a-records.conf:/conf/a-records.conf:ro
restart: unless-stopped
Make sure to place the Dockerfile and the docker-compose.yml file in the same location when attempting to build and deploy with a compose file.
Unbound configurations and A-Records
Configuration of the unbound mostly involves pointing to the root.hints and root.key files which are the most important for recursively querying the DNS servers for the IP address of the domain in question. Then the port unbound uses, the log file location, the verbosity of the logs and some configuration for local DNS queries can be configured with a-records.conf.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server:
auto-trust-anchor-file: "/keys/root.key"
root-hints: "/keys/root.hints"
interface: 0.0.0.0@53
logfile: /var/log/unbound.log
verbosity: 1
log-local-actions: no
log-queries: no
log-replies: no
log-servfail: no
chroot: ""
access-control: 127.0.0.1/32 allow
access-control: 192.168.0.0/16 allow
access-control: 172.24.0.0/12 allow
access-control: 10.0.0.0/8 allow
num-threads: 2
so-reuseport: yes
include: /conf/a-records.conf
Many self-hosters will have locally deployed services, it makes little sense to take a round-trip around the internet to access them. The example configuration below configures a domain and sub-domain to be responded with a local IP by unbound.
1
2
local-data: "example.co.uk A 192.168.1.1"
local-data: "cloud.example.co.uk A 192.168.1.1"
Once you have all these files in place you can execute the following command:
1
sudo docker compose up -d
Couple of side notes
Minor note #1
You might need to modify your resolv.conf to point to your local IP. Moreover adding a fallback here can be of some benefits in certain moments when you’re debugging your unbound container which will take down your personal DNS server.
1
2
3
4
5
> vi /etc/resolv.conf
nameserver 192.168.1.2
#nameserver <fallback DNS>
options edns0 trust-ad
search .
In the case of the fallback DNS you an use a major provider as a emergency backup as we must rely a little on the greater infrastructure that is available to us. You can always leave it commented out until you need it.
Minor note #2
The configuration I have provided for unbound doesn’t provide the maximalist configuration to protect the privacy of the individual making DNS queries. Unbound provides functionality to use DoH (DNS over HTTPS) or DoT (DNS over TLS) for upstream queries which can provide additional benefits. I leave this topic open for experimentations in a future article.
Minor note #3
Restarting the unbound container results in the in-memory cache being wiped. You can build a version of unbound can be configured with redis (maybe valkey) to keep the in-memmory cache more persistent. This can improve performance when your server or services need to be updated and/or restarted by reducing the number of requests needed to be made to root DNS servers for commonly requested sites.
Testing DNS requests
Finally we get to testing what we have built and deployed. Its quite straight-forward starting with the tool dig. You can install it as follows on different linux distros.
1
2
3
4
5
# Debian
apt install dnsutils
# Fedora
dnf install bind-utils
Use the following command to make a DNS query from your locally deployed unbound recursive DNS.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
> dig kernel.org
; <<>> DiG 9.18.39-0ubuntu0.24.04.1-Ubuntu <<>> kernel.org
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 50099
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
;; QUESTION SECTION:
;kernel.org. IN A
;; ANSWER SECTION:
kernel.org. 287 IN A 139.178.84.217
;; Query time: 0 msec
;; SERVER: 192.168.1.2#53(192.168.1.2) (UDP)
;; WHEN: Tue Oct 21 14:37:05 UTC 2025
;; MSG SIZE rcvd: 55
You have officially made a DNS query using root DNS servers. Congratulations!