Get Rewarded! We will reward you with up to €50 credit on your account for every tutorial that you write and we publish!

Blocky als DNS-Filter mit Caddy-DoH und WireGuard auf Debian installieren

profile picture
Author
Nikita Siegle
Published
2026-11-03
Time to read
13 minutes reading time

Einführung

In diesem Tutorial installieren und richten wir die DNS-Software "Blocky" auf einem Hetzner Cloud Server mit Debian 13 (trixie) ein. Danach stellen wir Blockys DNS-over-HTTPS-Endpunkt (DoH) über Caddy bereit und zum Schluss erstellen wir einen WireGuard-Tunnel, damit man auch unterwegs den DNS Server verwenden kann.

Wenn du eine containerbasierte Alternative zum Werbeblocken zu Hause suchst, schau dir gern das Pi-hole-Tutorial von Miroslav Minárik an.

Getestet mit diesen Versionen:

Komponente Version
Debian 13.7 (trixie)
Blocky v0.35.0
Caddy v2.11.7
wireguard-tools v1.0.20210914

Voraussetzungen

  • Ein aktivierter Hetzner Account
  • Ein Server mindestens vom Typ "CX23" mit Debian 13 (trixie)
  • Eine öffentliche IPv4-Adresse
  • Root-Zugang per SSH
  • Optional, aber empfohlen: Eine (Sub)Domain, deren A- und AAAA-Record auf die Server-IP zeigt.

Ohne Domain: Du lässt Schritt 7 (Caddy) einfach weg. Blocky läuft dann trotzdem und filtert weiter, du erreichst den DNS-Server aber nur über den WireGuard-Tunnel aus Schritt 8, DoH ohne Tunnel ist ohne Domain nicht möglich.

Beispiel-Benennungen

  • Server-IPv4: <10.0.0.1>
  • Server-IPv6: <2001:db8:1234::1>
  • (Sub)Domain: <doh.example.com>
  • Mail für Let's Encrypt: <mail@example.com>
  • WireGuard-Netz: 10.66.0.0/24

Werte in spitzen Klammern wie <10.0.0.1> sind Platzhalter. Ersetz sie durch deine echten Werte, die Klammern kommen weg.

Ports

Port Protokoll Zweck Von außen offen?
53 UDP + TCP DNS für deine Geräte nein, nur über den WireGuard-Tunnel erreichbar
80 TCP Caddy: Let's-Encrypt-Challenge und HTTP-zu-HTTPS-Redirect ja
443 TCP + UDP Caddy: HTTPS/DoH (HTTP/2 und HTTP/3) ja
51820 UDP WireGuard ja
4000 TCP Blocky-API und Metriken nein, lauscht nur auf 127.0.0.1

Port 53 darf nie aus dem Internet erreichbar sein. Wie du die Firewall einrichtest, steht in Schritt 1.

Schritt 1 - Hetzner Cloud Firewall einrichten

Ein neuer Hetzner Cloud Server hat standardmäßig keine Firewall, alle Ports sind also aus dem Internet erreichbar. Da Blocky auf Port 53 auf allen Interfaces lauscht, wäre der Server nach Schritt 6 ein offener DNS-Resolver, der sehr schnell für Amplification-Angriffe missbraucht wird. Deshalb legen wir zuerst eine Firewall an und öffnen nur die Ports, die wir wirklich brauchen.

Erstelle in der Hetzner Console unter "Firewalls" eine Firewall und trag diese eingehenden Regeln ein:

Port Protokoll Zweck
22 TCP SSH
80 TCP Caddy: Let's-Encrypt-Challenge und HTTP-zu-HTTPS-Redirect
443 TCP + UDP Caddy: HTTPS/DoH (HTTP/2 und HTTP/3)
51820 UDP WireGuard

Port 53 bleibt geschlossen. Geräte erreichen Blocky entweder über den WireGuard-Tunnel oder über den DoH-Endpunkt auf Port 443. Lass die ausgehenden Regeln auf "allow all", damit der Server die Blocklisten laden und Zertifikate beziehen kann.

Zum Schluss weist du die Firewall deinem Server zu. Schränk bei Port 22 die "Sources" nach Möglichkeit auf deine eigene IP ein.

Schritt 2 - Server vorbereiten

