rss logo

Set Up DNS Web Filtering with Unbound and RPZ Block Lists on Linux

Unbound Logo

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.

💡 Note: The Pi-hole project provides a similar DNS-based filtering mechanism using large block lists and includes a web-based administration interface.

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.

💡 Recommended: On your gateway, block outbound TCP/UDP port 53 for client networks, while allowing the Unbound server itself to perform DNS queries. This prevents workstations from bypassing the local resolver by querying external DNS servers directly.

🚨 Note: For HTTPS websites, the browser will generally display a TLS certificate error instead of the custom block page because the local web server cannot provide a valid certificate for the blocked third-party domain. This is an inherent limitation of DNS-based redirection.

Network diagram showing how Unbound DNS filtering redirects a blocked domain to a local web server instead of resolving the real destination
Unbound DNS filtering process showing how a blocked domain is redirected to a local web server instead of being resolved to its real destination.

Debian Server

Debian Logo

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.html to 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.conf with 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

🚨 Security: Using access-control: 0.0.0.0/0 allow makes Unbound available to any IPv4 host that can reach the server. In production, restrict access to trusted networks only to avoid exposing an open recursive DNS resolver.

💡 Note: DNS filtering operates at the domain name level. It cannot inspect or filter individual URLs, paths, or page content such as example.com/category/page.html.

  • Create the RPZ zone file /etc/unbound/blacklist.zone. For testing purposes, we'll redirect orange.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:
Firefox displaying the custom Access Forbidden page after a blocked domain is redirected to the local web server
Custom block page displayed in Firefox after Unbound redirects a blocked domain to the local web server over HTTP.

🚨 Note: This test uses plain HTTP. If you try to access https://www.google.fr, the browser will normally display a TLS certificate error instead of the custom block page because the local server does not have a valid certificate for www.google.fr.

  • You can also verify the DNS filtering directly using nslookup or a similar tool. Both www.google.fr and orange.fr should resolve to the local block page IP address 192.168.0.200:
Windows nslookup output showing blocked domains resolving to the local IP address 192.168.0.200
DNS filtering verification with nslookup, showing blocked domains resolving to the local block page IP address 192.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.

💡 Note: HaGeZi provides an RPZ format compatible with Unbound. By default, blocked domains use the CNAME . RPZ action, which causes matching DNS queries to return an NXDOMAIN response. In our setup, we want blocked domains to resolve to the local web server instead, so we will replace this action with an A record pointing to 192.168.0.200.

  • 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 an A record pointing to the local web server:
root@host:~# sed -i 's/CNAME.*/IN A 192.168.0.200/' multi.txt

💡 Note: Blocking outbound TCP/UDP port 53 prevents clients from using traditional external DNS resolvers. You may also block DNS-over-TLS (DoT) and DNS-over-QUIC (DoQ), which commonly use port 853. Preventing DNS-over-HTTPS (DoH) is more difficult because it uses standard HTTPS traffic on port 443. Endpoint block lists and managed browser policies can help reduce this type of bypass. You can find a list of public DoH servers here: https://github.com/crypt0rr/public-doh-servers.

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.conf to 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/24 receives the social, adult, and dnsbypass filters.
  • The host 192.168.10.200/32 receives only the social and adult filters.
  • The subnet 192.168.20.0/24 receives the adult and dnsbypass filters.

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-control command:
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 of 2.6 is low and does not indicate continuous overload.
  • thread0.requestlist.max: highest number of pending recursive queries observed at one time. The value of 518 shows 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 of 251851 confirms 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.conf and adding the following directives under server:. 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

💡 Note: The configured cache sizes do not represent the total amount of memory that Unbound may consume. Internal data structures and allocation overhead increase actual memory usage, so cache values should always be adjusted according to the amount of RAM available on the server.

💡 Note: The outgoing-range and num-queries-per-thread directives control how many recursive queries Unbound can process concurrently. Values such as 8192 and 4096 are intended for high-load environments and should not be increased unnecessarily. Make sure that your Unbound build and operating-system file descriptor limits support them.

💡 Note: Setting so-rcvbuf: 4m may require increasing the operating-system maximum receive buffer size. On Linux, check the current limit with sysctl net.core.rmem_max.

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.service override 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