Setting up your own DNS server Unbound and ad blocker Pi-hole in docker
Greetings!

As you might have guessed from the title, today we will install and run in docker a local DNS server Unbound paired with blackjack.. an ad blocker via DNS requests — Pi-hole. All management of this setup will be done through the convenient web GUI of the Pi-hole program. And at the end of the article I’ll show how to connect this local DNS to our VPN server based on OpenConnect, which we set up earlier. It will be interesting and not difficult, almost)

All actions were tested in Debian 12 and Ubuntu 22.04 distribution environments.

Introduction

Unbound is an Open source product from NLnet Labs, representing a validating, recursive, and caching DNS server. Distributed under the BSD license.

Pi-hole is a Linux application for blocking ads and internet trackers at the network level, which acts as a DNS sinkhole and, optionally, as a DHCP server. Usually used in private networks. It’s also open source and distributed under the EUPL license.

Those already familiar with Pi-hole know that it also has a lightweight DNS server in its arsenal: Dnsmasq. But it doesn’t support recursive DNS queries. That is, it’s initially designed to forward requests to a third-party, recursive DNS server, which in our case will be Unbound, to get data about unknown addresses. But Dnsmasq is capable of caching responses and managing local DNS records.

Here’s a rough diagram of the interaction:

This DNS workflow greatly increases the level of privacy, since the initial resolving is performed through the root servers directly and is then cached on your own server.

However, it’s worth noting that this scheme has an obvious drawback in the form of a delay when performing recursive queries. You can speed up the process by setting up forwarding to the nearest third-party public DNS server. This option is also viable, especially if you encrypt requests using TLS. But in that case you will have to trust the chosen public DNS resolver. It’s up to you.

Please note that this article is not intended to cover all the intricacies of how the domain name system works, and in particular the Unbound server. We are simply deploying personal services 😉. It is assumed that you have a minimal understanding of DNS technology.

Preparation

To deploy the services we need a Docker engine installed. If it’s not installed yet, here’s the guide: Installing Docker engine on a Linux server running Debian

Now let’s perform some preparatory steps:

BASH
sudo -s

addgroup --system --gid 14956 pihole

adduser --system --gecos 'Unbound and Pihole DNS service' \
    --disabled-password --uid 14956 --ingroup pihole \
    --shell /sbin/nologin --home /opt/pihole/data pihole

cd /opt/pihole
Click to expand and view more

BASH
docker network create \
    --opt com.docker.network.bridge.name=br_vpn \
    --driver bridge --subnet 10.10.11.0/24 vpn_network

docker network ls
Click to expand and view more

As you can see, we created a network with addressing 10.10.11.0/24, named vpn_network, and with the name of the virtual network bridge device — br_vpn:

At this stage the preparation is complete, let’s move on to creating the docker container-services description file.

Creating docker-compose.yml

While in the /opt/pihole directory, open a new file for editing with any console editor you prefer. My readers know that I prefer Vim/Neovim for this.

I can’t help but mention that I have a series of articles about this editor on my site. You’ll find them at the old oak via 😉

BASH
vim docker-compose.yml
Click to expand and view more

And insert the following content into it:

YAML
---

networks:
  vpn_network:
    external: true