Damit alles up-to-Date ist updaten wir den Server und installieren noch ein paar Tools, die wir benötigen:

sudo apt update -y && sudo apt upgrade -y && sudo apt install -y curl ca-certificates dnsutils

Schritt 3 - Blocky-Binary installieren

Für Blocky gibt es kein offizielles Debian-Paket, deswegen laden wir die Binary direkt aus dem GitHub Release. In dem folgenden Codeblock wird automatisch die verwendete CPU (x86/ARM) erkannt und das richtige Binary gedownloaded. Zusätzlich wird gecheckt, ob der Download echt ist und nichts beschädigt ist.

cd /tmp
case "$(uname -m)" in
  x86_64)  BARCH="Linux_x86_64" ;;
  aarch64) BARCH="Linux_arm64" ;;
  *) echo "Nicht unterstützte Architektur: $(uname -m)" ;;
esac
VER="v0.35.0"
curl -fsSL -O "https://github.com/0xERR0R/blocky/releases/download/${VER}/blocky_${VER}_${BARCH}.tar.gz"
curl -fsSL -O "https://github.com/0xERR0R/blocky/releases/download/${VER}/blocky_checksums.txt"
sha256sum -c --ignore-missing blocky_checksums.txt
tar -xzf "blocky_${VER}_${BARCH}.tar.gz"
sudo install -m 0755 blocky /usr/local/bin/blocky
blocky version

sha256sum -c muss OK melden, sonst ist der Download kaputt! blocky version zeigt am Ende die Version und die passende Architektur.

Blocky sollte nicht als root laufen, weil dies ein Risiko darstellen kann. Deswegen erstellen wir einen Systembenutzer samt der Verzeichnisse, die Blocky benötigt.

sudo useradd --system --no-create-home --shell /usr/sbin/nologin blocky
sudo mkdir -p /etc/blocky /var/cache/blocky/lists /var/log/blocky
sudo chown -R blocky:blocky /var/cache/blocky /var/log/blocky

Blocky braucht die Capability CAP_NET_BIND_SERVICE, um als Nicht-Root auf Port 53 zu lauschen. Wir setzen sie nicht per setcap, weil die systemd-Unit NoNewPrivileges=true verwendet und damit Datei-Capabilities beim Start verwirft. Stattdessen vergibt systemd die Capability als Ambient-Capability, mehr im nächsten Schritt.

Schritt 4 - systemd-Unit mit Hardening anlegen

Ein fertiges Unit-Paket gibt es nicht, also müssen wir eine eigene schreiben. Hier ist eine Vorlage, die direkt per Command eingefügt werden kann.

sudo tee /etc/systemd/system/blocky.service > /dev/null <<'EOF'
[Unit]
Description=Blocky DNS Server
Documentation=https://0xerr0r.github.io/blocky/
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=blocky
Group=blocky
WorkingDirectory=/etc/blocky
ExecStart=/usr/local/bin/blocky --config /etc/blocky/config.yaml
Restart=on-failure
RestartSec=5s

NoNewPrivileges=true
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK
RestrictNamespaces=true
LockPersonality=true
RestrictRealtime=true
SystemCallArchitectures=native
ReadWritePaths=/var/cache/blocky /var/log/blocky

[Install]
WantedBy=multi-user.target
EOF

Kurz zu den wichtigsten Zeilen:

Beschreibung
User/Group=blocky Kein Root.
NoNewPrivileges=true Der Prozess kann zur Laufzeit keine zusätzlichen Rechte mehr bekommen. Das wirft auch Datei-Capabilities beim Start raus, deshalb verwenden wir kein setcap und systemd vergibt NET_BIND_SERVICE stattdessen als Ambient-Capability.
ProtectSystem=strict Das Dateisystem wird read-only gemountet, schreiben darf Blocky nur nach ReadWritePaths.
ProtectHome
PrivateTmp
PrivateDevices
ProtectKernel*
ProtectControlGroups
Eine übliche Isolation für einen Netzwerkdienst, der weder Kernel noch Home anfassen muss.
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK Nur IPv4, IPv6 und AF_NETLINK.

Schritt 5 - Blocklisten wählen und config.yaml schreiben

Als Upstreams nehmen wir verschlüsselte DNS-over-TLS-Resolver (DoT) wie Cloudflare, Quad9 und Digitalcourage. Blocky fragt zwei davon parallel ab und nimmt die schnellste Antwort.

