What you will set up
Every website, app and update check starts with a DNS lookup, and whoever answers them sees a list of everything your devices connect to. Usually that is your internet provider, or a public resolver such as Cloudflare's or Google's.
Here, your own server answers instead. Unbound looks names up itself, directly at the servers responsible for each domain, and checks DNSSEC signatures, so forged answers are refused. Each of those servers only sees lookups for its own domains, coming from your server's address rather than from your devices.
Unbound answers the server itself and the devices on your WireGuard VPN, and nobody else. That matters: a resolver that answers anyone gets abused to flood other networks with traffic.
Every step below was run on a Melonslab server with Debian 13, both with Debian's package and in a rootless container, with a WireGuard device resolving names through the tunnel before and after a reboot, and lookups from the internet refused.
Package or container
Our other app guides run in rootless Podman. For Unbound, Debian's package is the simpler choice: it already runs Unbound as its own unprivileged user once it has opened port 53, and security fixes arrive with your regular apt updates. If you would rather keep it in a container too, follow the container steps instead of steps 1 and 2.
Before you start
You need a server with WireGuard set up as in the WireGuard guide, with the tunnel address 10.8.0.1.
1. Install Unbound
apt update
apt install -y unbound bind9-dnsutils
Unbound starts straight away, answering only the server itself. Debian sets it up to validate DNSSEC, and keeps its root key current. bind9-dnsutils provides dig, for testing.
2. Answer the VPN
Create /etc/unbound/unbound.conf.d/vpn.conf:
server:
# Answer the server itself and devices on the VPN, nobody else
interface: 127.0.0.1
interface: ::1
interface: 10.8.0.1
ip-freebind: yes
access-control: 10.8.0.0/24 allow
# Give away as little as possible
hide-identity: yes
hide-version: yes
qname-minimisation: yes
# Answer faster from what is already known
aggressive-nsec: yes
prefetch: yes
Check it and restart Unbound:
unbound-checkconf
systemctl restart unbound
Unbound now listens on the loopback addresses and on the tunnel address 10.8.0.1, never on your server's public addresses. ip-freebind lets it listen on 10.8.0.1 at boot even before WireGuard has created it. access-control lets the VPN's addresses in; the server itself is allowed by default, and everyone else is refused.
qname-minimisation asks each server only for as much of a name as it needs: the servers for .com are asked about example.com, not www.example.com.
3. Test it on the server
dig @127.0.0.1 example.com
The answer's flags line should include ad, which means Unbound checked the DNSSEC signatures. A domain with deliberately broken signatures should fail:
dig @127.0.0.1 dnssec-failed.org
It should report status: SERVFAIL.
4. Use it from your devices
In each device's WireGuard configuration, from step 7 of the WireGuard guide, change the DNS line to:
DNS = 10.8.0.1
Load the new configuration on the device again: show it as a QR code with qrencode -t ansiutf8 < /etc/wireguard/phone.conf and scan it, or import the file on a laptop. The one address covers IPv6 as well: the question travels over IPv4 inside the tunnel, and the answers include IPv6 addresses.
To check that your server looks names up by itself, run this on a laptop connected to the VPN:
dig +short TXT proto.on.quad9.net @10.8.0.1
It prints nothing. Quad9, a DNS provider, only answers this name for lookups that go through its own resolvers: asked directly, with @9.9.9.9 in place of @10.8.0.1, it prints "do53-udp". The empty answer means the lookup did not pass through Quad9: your server found the answer itself.
5. Check that it is not open
From a computer outside the VPN, not the server:
dig @203.0.113.10 example.com
Replace 203.0.113.10 with your server's public address. The answer should be "connection refused", with no reply to the question.
Troubleshooting
Devices lose DNS when the VPN is connected. Check the DNS = 10.8.0.1 line, and that systemctl status unbound shows it running. ss -lnup | grep :53 should list 10.8.0.1:53. If you use ufw as in the security guide, it needs ufw allow in on wg0.
unbound-checkconf reports an error. It names the file and the line with the problem. Fix it before you restart: Unbound keeps running on its current configuration until then, and a restart with a broken one takes it down.
A site stops loading, and dig returns SERVFAIL for it. Usually the domain's DNSSEC signatures are broken, and Unbound refuses answers it cannot verify. That protects you from forged answers; the domain's owner needs to fix it.
In a rootless container instead
NLnet Labs, who make Unbound, do not publish a container image. Alpine Linux's infrastructure team does: docker.io/alpinelinux/unbound is Alpine's own Unbound package on a minimal Alpine, rebuilt regularly from its source. If you pick another image, check that it is still updated: mvance/unbound, the most downloaded one, has not been since October 2024.
These steps replace steps 1 and 2. If you already installed Debian's package, switch it off first with systemctl disable --now unbound.
As root, let ordinary users use port 53, and bind to the tunnel address before it exists:
cat > /etc/sysctl.d/50-unprivileged-ports.conf <<EOF
net.ipv4.ip_unprivileged_port_start = 53
net.ipv4.ip_nonlocal_bind = 1
EOF
sysctl --system
This replaces the file from the Podman guide, and 53 covers ports 80 and 443 as well. The container publishes its port on 10.8.0.1 only, and at boot it may start before WireGuard has created that address; ip_nonlocal_bind lets it bind anyway.
Create a user for it, and switch to it:
useradd -m -s /bin/bash dns
loginctl enable-linger dns
machinectl shell dns@
Create ~/unbound.conf, which is Unbound's whole configuration here:
server:
# Inside the container. Only the VPN address is published (see unbound.container).
interface: 0.0.0.0
access-control: 10.8.0.0/24 allow
auto-trust-anchor-file: /etc/unbound/root.key
use-syslog: no
# The container cannot raise the send buffer, so use the system default
so-sndbuf: 0
# Give away as little as possible
hide-identity: yes
hide-version: yes
qname-minimisation: yes
# Answer faster from what is already known
aggressive-nsec: yes
prefetch: yes
And ~/.config/containers/systemd/unbound.container, after mkdir -p ~/.config/containers/systemd:
[Unit]
Description=Unbound, a private DNS resolver for the VPN
[Container]
ContainerName=unbound
Image=docker.io/alpinelinux/unbound:latest
Volume=%h/unbound.conf:/etc/unbound/unbound.conf:ro
PublishPort=10.8.0.1:53:53/udp
PublishPort=10.8.0.1:53:53/tcp
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
Start it, check the configuration, and switch on Podman's daily updates:
systemctl --user daemon-reload
systemctl --user start unbound
podman exec unbound unbound-checkconf
systemctl --user enable --now podman-auto-update.timer
The container fetches the current DNSSEC root key every time it starts. Each device's real address reaches it, so access-control works as in step 2: a query from any address outside the VPN is refused.
It answers only on 10.8.0.1, not on the server's loopback address, so test it on the server with dig -b 10.8.0.1 @10.8.0.1 example.com, and carry on from step 4. If the container does not start and its log says "Failed to bind port 53", one of the two settings above is missing: "Permission denied" points at the first, "Cannot assign requested address" at the second.
Private DNS on the VPN you run
With Unbound on a Melonslab server, your devices' DNS lookups are answered in Sweden, by a server only you use.