services:

  unbound:
    image: r4venme/unbound:1.17
    container_name: unbound
    restart: on-failure
    stop_grace_period: 30s
    # healthcheck:
    #   disable: true
    deploy: &default_deploy
      resources:
        limits:
          cpus: '0.70'
          memory: 512M
        reservations:
          cpus: '0.2'
          memory: 256M
    logging: &default_logging
      driver: json-file
      options:
        max-size: "50m"
        max-file: "5"
    cap_add:
      - NET_ADMIN
    hostname: "unbound"
    environment:
      TZ: "Europe/Moscow"
      PUID: 14956
      PGID: 14956
    volumes:
      - "./data/unbound/:/etc/unbound/"
    expose:
      - "53/udp"
      - "53/tcp"
    # ports:
    #   - "53:53/udp"
    #   - "53:53/tcp"
    networks:
      vpn_network:
        ipv4_address: 10.10.11.200
        aliases:
          - unbound-server

  pihole:
    # depends_on: [unbound]
    container_name: pihole
    image: pihole/pihole:2025.03.0
    restart: on-failure
    stop_grace_period: 30s
    deploy: *default_deploy
    logging: *default_logging
    cap_add:
      - NET_ADMIN
      # - SYS_TIME
      # - SYS_NICE
    hostname: pihole
    dns:
      - 10.10.11.200
      - 10.10.11.200
    environment:
      TZ: "Europe/Moscow"
      PIHOLE_UID: 14956
      PIHOLE_GID: 14956
      FTLCONF_webserver_api_password: 'pihole_password'
      FTLCONF_dns_listeningMode: 'all'
      FTLCONF_dns_upstreams: 10.10.11.200;10.10.11.200
    volumes:
      - "./data/pihole/:/etc/pihole/"
      # - "./data/dnsmasq/:/etc/dnsmasq.d/"
    expose:
      - "53/tcp"
      - "53/udp"
      - "80/tcp"
    ports:
      - "127.0.0.1:8081:80"
      # - "53:53/tcp"
      # - "53:53/udp"
      # - "80:80/tcp"
      # - "443:443/tcp"
      # - "67:67/udp"
      # - "123:123/udp"
    networks:
      vpn_network:
        ipv4_address: 10.10.11.100
        aliases:
          - pihole-dns
Click to expand and view more

Note that the containers described in the docker-compose.yml file are deliberately limited in hardware resource usage to cpus: '0.70' and memory: 512M, i.e. the maximum allowed CPU usage is 70% of one core and 512 MB of RAM + reserve. Explicit limits are also set for storing container logs: 5 files of 50 MB each per container. If necessary, adjust these parameters according to your needs. Read more about service resource limits when using docker compose here, and about logging here.

The current version of the docker-compose.yml file is also available on GitHub.

Let’s briefly go through the contents.

Just in case, I’ll leave a link to the reference for compose file contents here and at the end of the article.

Oh yes, one more thing. The FTLCONF_webserver_api_password: 'pihole_password' variable sets the admin password when logging into the pihole GUI. If you leave it empty, the password will be generated automatically.

Now let’s save the file and move on to starting the containers.

Running Pihole + Unbound

In the project directory, let’s start our containers with this command:

BASH
docker compose up -d && docker compose logs -f
Click to expand and view more

You should see the startup status of Pi-hole and the operation of the name resolution process passing through Unbound:

If everything started correctly, exit the container output viewing mode by pressing Ctrl+c.

To view the status of the running containers, let’s use the command:

BASH
docker compose ps
Click to expand and view more

The services are running, let’s continue.

A bit about Unbound configuration

An attentive reader may have noticed that the compose file uses my Unbound image, the build of which I’ll describe in a separate article.

On the first launch of the Unbound container, files necessary for its operation are created in the /opt/pihole/data/unbound directory:

The following files are not used by default and are provided as examples. They can be included via the include directive in the main unbound.conf config:

For example, to set up forwarding of all requests to a third-party server, include the forward-records.conf file in the unbound.conf file:

YAML
include: "/etc/unbound/forward-records.conf"
Click to expand and view more

In which set up the forwarding. Below is an example of forwarding to opennameserver.org using TLS encryption (DOT):

YAML
forward-zone:
    name: "."
    forward-tls-upstream: yes
    forward-addr: 217.160.70.42@853#ns1.opennameserver.org
    forward-addr: 213.202.211.221@853#ns2.opennameserver.org
Click to expand and view more

To apply the settings you need to restart the Unbound container:

YAML
docker compose restart unbound
Click to expand and view more

Checking DNS operation from the local host

You can check DNS operation with an explicit resolver specified using the dig utility from the dnsutils package:

BASH
# install dig
apt update && apt install dnsutils