Bei den Blocklisten bleiben wir bei einem Anbieter: "HaGeZi". Fünf Listen von fünf Projekten bringen uns kaum etwas, weil sie sich stark überschneiden, und doppelte Einträge kosten nur RAM und Traffic. "HaGeZi Pro" deckt Werbung, Tracker, Malware und Scams ab, tif.medium ergänzt frische Phishing- und Malware-Domains.

Ganz ohne Fehler geht es nicht. Z. B. blockt HaGeZi Pro die Domain p.typekit.net, den Font-CDN von Adobe Fonts, deshalb steht die Domain in der Allowlist, die Vorrang vor der Denylist hat.

Als Blocktyp nehmen wir zeroIp: blockierte Domains antworten dann mit 0.0.0.0. refused verwenden wir nicht, weil viele Clients dann einfach den nächsten DNS-Server fragen und somit der Filter umgangen wird.

sudo tee /etc/blocky/config.yaml > /dev/null <<'EOF'
# yaml-language-server: $schema=https://raw.githubusercontent.com/0xERR0R/blocky/main/docs/config.schema.json

upstreams:
  init:
    strategy: failOnError
  groups:
    default:
      - tcp-tls:1.1.1.1:853#cloudflare-dns.com
      - tcp-tls:9.9.9.9:853#dns.quad9.net
      - tcp-tls:5.9.164.112:853#dns3.digitalcourage.de
  strategy: parallel_best
  timeout: 3s

bootstrapDns:
  - https://1.1.1.1/dns-query

blocking:
  denylists:
    ads:
      - https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/pro.txt
    security:
      - https://raw.githubusercontent.com/hagezi/dns-blocklists/main/wildcard/tif.medium.txt
  allowlists:
    ads:
      - |
        p.typekit.net
  clientGroupsBlock:
    default:
      - ads
      - security
  blockType: zeroIp
  blockTTL: 1m
  loading:
    refreshPeriod: 24h
    strategy: blocking
    concurrency: 4
    downloads:
      timeout: 60s
      attempts: 5
      cachePath: /var/cache/blocky/lists

caching:
  minTime: 5m
  maxTime: 30m

ports:
  dns: 53
  http: 127.0.0.1:4000
  dohPath: /dns-query

prometheus:
  enable: true

queryLog:
  type: sqlite
  target: /var/log/blocky/querylog.db
  logRetentionDays: 7

log:
  level: info
  format: text
EOF
blocky validate --config /etc/blocky/config.yaml

blocky validate muss Configuration is valid melden!

Hier nochmal was die wichtigsten Zeilen tuen:

  • blocking.denylists: unsere zwei Gruppen, ads ist HaGeZi Pro, security ist HaGeZi TIF medium.
  • clientGroupsBlock.default: alle Clients bekommen beide Gruppen. Ohne dieses Mapping würden die Listen erst gar nicht greifen!
  • loading.downloads.cachePath: Listen werden auf Platte zwischengespeichert. Ist GitHub beim Start mal nicht erreichbar, nimmt Blocky die letzte Version.
  • ports.dns: 53 lauscht auf allen Interfaces (auch dem WireGuard-Interface später), ports.http: 127.0.0.1:4000 bindet API, Metriken und DoH-Endpunkt nur lokal, Caddy erreicht ihn trotzdem, weil beide auf demselben Host laufen.
  • queryLog.type: sqlite: schreibt jede Anfrage in /var/log/blocky/querylog.db, damit du später nachschauen kannst, was geblockt oder erlaubt wurde.

Schritt 6 - Blocky starten und testen

Blocky starten und Status prüfen:

sudo systemctl daemon-reload
sudo systemctl enable --now blocky
systemctl status blocky --no-pager

Status MUSS active (running) zeigen. Blocky lädt beim Start dann die beiden Listen, das kann etwas dauern (meist max. 1 Min.). Checke mit diesem Command, ob sie wirklich geladen sind.

curl -s http://127.0.0.1:4000/metrics | grep blocky_denylist_cache_entries

Die Ausgabe zeigt, wie viele Einträge pro Gruppe geladen sind (typischerweise mehrere hunderttausend). Die genauen Zahlen hängen vom aktuellen Stand der HaGeZi-Listen ab und ändern sich mit jedem Listen-Update, deine Werte werden also abweichen. Wichtig ist nur, dass beide Gruppen auftauchen:

