# 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

* [Link to the Github Repo](https://github.com/sumitsaz23/unbound-dns-container)
    

## 📦 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

* Installed Ubuntu 24.04 LTS server on [Raspberry Pi](https://www.raspberrypi.com/) using [Raspberry Pi Imager](https://www.raspberrypi.com/software/)
    
* installed `Docker` & `Docker Compose` on the Pi- [Installation guide](https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository)
    
* `Git` installed and a `private repo` created to track Unbound configs
    

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1749921031862/1e4160eb-b3e0-4f89-b511-af7ee712317f.png align="center")

* `inotifywait` installed for the watchdog
    

```bash
sudo apt update
sudo apt install inotify-tools
```

## 🗂️ Project Structure

```bash
~/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

```yaml
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

```Dockerfile
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`

```bash
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`)

```bash
# 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."
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1749921665506/fd8b0bdf-e60d-40ab-aec7-21151d3da67c.png align="left")

## 🧠 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.

```bash
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

```bash
git pull
docker compose build
docker compose up -d
```

Check the container is up

```bash
docker ps
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1749920275010/3834186c-4e6d-41fa-aaaf-d72b9cc98415.png align="center")

Test DNS resolution

```bash
$ 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:

```bash
git pull
docker compose restart <unbound_service_name>
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1750322627762/54dc616b-b871-4cf0-8243-d9625cc67f06.png align="center")

## 🔄 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`

```bash
#!/bin/bash
cd ~/unbound-dns
git pull origin main
```

Setup a cron job to automate:

```bash
*/5 * * * * /home/pi/unbound-dns/git-pull.sh
```

## Setup the watchdog

`watch-and-restart.sh`

```bash
#!/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:

```bash
chmod 700 watch-and-restart.sh
```

Start the watchdog

```bash

nohup ./watch-and-restart.sh > ~/watcher.log 2>&1 &
```

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1749926147711/4d870197-91b6-4721-9270-df9e385b2a9e.png align="center")

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