# request via pihole
dig @10.10.11.100 r4ven.me +short +answer +identify

# request via unbound
dig @10.10.11.200 r4ven.me +short +answer +identify
Click to expand and view more

Pay attention to the response time of the first request and the subsequent one: this is the DNS caching mechanism at work.

In the Unbound logs you should see requests from the Pihole container (10.10.11.100) and our direct request from the host, which goes to Unbound (10.10.11.200) through the bridge (10.10.11.1) connection we created earlier:

If you enable a verbose logging level for Unbound (the verbosity: 3 parameter in unbound.conf), then on initial requests you’ll see how recursive requests from Unbound to the root DNS servers happen.

I mentioned earlier that the Unbound configuration used in this article supports DNSSEC, which ensures the authenticity of received responses (if the domain has a corresponding signature) by verifying their signature with the root.key key.

You can check that Unbound uses DNSSEC by executing a request with the dig command directly through the Unbound container. The response should have the ad (authenticated data) flag:

BASH
# no dnssec (should add it)
dig @10.10.11.200 r4ven.me +answer +identify +dnssec | grep ';; flags'
# will output nothing
dig @10.10.11.200 r4ven.me +short DNSKEY

# has a dnssec signature
dig @10.10.11.200 opennameserver.org +answer +identify +dnssec | grep ';; flags'
# will output the signature key
dig @10.10.11.200 +short DNSKEY opennameserver.org
Click to expand and view more

Note that DNSSEC does not imply encryption of transmitted data. That’s the responsibility of the DNS over TLS (DOT) or HTTPS (DOH) transmission mechanism.

Checking DNS operation from external hosts

If you need to send requests to your DNS server from outside, for example from the local network, uncomment these lines for the pihole service in docker-compose.yml:

YAML
...
ports:
  - "53:53/tcp"
  - "53:53/udp"
...
Click to expand and view more

And restart the services:

BASH
docker compose down

docker compose up -d
Click to expand and view more

Now your DNS server can be reached by the available IP of your host. In my example, the host on which Unbound+Pihole is deployed is accessible from the local network at 192.168.122.192:

BASH
# request via pihole
dig @192.168.122.192 r4ven.me +short +answer +identify
Click to expand and view more

Everything works 👌.

If your system running Pihole + Unbound uses systemd-resolved, you most likely won’t be able to bind port 53, since it’s already being listened on the local interface: 127.0.0.1:53. In that case, the pihole container won’t start. We have two options here:

  1. disable systemd-resolved;
  2. change the local listening IP address to an external one and redirect systemd-resolved to our pihole and unbound containers — let’s consider this option.

Let’s open the config:

BASH
vim /etc/systemd/resolved.conf
Click to expand and view more

And change the following parameters:

PLAINTEXT
[Resolve]
DNS=10.10.11.100
FallbackDNS=10.10.11.200
DNSStubListenerExtra=192.168.122.192
Click to expand and view more

Where:

Let’s restart the systemd-resolved service and check whether the address changed:

BASH
systemctl restart systemd-resolved

ss -tuln | grep 53
Click to expand and view more

If you have a firewall installed on your server, you need to open access to port 53.

Let’s also check DNS resolving from the outside:

BASH
dig @192.168.122.192 r4ven.me +short +answer +identify
Click to expand and view more

There’s a response.

Closer to the end of the article we’ll look at ways to configure DNS on clients. But for now let’s set up autostart of our compose file from the pihole service user using the Systemd initialization system.

Setting up autostart with Systemd

We’ll run services on behalf of the service account, to which we’ll grant the ability to run two strictly defined commands on behalf of the privileged docker group.

Let’s create the service unit file:

BASH
vim /etc/systemd/system/pihole.service
Click to expand and view more

And fill it in:

PLAINTEXT
[Unit]
Description=Pihole and Unbound DNS service
Requires=docker.service
After=docker.service

