Hosting Your Own DNS with Unbound & Docker on Raspberry Pi
I've been steadily building a self-sufficient environment for my infrastructure experiments. One milestone was setting up my own DNS server—local and fully controlled.
I had a Raspberry Pi lying around, so I decided to use it to host my DNS as a weekend project.
This post will walk you through how I achieved that using Unbound, Docker, configuration management on my Raspberry Pi.
🗃️ The project code
📦 What We Are Building
A self hosted lightweight Unbound DNS resolver
Deployed via Docker Compose
Config stored in Git and auto-applied on change
Auto-start on Raspberry Pi boot
Designed for private use with domain:
home.labResilient to reboots and container crashes
🧰 Prerequisites
Installed Ubuntu 24.04 LTS server on Raspberry Pi using Raspberry Pi Imager
installed
Docker&Docker Composeon the Pi- Installation guideGitinstalled and aprivate repocreated to track Unbound configs

inotifywaitinstalled for the watchdog
sudo apt update
sudo apt install inotify-tools
🗂️ Project Structure
~/unbound-gitops/
├── docker-compose.yml
├── Dockerfile
├── unbound/
│ ├── unbound.conf
│ ├── root.hints
│ └── a-records.conf
├── watch-and-restart.sh
└── git-pull.sh (optional)
🐳 Docker Compose File
services:
unbound:
build: .
container_name: unbound
restart: unless-stopped #Persistent on reboot. So it automatically starts after reboot
ports:
- "53:53/udp"
- "53:53/tcp"
volumes:
- ./unbound/unbound.conf:/etc/unbound/unbound.conf:ro
- ./unbound/a-records.conf:/etc/unbound/a-records.conf:ro
- ./unbound/root.hints:/etc/unbound/root.hints:ro
🛠️ Dockerfile for Custom Unbound Image
FROM alpine:latest
RUN apk add --no-cache unbound libcap wget
COPY unbound/unbound.conf /etc/unbound/unbound.conf
COPY unbound/a-records.conf /etc/unbound/a-records.conf
COPY unbound/root.hints /etc/unbound/root.hints
RUN unbound-checkconf
EXPOSE 53/udp 53/tcp
CMD ["unbound", "-d", "-c", "/etc/unbound/unbound.conf"]
⚙️ Sample unbound.conf
server:
logfile: "/var/log/unbound/unbound.log" # Log file path
verbosity: 1 # Set verbosity level (0-4)
# Disable logging for performance, enable if you need to debug
log-queries: no
log-replies: no
log-tag-queryreply: no
interface: 0.0.0.0 # Listen on all interfaces
port: 53
# Enable IPv4, UDP, and TCP
do-ip4: yes
do-udp: yes
do-tcp: yes
# Disable IPv6 if not needed on your network
do-ip6: no
prefer-ip6: no
# Access control list. By default, refuse all.
# Then, allow specific networks.
access-control: 127.0.0.1/32 allow
access-control: 192.168.1.0/24 allow
access-control: 172.17.0.0/16 allow
access-control: 0.0.0.0/0 deny # Deny all other IPs
#access-control: 0.0.0.0/0 allow # Allow all IPs to query
# Uncomment above line to allow all IPs
#root hints file & records file
root-hints: "/etc/unbound/root.hints"
include: "/etc/unbound/a-records.conf"
# Harden DNS security settings
hide-identity: yes
hide-version: yes
harden-glue: yes
harden-dnssec-stripped: yes
use-caps-for-id: yes
prefetch: yes
rrset-roundrobin: yes
cache-max-ttl: 86400
cache-min-ttl: 3600
remote-control:
# Enable remote control interface with unbound-control
control-enable: no
📝 Add A Records (Example a-records.conf)
# Unbound DNS Configuration for Home Lab
# This file contains local DNS records for the home lab environment.
# It is included in the main unbound configuration file.
# Local Zone
local-zone: "home.lab." static
# A Records
local-data: "pve01.home.lab. IN A 192.168.1.190"
local-data: "pi.home.lab. IN A 192.168.1.10"
local-data: "nfs.home.lab. IN A 192.168.1.110"
local-data: "k8scontrol01.home.lab. IN A 192.168.1.100"
local-data: "k8snode01.home.lab. IN A 192.168.1.101"
local-data: "k8snode02.home.lab. IN A 192.168.1.102"
# PTR Record
local-data-ptr: "192.168.1.190 pve01.home.lab"
local-data-ptr: "192.168.1.10 pi.home.lab"
local-data-ptr: "192.168.1.110 nfs.home.lab"
local-data-ptr: "192.168.1.100 k8scontrol01.home.lab"
local-data-ptr: "192.168.1.101 k8snode01.home.lab"
local-data-ptr: "192.168.1.102 k8snode02.home.lab"
# CNAME Record
local-data: "storage.home.lab. IN CNAME nfs.home.lab."

🧠 Configure Raspberry Pi’s DNS Resolver
Currently, systemd-resolved is the DNS resolver for the host system i.e the RPi.
We need to stop systemd-resolved to free up port 53, so the Unbound container can use it.
sudo systemctl disable --now systemd-resolved
sudo rm -f /etc/resolv.conf
sudo tee /etc/resolv.conf > /dev/null <<EOF
nameserver 192.168.1.10
options edns0 trust-ad
search home.lab
EOF
🧪 Testing
Build the Docker image and start Docker Compose
git pull
docker compose build
docker compose up -d
Check the container is up
docker ps

Test DNS resolution
$ dig pve01.home.lab
; <<>> DiG 9.20.4-3ubuntu1.1-Ubuntu <<>> pve01.home.lab
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 16775
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 65494
;; QUESTION SECTION:
;pve01.home.lab. IN A
;; ANSWER SECTION:
pve01.home.lab. 975 IN A 192.168.1.190
;; Query time: 0 msec
;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP)
;; WHEN: Sat Jun 14 22:25:18 IST 2025
;; MSG SIZE rcvd: 59
You should see status: NOERROR and an ANSWER SECTION with your configured IP
Every time new DNS record is committed to Git, log in to the RPi and follow these steps:
git pull
docker compose restart <unbound_service_name>

🔄 Auto-Restart Container on Config Change
This steps gives the ability that any update you commit and pull will trigger a container restart.
Pull Git Updates
git-pull.sh
#!/bin/bash
cd ~/unbound-dns
git pull origin main
Setup a cron job to automate:
*/5 * * * * /home/pi/unbound-dns/git-pull.sh
Setup the watchdog
watch-and-restart.sh
#!/bin/bash
CONFIG_DIR="./unbound"
inotifywait -m -r -e modify,create,delete --format '%w%f' "$CONFIG_DIR" | while read file; do
echo "[INFO] Change detected in $file"
docker compose restart unbound
done
Make it executable:
chmod 700 watch-and-restart.sh
Start the watchdog
nohup ./watch-and-restart.sh > ~/watcher.log 2>&1 &

To make watch-and-restart.sh script run in the background on reboot, a reliable approach is to set it up as a systemd service.
But I want to completely scrap the watchdog and implement a pipeline-based solution that is more robust and independent. This approach allows for automated workflows that can be triggered by events such as code commits or pull requests.
I am working on a solution using git-action & container based ARM self-hosted runner, which i will share here very soon.






