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 dnsutilsSchritt 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 versionsha256sum -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/blockyBlocky 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
EOFKurz 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. |
ProtectHomePrivateTmpPrivateDevicesProtectKernel*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.yamlblocky validate muss Configuration is valid melden!
Hier nochmal was die wichtigsten Zeilen tuen:
blocking.denylists: unsere zwei Gruppen,adsist HaGeZi Pro,securityist 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: 53lauscht auf allen Interfaces (auch dem WireGuard-Interface später),ports.http: 127.0.0.1:4000bindet 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-pagerStatus 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_entriesDie 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 +shortexample.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 versioncaddy 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> AKommt 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-pagerIm 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 +shortDie 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-toolsJetzt 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.confumask 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 showwg 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 ansiutf8Scan 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 +shortDie 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> Anichts 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 showkeinen Handshake zeigt, fehlt sehr wahrscheinlich der Port51820/UDPin deiner Hetzner Cloud Firewall. Trag den Port ein und prüf mitwg showerneut.
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
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:
- Blocky-Update: neues Binary nach
/usr/local/bin/blocky, dannsudo systemctl restart blocky. - Weitere HaGeZi-Listen und Formate: https://github.com/hagezi/dns-blocklists
- Domain zu Unrecht geblockt? Trag sie in die
allowlistsderselben Gruppe in/etc/blocky/config.yamlein und starte Blocky neu. - Statistiken und Query-Log im Detail: https://0xerr0r.github.io/blocky/latest/