blocky_denylist_cache_entries{group="ads"} <number>
blocky_denylist_cache_entries{group="security"} <number>

Jetzt der eigentliche Test, drei Anfragen hintereinander:

dig @127.0.0.1 example.com A +short
dig @127.0.0.1 googlesyndication.com A +short
dig @127.0.0.1 p.typekit.net A +short

example.com liefert echte IPs, googlesyndication.com liefert 0.0.0.0 (der Filter greift), p.typekit.net liefert wieder echte IPs (die Allowlist greift). Bei p.typekit.net kann die Antwort zusätzlich CNAME-Records enthalten.

Schritt 7 - Caddy als Reverse Proxy für DoH

Blocky kann DoH auch selbst liefern, aber dann musst du dich komplett selbst um ein eigenes Zertifikat kümmern und es dann auch immer selbst wieder erneuern. Mit Caddy, was wir verwenden, wird diese Aufgabe automatisch erledigt. Blocky bleibt dann auf 127.0.0.1:4000 beschränkt und nur Caddy redet nach außen.

Caddy installieren und Version prüfen:

sudo apt install -y debian-archive-keyring apt-transport-https gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
caddy version

caddy version zeigt die installierte Version (siehe Tabelle oben). Bevor es weitergeht, muss der A- und AAAA-Record von <doh.example.com> auf die IPv4- und IPv6-Adresse deines Servers zeigen! Nutzt du Cloudflare als DNS-Anbieter, muss der Eintrag auf "DNS only" stehen (graue Wolke, nicht proxied), sonst bekommt Caddy kein Zertifikat. Check das kurz:

dig +short <doh.example.com> A

Kommt nichts zurück, ist der Record noch nicht aktiv und der Test weiter unten scheitert. Warte in dem Fall ein paar Minuten und prüf erneut.

Jetzt das Caddyfile, wieder per Heredoc (überschreibt die Datei komplett, kein manuelles Löschen nötig):

sudo tee /etc/caddy/Caddyfile > /dev/null <<'EOF'
{
	email <mail@example.com>
}

<doh.example.com> {
	reverse_proxy 127.0.0.1:4000 {
		header_up -Forwarded
	}
}
EOF
caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddy
systemctl status caddy --no-pager

Im oberen Block musst du deine Kontakt-Mail für Let's Encrypt setzen, damit dir ein kostenloses SSL-Zertifikat erstellt werden kann.

Wenn du damit fertig bist, testen wir mit kdig (Knot-DNS-Utils), ob DoH funktioniert.

sudo apt install -y knot-dnsutils
kdig @<doh.example.com> +https example.com A +short
kdig @<doh.example.com> +https googlesyndication.com A +short

Die erste Anfrage sollte eine echte IP liefern, die zweite dann wiederum 0.0.0.0. Damit wird bewiesen, dass die DNS-Anfrage verschlüsselt über Caddy läuft und Blocky sie filtert. Dieselbe URL https://<doh.example.com>/dns-query trägst du in Firefox, Chrome oder direkt in den DNS-Einstellungen von Android und iOS ein. Beachte, dass jeder, der diese URL kennt, den Endpunkt ebenfalls nutzen kann, er ist also praktisch ein öffentlicher Dienst.

Schritt 8 - WireGuard einrichten

Für unterwegs bauen wir einen WireGuard-Tunnel, damit du immer den DNS verwenden kannst. Das Kernel-Modul steckt bereits im Debian-Kernel, das Tool-Paket reicht.

Tool-Paket installieren:

sudo apt install -y wireguard-tools

Jetzt erzeugen wir Schlüsselpaare, um direkt beide Konfigurationsdateien per Heredoc mit den Schlüsseln zu befüllen.

umask 077
SERVER_KEY=$(wg genkey)
SERVER_PUB=$(echo "$SERVER_KEY" | wg pubkey)
CLIENT_KEY=$(wg genkey)
CLIENT_PUB=$(echo "$CLIENT_KEY" | wg pubkey)

sudo tee /etc/wireguard/wg0.conf > /dev/null <<EOF
[Interface]
Address = 10.66.0.1/24
ListenPort = 51820
PrivateKey = $SERVER_KEY

[Peer]
PublicKey = $CLIENT_PUB
AllowedIPs = 10.66.0.2/32
EOF

sudo tee /etc/wireguard/client1.conf > /dev/null <<EOF
[Interface]
Address = 10.66.0.2/24
PrivateKey = $CLIENT_KEY
DNS = 10.66.0.1

[Peer]
PublicKey = $SERVER_PUB
Endpoint = <10.0.0.1>:51820
AllowedIPs = 10.66.0.0/24
PersistentKeepalive = 25
EOF

sudo chmod 600 /etc/wireguard/wg0.conf /etc/wireguard/client1.conf

umask 077 bleibt für den Rest der Session aktiv, neu erstellte Dateien sind also nur für root lesbar.

Ersetz <10.0.0.1> im Endpoint durch die echte IP deines Servers.

Server aktivieren:

systemctl enable --now wg-quick@wg0
wg show

wg show zeigt das Interface wg0 und den Peer mit allowed ips: 10.66.0.2/32. Der Handshake kommt erst zustande, wenn sich der Client verbindet.

Dank der DNS-Zeile (DNS = 10.66.0.1) werden nur DNS-Anfragen zum Server gesendet, aber dein eigentlicher Traffic (z.B. das Laden von Webseiten) geht dran vorbei und wird durch das verbundene WLAN oder Mobilfunk gesendet.

Aufs Handy bekommst du die Config am einfachsten per QR-Code:

sudo apt install -y qrencode
sudo cat /etc/wireguard/client1.conf | qrencode -t ansiutf8

Scan den Code in der WireGuard-App über "Tunnel hinzufügen" und "Aus QR-Code scannen".

Für den Test vom Client aus (wenn du dafür die Werkzeuge hast), drei Commands:

ping -c 2 10.66.0.1
dig @10.66.0.1 example.com A +short
dig @10.66.0.1 googlesyndication.com A +short

Die erste dig-Anfrage liefert echte IPs, die zweite 0.0.0.0. Sobald DNS = 10.66.0.1 im Profil aktiv ist, läuft jede DNS-Anfrage automatisch durch deinen Filter, auch im fremden WLAN.

Troubleshooting

  • Wenn beim Command dig +short <doh.example.com> A nichts zurückkommt, ist der Record noch nicht aktiv. Warte ein paar Minuten und prüf erneut.
  • Wenn Caddy kein Zertifikat bekommt und du Cloudflare als DNS-Anbieter verwendest, musst du den Eintrag auf "DNS only" stellen (graue Wolke, nicht proxied), sonst bekommt Caddy kein Zertifikat.
  • Wenn wg show keinen Handshake zeigt, fehlt sehr wahrscheinlich der Port 51820/UDP in deiner Hetzner Cloud Firewall. Trag den Port ein und prüf mit wg show erneut.

Wie gut filtert das wirklich?

Ein gutes Tool zum checken wie viele Domains geblocked werden ist superadblocktest.com. Stand 19.09.2026 hat dieses Setup dort 65 % erreicht: 362 von 482 geprüften Werbe- und Tracker-Anfragen wurden blockiert, 120 kamen durch.

Ergebnis von superadblocktest.com: 65 Prozent, 362 blockiert, 120 durchgekommen

Ergebnis

Blocky läuft jetzt als gehärteter systemd-Dienst auf Port 53, er filtert über die HaGeZi-Listen und schreibt die Anfragen in eine SQLite-Datenbank. Caddy stellt nun den DoH-Endpunkt unter https://<doh.example.com>/dns-query mit automatischem Let's-Encrypt-Zertifikat bereit. Über den WireGuard-Tunnel nutzt du denselben Filter auch von unterwegs, und die Hetzner Cloud Firewall gibt nur die Ports frei, die du wirklich brauchst.

Nächste Schritte:

License: MIT
Want to contribute?

Get Rewarded: Get up to €50 in credit! Be a part of the community and contribute. Do it for the money. Do it for the bragging rights. And do it to teach others!

Report Issue
Try Hetzner Cloud

Get €20/$20 free credit!

Valid until: 31 December 2026 Valid for: 3 months and only for new customers
Get started
Want to contribute?

Get Rewarded: Get up to €50 credit on your account for every tutorial you write and we publish!

Find out more