Skip to main content

Command Palette

Search for a command to run...

Hosting Your Own DNS with Unbound & Docker on Raspberry Pi

Updated
5 min read

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.lab

  • Resilient to reboots and container crashes

🧰 Prerequisites

  • inotifywait installed 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.

More from this blog

A

AUTOMATESTACK

15 posts