[Service]
Restart=on-failure
RestartSec=5
User=pihole
Group=pihole
ExecStart=/usr/bin/sudo --group=docker /usr/bin/docker compose -f /opt/pihole/docker-compose.yml up
ExecStop=/usr/bin/sudo --group=docker /usr/bin/docker compose -f /opt/pihole/docker-compose.yml down

[Install]
WantedBy=multi-user.target
Click to expand and view more

Now let’s create a file with the list of restricted sudoers permissions:

BASH
visudo -f /etc/sudoers.d/90_pihole
Click to expand and view more

The visudo command allows you to safely edit sudoers files: in case of a syntax error, it will display a warning and won’t let you save the file.

And fill it in:

BASH
Cmnd_Alias PIHOLE_DOCKER = \
    /usr/bin/docker compose -f /opt/pihole/docker-compose.yml up, \
    /usr/bin/docker compose -f /opt/pihole/docker-compose.yml down

pihole ALL = (:docker) NOPASSWD: PIHOLE_DOCKER
Click to expand and view more

For a better understanding of how sudo and sudoers files work, I recommend reading my article: Linux command line, privilege escalation: su, sudo commands 😌.

Let’s stop the manually started services and enable {auto}start already via Systemd:

BASH
docker compose down

systemctl enable --now pihole

systemctl status pihole

docker compose ps
Click to expand and view more

Looks like everything’s working 👍 well done us, let’s move on to the Pihole web GUI.

Connecting to the Pihole web GUI

If you deployed the DNS server on the same machine you’re working from, simply go to http://localhost/admin.

If your DNS server is remote, given that our GUI listens on port 80 on the local port of the server, you need to set up port forwarding using SSH. This is done for secure access to the web interface.

On the client machine, open a terminal and run:

BASH
# forwarding the port
ssh -4 -f -N -L 8081:localhost:8081 user@example.com
Click to expand and view more

Where:

And let’s check:

BASH
ss -tln | grep 8081
Click to expand and view more

In my example the port forwarding command is:

Now let’s open a browser and go to: http://localhost:8081/admin

Enter the password set in the FTLCONF_webserver_api_password variable, in my example this is pihole_password:

And we end up in our web GUI:

The pihole interface, as they say, is intuitive. In the context of the article, we’re interested in local DNS records and ad domain blocklists.

Local DNS records

DNS records are located in the Local DNS section 😉. Let’s add a few new ones for testing:

And let’s immediately check the name resolution:

BASH
dig @10.10.11.100 pihole.r4ven.me +short +answer +identify

dig @10.10.11.100 unbound.r4ven.me +short +answer +identify
Click to expand and view more

Ad domain blocklists

Lists of domains to block are set in the Adlists section. They are a link to a text file with the list itself in hosts format: ip_address domain_name

Blocking is achieved by simply redirecting the unwanted domain name to a stub address 0.0.0.0. Everything ingenious is simple)

As you can see in the screenshot, one public list is already added in Pi-hole. You can easily add your own here:

Or in the /opt/pihole/data/pihole/adlists.list file.

Let’s move on to configuring DNS on clients.

Setting up DNS on clients

In the Linux world there are many ways to configure DNS on clients, which only confuses many users. I’ll cover just the most popular ways.

The resolv.conf file

The most classic way to configure DNS in Linux is the resolv.conf file. Let’s open it for editing:

BASH
vim /etc/resolv.conf
Click to expand and view more

And add a record. It’s important that it’s above the already existing nameserver parameters:

BASH
nameserver 192.168.122.192
Click to expand and view more

Save, close. The settings are picked up on the fly.

Systemd-resolved

In many modern Linux distributions, DNS management is done using one of the systemd modules — systemd-resolved, which we’ve already discussed.

To edit DNS parameters, let’s open the configuration file:

BASH
vim /etc/systemd/resolved.conf
Click to expand and view more

And in the Resolve block change (or add) the DNS= directive:

BASH
[Resolve]
DNS=192.168.122.192
Click to expand and view more

Save and restart the service + clear the DNS cache:

BASH
systemctl restart systemd-resolved

resolvectl flush-caches
Click to expand and view more

