Set Up DNS Web Filtering with Unbound and RPZ Block Lists on Linux
- Last updated: Sep 13, 2026
I was looking for a reliable way to implement DNS web filtering on GNU/Linux. While tools like SquidGuard can be used for web filtering, I found them overly complex to configure and difficult to deploy automatically across multiple workstations, especially in environments not managed by an Active Directory domain. During my research, I discovered the DynFi Open Source firewall (https://dynfi.com), which provides DNS-based filtering capabilities using RPZ (Response Policy Zone) — https://en.wikipedia.org/. This led me to explore RPZ-based filtering further and build a working solution using Unbound and RPZ on Linux.
- This solution provides the following features:
- Blocks access to domains contained in a predefined DNS block list
- Redirects blocked domains to a local web server, allowing a custom block page to be displayed for plain HTTP connections
- Supports large RPZ block lists and different filtering policies depending on the client network
Network Diagram
In this setup, a Debian server functions as both a DNS resolver and a web server. When a client attempts to resolve a domain contained in the predefined block list, Unbound returns the IP address of the local web server instead of the real destination. For plain HTTP connections, the web server can then display a custom blocked access page in the user's browser.
Debian Server
As outlined above, the Debian server runs two key services: a web server that can display a notification page for blocked domains over plain HTTP, and a DNS resolver that returns either standard or modified DNS responses according to the filtering rules. To implement this setup, we'll use micro-httpd, a lightweight and minimalist HTTP server, and Unbound as the DNS resolver with RPZ-based filtering support.
micro-httpd
To notify users when a domain is blocked, we need a lightweight web server capable of serving a simple "access forbidden" page. For plain HTTP connections, blocked domains are redirected to this local web server, which displays the notification page. For this purpose, we'll use micro-httpd, a minimal HTTP server that is well suited for serving a simple static page.
Installation
- Install the micro-httpd package:
root@host:~# apt install micro-httpd
Configuration
- The micro-httpd systemd service configuration is located in
/lib/systemd/system/micro-httpd@.service:
[Unit]
Description=micro-httpd
Documentation=man:micro-httpd(8)
[Service]
User=nobody
Group=www-data
ExecStart=-/usr/sbin/micro-httpd /var/www/html
StandardInput=socket
- The socket configuration is defined in
/lib/systemd/system/micro-httpd.socket:
[Unit]
Description=micro-httpd
Documentation=man:micro-httpd(8)
[Socket]
ListenStream=0.0.0.0:80
Accept=true
[Install]
WantedBy=sockets.target
- Create a simple HTML file at
/var/www/html/index.htmlto serve as the custom block page:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Access Forbidden</title>
</head>
<body>
<h1>Access Forbidden</h1>
<p>Access to this domain has been blocked by the network filtering policy.</p>
</body>
</html>
root@host:~# chown -R www-data:www-data /var/www/html
- If needed, restart the micro-httpd socket to apply the changes:
root@host:~# systemctl restart micro-httpd.socket
Open a web browser and navigate to http://192.168.0.200/ to verify that the custom block page is displayed correctly.
Unbound
Unbound is the core component of our DNS web-filtering setup. It acts as the DNS resolver and can return modified responses for blocked domain names according to predefined filtering rules. Because client devices use this server for DNS resolution, Unbound can block, redirect, or modify DNS responses through RPZ policies, making it a central control point for domain-level filtering. In this section, we will configure Unbound to operate both as a standard recursive DNS resolver and as the engine that enforces our web-filtering policies.
Installation
- Install the Unbound package:
root@host:~# apt install unbound
Configuration
- Create the configuration file
/etc/unbound/unbound.conf.d/rpz.confwith the following content:
server:
module-config: "respip validator iterator" # Load modules required for RPZ processing
interface: 192.168.0.200 # Listen for DNS queries on the LAN interface
interface: 127.0.0.1 # Listen for local DNS queries
do-ip4: yes # Enable IPv4
do-ip6: no # Disable IPv6 if it is not used on your network
do-udp: yes # Enable DNS over UDP
do-tcp: yes # Enable DNS over TCP
access-control: 0.0.0.0/0 allow # Allow DNS queries from any IPv4 address that can reach the server
# More restrictive configuration (recommended in production):
# access-control: 127.0.0.0/8 allow
# access-control: 192.168.0.0/24 allow
# access-control: 0.0.0.0/0 refuse
rpz:
name: rpz.std.rocks
zonefile: /etc/unbound/blacklist.zone
- Create the RPZ zone file
/etc/unbound/blacklist.zone. For testing purposes, we'll redirectorange.fr,google.fr, and their subdomains to the local block page IP address:
$TTL 3600
@ IN SOA localhost. root.localhost. (
1 ; Serial
3600 ; Refresh
900 ; Retry
604800 ; Expire
3600 ; Minimum TTL
)
@ IN NS localhost.
orange.fr IN A 192.168.0.200
*.orange.fr IN A 192.168.0.200
google.fr IN A 192.168.0.200
*.google.fr IN A 192.168.0.200
- Check the Unbound configuration for syntax errors:
root@host:~# unbound-checkconf
- If no errors are reported, restart the Unbound service to apply the changes:
root@host:~# systemctl restart unbound
Workstation
- From your workstation, open a web browser and access
http://www.google.fr. Because the DNS response points to the local web server, the custom block page should be displayed:
- You can also verify the DNS filtering directly using
nslookupor a similar tool. Bothwww.google.frandorange.frshould resolve to the local block page IP address192.168.0.200:
Downloading and Applying a Block List
Now that the DNS web-filtering system is in place, we can make it more effective by applying a real-world block list. Numerous public RPZ lists are available online. For this example, we'll use one from the HaGeZi DNS Blocklists project: https://github.com/hagezi/dns-blocklists.
- HaGeZi RPZ entries typically use the following format:
website.to.block CNAME .
- For our local block-page setup, these entries need to be converted to the following format:
website.to.block IN A 192.168.0.200
There are several ways to perform this conversion. In this example, we'll use the sed stream editor.
- First, download the HaGeZi RPZ block list:
root@host:~# wget https://raw.githubusercontent.com/hagezi/dns-blocklists/main/rpz/multi.txt
- Then replace the default
CNAME .RPZ action with anArecord pointing to the local web server:
root@host:~# sed -i 's/CNAME.*/IN A 192.168.0.200/' multi.txt
Go Further
To make the filtering system more flexible, you can use client tags to apply different RPZ policies to specific networks or individual hosts. This allows you to define different filtering levels depending on the source IP address of each DNS client.
- Edit the configuration file
/etc/unbound/unbound.conf.d/rpz.confto define tags, assign them to specific clients or networks, and associate each tag with the corresponding RPZ zone:
server:
module-config: "respip validator iterator" # Load modules required for RPZ processing
interface: 192.168.0.200 # Listen for DNS queries on the LAN interface
interface: 127.0.0.1 # Listen for local DNS queries
do-ip4: yes # Enable IPv4
do-ip6: no # Disable IPv6 if it is not used on your network
do-udp: yes # Enable DNS over UDP
do-tcp: yes # Enable DNS over TCP
access-control: 0.0.0.0/0 allow # Allow DNS queries from any IPv4 host that can reach the server
# More restrictive configuration (recommended in production):
# access-control: 127.0.0.0/8 allow
# access-control: 192.168.0.0/24 allow
# access-control: 192.168.10.0/24 allow
# access-control: 192.168.20.0/24 allow
# access-control: 0.0.0.0/0 refuse
# Define filtering tags
define-tag: "social adult dnsbypass"
# Assign tags to specific networks or hosts
access-control-tag: 192.168.10.0/24 "social adult dnsbypass"
access-control-tag: 192.168.10.200/32 "social adult"
access-control-tag: 192.168.20.0/24 "adult dnsbypass"
rpz:
name: rpz.social.std.rocks
zonefile: /var/lib/unbound/social_networks/blacklist.zone
tags: "social"
rpz:
name: rpz.adult.std.rocks
zonefile: /var/lib/unbound/adult/blacklist.zone
tags: "adult"
rpz:
name: rpz.dnsbypass.std.rocks
zonefile: /var/lib/unbound/dns_bypass/blacklist.zone
tags: "dnsbypass"
In this example:
- The subnet
192.168.10.0/24receives thesocial,adult, anddnsbypassfilters. - The host
192.168.10.200/32receives only thesocialandadultfilters. - The subnet
192.168.20.0/24receives theadultanddnsbypassfilters.
This approach allows you to apply different DNS filtering policies to individual hosts or entire network segments while using the same Unbound resolver.
Performance Optimization
Depending on the number of users, the DNS traffic pattern, or the size of your block list, Unbound may experience higher latency or dropped queries during traffic spikes. In this section, we will review several statistics and configuration options that can help improve performance.
- Start by checking the current performance using the
unbound-controlcommand:
root@host:~# unbound-control stats_noreset | grep -E "total.num.queries|total.recursion.time.avg|total.requestlist.avg|cache|requestlist.avg|requestlist.max|requestlist.exceeded"
thread0.num.cachehits=29387234
thread0.num.cachemiss=25838180
thread0.requestlist.avg=2.6
thread0.requestlist.max=518
thread0.requestlist.exceeded=251851
total.num.queries=55225414
total.num.queries_ip_ratelimited=0
total.num.cachehits=29387234
total.num.cachemiss=25838180
total.requestlist.avg=2.62538
total.recursion.time.avg=0.047619
These statistics provide useful information about how efficiently your Unbound instance is handling DNS requests.
The total.num.cachehits value shows how many queries were answered directly from cache, while total.num.cachemiss represents queries that were not available in cache and therefore required further recursive processing. In this example, approximately 53% of queries were answered from cache. Whether this ratio can be improved depends on several factors, including the DNS traffic pattern, record TTL values, and the amount of memory allocated to the cache.
The total.recursion.time.avg value is approximately 48 ms. This represents the average time spent resolving queries that required recursive processing, which is already a good result.
The request list statistics are particularly useful for identifying temporary overload conditions:
thread0.requestlist.avg: average number of pending recursive queries. In this example, a value of2.6is low and does not indicate continuous overload.thread0.requestlist.max: highest number of pending recursive queries observed at one time. The value of518shows that the resolver experienced significantly higher concurrency during traffic peaks, but this value alone does not necessarily indicate a problem.thread0.requestlist.exceeded: number of queries that could not be added to the request list because its capacity was exceeded. The value of251851confirms that the resolver was unable to process all incoming queries during some periods of high load.
In this case, the server does not appear to be continuously overloaded, but the non-zero requestlist.exceeded value confirms that its request-processing capacity was exceeded at some point. This may occur during temporary traffic spikes or when the resolver is not configured to handle enough concurrent queries.
Another important observation is that only thread0 appears in the statistics, while the server used in this example has four CPU cores. Increasing the number of Unbound worker threads therefore provides a clear optimization opportunity.
- Based on these observations, you can tune the configuration by editing
/etc/unbound/unbound.conf.d/rpz.confand adding the following directives underserver:. Adjust the values according to your hardware and workload; in this example, the server has a 4-core CPU and 8 GB of RAM:
server:
# Use the available CPU cores
num-threads: 4
# Increase DNS cache capacity
msg-cache-size: 512m
rrset-cache-size: 1g
# Improve UDP query distribution between worker threads
so-reuseport: yes
# Increase the UDP receive buffer to better absorb traffic spikes
# The operating-system socket buffer limits may also need to be increased
so-rcvbuf: 4m
# Refresh frequently requested records before they expire
prefetch: yes
# Prefetch DNSSEC key material when DNSSEC validation is enabled
prefetch-key: yes
# Increase recursive-query concurrency for high-load environments
# Verify that the Unbound build and OS file descriptor limits support these values
outgoing-range: 8192
num-queries-per-thread: 4096
# Improve DNS availability when authoritative servers are temporarily unavailable
serve-expired: yes
# Optional: keep very short-lived records slightly longer to reduce recursion
# Avoid excessively high values because this overrides authoritative TTL values
# cache-min-ttl: 300
After applying the changes, restart Unbound so that all tuning parameters are fully reloaded:
root@host:~# systemctl restart unbound
Then reset the statistics counters. The unbound-control stats command displays the current statistics and resets the counters immediately afterward:
root@host:~# unbound-control stats
After several hours of normal usage, check the statistics again:
root@host:~# unbound-control stats_noreset | grep -E "total.num.queries|total.recursion.time.avg|total.requestlist.avg|cache|requestlist.avg|requestlist.max|requestlist.exceeded"
Pay particular attention to requestlist.exceeded. Ideally, this value should remain at 0 or close to it. You should also verify that total.recursion.time.avg remains stable or decreases, and compare the cache hit ratio before and after the changes to determine whether the larger cache and prefetch settings provide a measurable benefit.
Troubleshooting
When using RPZ lists containing hundreds of thousands of entries, Unbound may take significantly longer to start because the RPZ zone must be parsed and loaded into memory. In some cases, systemd may stop the service if the startup process exceeds the configured timeout.
- Example of a timeout error when starting Unbound:
root@host:~# systemctl restart unbound
Job for unbound.service failed because a timeout was exceeded.
See "systemctl status unbound.service" and "journalctl -xeu unbound.service" for details.
- First, verify the Unbound configuration and RPZ syntax:
root@host:~# unbound-checkconf
- If the configuration is valid, check the service logs to confirm whether the failure is actually caused by a startup timeout:
root@host:~# journalctl -u unbound.service -b
- (Optional) Set your preferred text editor. For example, to use
vim:
root@host:~# export EDITOR=vim
- If the logs confirm a startup timeout, edit the
unbound.serviceoverride configuration:
root@host:~# systemctl edit unbound.service
- Add the following lines to increase the Unbound startup timeout:
### Editing /etc/systemd/system/unbound.service.d/override.conf
### Anything between here and the comment below will become the new contents of the file
[Service]
TimeoutStartSec=300
TimeoutStopSec=300
### Lines below this comment will be discarded
### /lib/systemd/system/unbound.service
# [Unit]
# Description=Unbound DNS server
# Documentation=man:unbound(8)
# After=network.target
# Before=nss-lookup.target
# Wants=nss-lookup.target
#
# [Service]
# Type=notify
# Restart=on-failure
# EnvironmentFile=-/etc/default/unbound
# ExecStartPre=-/usr/libexec/unbound-helper chroot_setup
# ExecStartPre=-/usr/libexec/unbound-helper root_trust_anchor_update
# ExecStart=/usr/sbin/unbound -d -p $DAEMON_OPTS
# ExecStopPost=-/usr/libexec/unbound-helper chroot_teardown
# ExecReload=+/bin/kill -HUP $MAINPID
#
# [Install]
# WantedBy=multi-user.target
- If the failure was caused by the startup timeout, the Unbound service should now be able to start successfully:
root@host:~# systemctl restart unbound