Setting Up a Web Filtering Server with Unbound and RPZ on Linux
- Last updated: Jun 21, 2025
I was looking for a reliable way to implement web filtering on GNU/Linux. While tools like SquidGuard can be used for this purpose, 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 offers web filtering capabilities using RPZ (Response Policy Zone) — https://en.wikipedia.org/. This led me to explore RPZ-based filtering further and develop a working solution using Unbound and RPZ on Linux.
- This solution provides the following features:
- Blocks access to a predefined list of forbidden websites
- Redirects users to a custom block page when access is denied
Network Diagram
In this setup, a Debian server functions as both a DNS resolver and a web server. When a client attempts to access a forbidden website (based on a predefined block list), the request is intercepted and redirected to the web server, which displays a custom blocked access page in the user's browser.
- Prerequisites:
- Block port 53 (both UDP and TCP) on your gateway to prevent workstations from using external DNS servers directly.

Debian Server
As outlined above, our Debian server will run two key services: a web server to display a notification page when users attempt to access blocked websites, and a DNS resolver to deliver either standard or modified DNS responses based on filtering rules. To meet these requirements, we'll use micro-httpd, a lightweight and minimalist HTTP server, and Unbound as the DNS resolver supporting RPZ-based filtering.
micro-httpd
To notify users when access to a website is blocked, we need a lightweight web server capable of serving a simple "access forbidden" page. For this purpose, we'll use micro-httpd, which is minimal yet sufficient for displaying static messages.
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 the file
/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.html
to serve as the block page:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Access Forbidden</title>
</head>
<body>
<h1>Access Forbidden</h1>
<p>Sorry, but you do not have permission to access this page.</p>
</body>
</html>
root@host:~# chown -R www-data:www-data /var/www/html
- If needed, you can use the
systemctl
command to restart the micro-httpd socket:
root@host:~# systemctl restart micro-httpd.socket
Open a web browser and navigate to http://192.168.0.200/
to verify that the block page is being displayed correctly.
Unbound
Installation
- Install the unbound package:
root@host:~# apt install unbound
Configuration
- Create the configuration file
/etc/unbound/unbound.conf.d/rpz.conf
with the following content:
server:
module-config: "respip validator iterator" # Load respip, validator, and iterator modules. Needed for rpz
interface: 192.168.0.200 # Network interface used for DNS queries
interface: 127.0.0.1 # Loopback interface used for local DNS queries
do-ip4: yes # Enable IPv4 support
do-ip6: no # Disable IPv6 support
do-udp: yes # Enable UDP support for DNS queries
do-tcp: yes # Enable TCP support for DNS queries
do-daemonize: yes # Run Unbound as a daemon (in the background)
access-control: 0.0.0.0/0 allow # Allow all IP addresses to make DNS queries
local-zone: "std.priv." static # Define a local zone for DNS queries
local-data: "denied.std.priv. IN A 192.168.0.200" # Define a local DNS entry for a specific domain
local-data-ptr: "192.168.0.200 denied.std.priv." # Define a local DNS PTR entry for a specific IP address
rpz:
name: rpz.std.rocks # RPZ zone name
zonefile: /etc/unbound/blacklist.zone # RPZ zone file used for DNS query filtering
- Create the file
/etc/unbound/blacklist.zone
. For testing purposes, we'll redirect all DNS requests fororange.fr
andgoogle.fr
to our block page IP:
*.orange.fr IN A 192.168.0.200
*.google.fr IN A 192.168.0.200
- Restart the unbound service to apply the changes:
root@host:~# systemctl restart unbound
Workstation
- From your workstation, open a web browser and try accessing
www.google.fr
. You should be redirected to the custom block page:

- You can also verify DNS redirection using
nslookup
or a similar tool. Bothwww.google.fr
andorange.fr
should resolve to192.168.0.200
:

Downloading and Applying a Block List
Now that the web filtering system is in place, we can make it truly 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 project: https://github.com/hagezi/dns-blocklists.
- These RPZ lists typically use the following format:
website.to.block CNAME .
- For compatibility with our setup, the entries must be converted to this format:
website.to.block IN A 192.168.0.200
So, how do we translate the default format into a format that is useful in our architecture, as shown above? There are various approaches, but personally, I'll be using the sed editor. Let's see how it's done!
- First, download one of the RPZ lists:
root@host:~# wget https://raw.githubusercontent.com/hagezi/dns-blocklists/main/rpz/multi.txt
- Then convert the list into the required format, with the
sed
stream editor:
root@host:~# sed -i 's/CNAME.*/IN A 192.168.0.200/' multi.txt
Note: To prevent users from circumventing DNS filtering, it's highly recommended to also block DoH (DNS-over-HTTPS), VPN, TOR, and proxy endpoints. You can find a list of public DoH IPs here: https://github.com/crypt0rr/public-doh-servers.
Go Further
To make the filtering system more flexible, you can use access control to apply different RPZ policies to specific networks or individual hosts. This allows for differentiated filtering levels based on IP ranges.
- Edit the configuration file
/etc/unbound/unbound.conf.d/rpz.conf
to define tagged access controls and associate them with specific RPZ zones:
server:
module-config: "respip validator iterator" # Load respip, validator, and iterator modules
interface: 192.168.0.200 # Network interface used for DNS queries
interface: 127.0.0.1 # Loopback interface used for local DNS queries
do-ip4: yes # Enable IPv4 support
do-ip6: no # Disable IPv6 support
do-udp: yes # Enable UDP support for DNS queries
do-tcp: yes # Enable TCP support for DNS queries
do-daemonize: yes # Run Unbound as a daemon (in the background)
access-control: 0.0.0.0/0 allow # Allow all IP addresses to make DNS queries
local-zone: "std.priv." static # Define a local zone for DNS queries
local-data: "denied.std.priv. IN A 192.168.0.200" # Define a local DNS entry for a specific domain
local-data-ptr: "192.168.0.200 denied.std.priv." # Define a local DNS PTR entry for a specific IP address
define-tag: "social adult dnsbypass"
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 # RPZ zone name
zonefile: /var/lib/unbound/social_networks/blacklist.zone
tags: "social"
rpz:
name: rpz.adult.std.rocks # RPZ zone name
zonefile: /var/lib/unbound/adult/blacklist.zone
tags: "adult"
rpz:
name: rpz.dnsbypass.std.rocks # RPZ zone name
zonefile: /var/lib/unbound/dns_bypass/blacklist.zone
tags: "dnsbypass"
In this example:
- The subnet
192.168.10.0/24
receives all three filters:social
,adult
, anddnsbypass
. - The specific host
192.168.10.200
is assigned onlysocial
andadult
filters. - The subnet
192.168.20.0/24
receivesadult
anddnsbypass
filters.
This modular approach allows precise control over what is filtered for each segment of your network.
Troubleshooting
When using RPZ lists containing hundreds of thousands of entries, the Unbound service may fail to start. This is usually due to the service taking too long to load the configuration and being automatically stopped by systemd
due to a timeout.
- Example of the error message 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.
- (Optional) Set your preferred text editor. For example, to use
vim
:
root@host:~# export EDITOR=vim
- Edit the
unbound.service
override configuration:
root@host:~# systemctl edit unbound.service
- Add the following lines to increase the startup and shutdown timeout limits:
### 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
- The unbound service should now be able to start without error:
root@host:~# systemctl restart unbound