From experience, in the case of systemd-resolved, it’s preferable to reboot the entire system)

BASH
reboot
Click to expand and view more

Also, for compatibility with applications that don’t use library calls but access the DNS server directly, it’s recommended to create such a symlink (with a preliminary backup of the resolv.conf file):

BASH
mv /etc/resolv.conf{,.backup}

ln -sv /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf
Click to expand and view more

NetworkManager

On desktop Linux systems, the network, including DNS parameters, is managed by NetworkManager. Here you’ll have to do some clicking around.

Let’s go into network connections via the desktop applet or through the application menu:

Next select our main physical connection:

Go to the IPv4 settings tab and set the DNS server address:

Done)

Final check

To check the correctness of the DNS settings in the case of resolve.conf and NetworkManager, execute:

BASH
nslookup r4ven.me

dig r4ven.me +short +answer +identify
Click to expand and view more

The name resolves through the local DNS, exactly what’s needed.

And in the case of systemd-resolved the commands are:

BASH
resolvectl dns

resolvectl query r4ven.me
Click to expand and view more

Excellent.

Now let’s check the DNS records we added earlier in the Pi-hole web interface:

BASH
ping -c3 unbound.r4ven.me

ping -c3 pihole.r4ven.me
Click to expand and view more

On the left, the host on which pihole + unbound is deployed, on the right, a host from the local network.

As we can see, names resolve correctly on different clients, even if they are unreachable 😌.

Now let’s move on to setting up DNS parameters on the VPN server (if you have one).

Setting up DNS in the OpenConnect (ocserv) config

If you configured ocserv according to my article, then after adding a DNS server to the diagram, it will look like this:

It’s assumed that openconnect and pihole + unbound are deployed on the same machine. So, to set up the pairing, the first thing to do is add to the openconnect project’s compose file:

BASH
vim /opt/openconnect/docker-compose.yml
Click to expand and view more

Network definition:

BASH
networks:
  vpn_network:
    external: true
Click to expand and view more

And to the openconnect container parameters, add the networks: directive

BASH
...
networks:
  - vpn_network
...
Click to expand and view more

Now we need to edit the config file of the ocserv server itself:

BASH
vim /opt/openconnect/data/ocserv.conf
Click to expand and view more

Adding the value of the dns parameter (the first one):

The tunnel-all-dns parameter must be true.

After that, you need to reload the config or restart the OpenConnect service by any method:

BASH
# reloading the config
docker exec -it openconnect occtl reload

# restarting the container
docker restart openconnect

# or
systemctl restart openconnect
Click to expand and view more

And also reconnect on the clients and that’s it. Now all DNS requests from ocserv clients will go through the local resolver, which in turn will query the root servers directly and build its own cache.

Conclusion

So we’ve set up our local DNS server based on Unbound + Pi-hole. We’ve also configured clients to use it. Additionally, we set the DNS server address in the OpenConnect server config, thereby getting a complete VPN solution.

Of course, we haven’t covered all the capabilities of this stack. But as I said, that wasn’t the goal. Pi-hole has many interesting features out of the box, such as a DHCP server configurable in the web GUI, and so on. But that’s beyond the scope of this article. You’ll find all documentation sources below.

Subscribe to our Telegram channel @r4ven_me to not miss new posts on the Raven Blog website)

And if you still have questions, join our chat at the same place: @r4ven_me_chat. I try to always answer there, in my free time.

Thanks for reading! Good luck with your name resolving 😉

Useful sources

Comments

Question from the Telegram chat: “What’s the difference between dnsmasq and unbound?”

Comment from Telegram from Dmitry:

Comment from Telegram:

Copyright Notice

Author: Ivan Cherniy

Link: https://r4ven.me/en/networking/podnimaem-svoj-dns-server-unbound-i-blokirovshhik-reklamy-pihole-v-docker/

License: CC BY-NC-SA 4.0

Blog materials may be used with attribution to the author and source, for non-commercial purposes, and under the same license.

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut