Ferrite

Ferrite

Docker app from syntlyx's Repository

Overview

Ferrite is a self-hosted DNS filter and router for the home network. It blocks ads, trackers and malware for every device; applies per-device profiles — blocked services such as TikTok or Roblox, weekly schedules, SafeSearch and YouTube Restricted Mode, default-deny; and sends chosen domains, services or devices through built-in WireGuard (kernel-backed for line-rate throughput where the host allows it, in-process userspace otherwise), SOCKS5/Tor or a DPI-evasion path. Encrypted upstreams (DoH/DoT/DoQ), a live query log and per-device activity, notifications to ntfy, Telegram, Discord or a webhook, and a REST API — one Rust binary, configured from its web panel.

ferrite

A self-hosted DNS filter and router for the home network — one Rust binary.

ferrite answers DNS for every device in the house. It blocks ads, trackers and malware for all of them, applies stricter rules to the devices that need them — a kid's tablet, a games console, the IoT corner — and sends the traffic you choose through WireGuard, SOCKS5 or Tor. Its panel shows what every device is doing and why each name was allowed, blocked or routed.

It runs as one process without root on anything from a Raspberry Pi to a NAS, and keeps everything it learns on that box.

What it does

Filtering for the whole network. Subscribe to blocklists and allowlists in hosts, Adblock or plain-domain format (a curated catalog is built in, or paste any URL); they compile into one compact lookup. Add your own block and allow entries — names, wildcards or regular expressions — block whole services network-wide, and turn on SafeSearch and YouTube Restricted Mode. Every verdict can be explained: which list, rule or service matched, and what exempted it.

Rules per device. Profiles apply to chosen devices by MAC or IP:

  • block services from a catalog of 43 — TikTok, Roblox, YouTube, Discord, Fortnite and more, in seven categories — with one click each;
  • run on a weekly schedule ("school nights, 21:30–07:00");
  • force SafeSearch and YouTube Restricted Mode;
  • use a subset of the lists, plus their own block and allow entries;
  • or flip to default-deny: only what is allowed resolves, while local names keep working.

Any device can be paused for a few minutes or exempted from filtering.

Routing through tunnels. Send a domain, a whole service or everything a device does through an egress: built-in WireGuard (paste a .conf; the kernel does the crypto when ferrite has CAP_NET_ADMIN, an in-process userspace tunnel otherwise — no TUN device either way), SOCKS5 (point it at Tor), or direct with TLS ClientHello fragmentation to get past DPI. Rules can be scoped to devices and fail closed when a tunnel is down. Devices on encrypted DNS can be routed away from home too, with no VPN app: their routed names point at the box's public address, which lets in only your devices, and only for routed names. ferrite reads the SNI or Host and never terminates TLS. Routing decides how traffic leaves; blocking still decides whether a name resolves.

Seeing what happens. A live dashboard, a query log with device names, and a side panel for any domain or device: its verdict and why, a day of activity, the services behind it, the profile that governs it. Notifications go to ntfy, Telegram, Discord, Slack, Gotify or a JSON webhook when a tunnel stays down or a device the box has never seen joins the network.

Encrypted DNS for your devices. Android's Private DNS (DNS over TLS) and iPhones, Macs and browsers (DNS over HTTPS) can use ferrite at home and away. Each device connects under its own name, so it keeps its profile wherever it is. Give it a Cloudflare token and ferrite gets the certificate from Let's Encrypt and renews it, with no port open for that; an iPhone or Mac sets itself up from a profile, from a QR code in the panel or by opening the panel's /setup page at home, with no password. DNS over HTTPS speaks HTTP/3 too, shares 443 with Tunnels, or sits behind a reverse proxy such as Nginx Proxy Manager, and from outside the house only the devices you list are answered.

Keeping devices on the filter. Firefox is kept from switching to its own DNS over HTTPS, and iCloud Private Relay and Encrypted Client Hello can be turned off for the network. For routers that won't hand out another DNS server, ferrite can run DHCP itself (off by default), and every device it gives an address shows up under its own name.

Private DNS. Plain, DoT, DoH and DoQ upstreams in one pool with failover; an upstream's queries can themselves ride a tunnel. EDNS Client Subnet is stripped, DNSSEC is requested, local zones can be forwarded to your router, and custom A / AAAA / CNAME records (exact or wildcard) answer first.

Two resolvers, one setup. A second ferrite can follow the first: it takes the chosen parts of its configuration — filtering, records, names, DNS settings — every few minutes, so devices given both resolvers get the same answers from either, and you edit in one place.

Coming from Pi-hole or AdGuard Home? Settings → System reads a Pi-hole v6 Teleporter export or an AdGuardHome.yaml and shows what comes over — lists, rules, local records, groups and per-device settings, blocked services — and what can't, before anything changes.

Automation. Everything the panel does is a REST API. Prometheus metrics at /api/metrics, config export and import, self-update from the panel, and an MCP endpoint so an AI assistant can ask why a site is blocked — or, if you allow it, block one.

Small and fast. ~60 MB RSS on an ARM board serving 30 clients off a 1.8M-domain blocklist, sub-2 ms cache and block decisions, tens of thousands of queries per second. Static musl binaries for x86_64 and aarch64, a Docker image, and an Unraid template.

Screenshots

Images live in the web UI repo.

Dashboard Profiles Device panel Tunnels Encrypted DNS

Privacy

ferrite is a privacy tool first. What that means concretely:

  • No telemetry, no analytics, no phone-home. The only outbound call ferrite makes on its own is an hourly GitHub check for updates (plus the blocklist fetches and notifications you configure). Nothing about your queries ever leaves the box.
  • Your DNS, encrypted upstream. Plain, DoT, DoH, and DoQ upstreams in one pool. A resolver's queries can also ride a tunnel (DNS-over-TCP or DoT through an egress) — so your ISP sees only WireGuard, not your lookups.
  • No client-subnet leak. EDNS Client Subnet is stripped from outgoing queries, so the upstream never learns the client's subnet.
  • DNSSEC requested. The DO bit is set and signatures are forwarded; pair with a validating resolver over DoT/DoH for end-to-end integrity.
  • No TLS interception. For routed domains ferrite peeks the SNI/Host but never terminates TLS — the client validates the real certificate end-to-end.
  • Local, optional logging — and switchable off. The query log is SQLite on your disk. Retention is configurable, verbose logging is a toggle, and the query log and statistics each have their own on/off switch (Settings → Privacy). Turn both off and ferrite keeps filtering while recording nothing about what was resolved: no per-query rows, no top lists, no per-device history, no host names on the Tunnels page, and no cached domains in the restart snapshot — the one already written is rewritten the moment you flip the switch. What was recorded earlier is not served back either, and a reboot does not undo the choice. Clearing the log vacuums the database, so the freed pages actually leave the disk.
  • No auth by default is loopback-only. The panel binds 127.0.0.1 until you expose it; if you bind it to the LAN without a password, ferrite warns you.

How it works

Every query runs the shortest useful path, stopping at the first stage that can answer:

client ──▶ ferrite
            1. custom records   → local A / AAAA / CNAME answer
            2. blocklist        → blocked for this device? NXDOMAIN (or the answer you chose)
               2b. SafeSearch   → CNAME to the engine's safe host
            3. routing rule     → answer with ferrite's own IP (traffic goes through the tunnel)
            4. cache            → fresh? cached answer
            5. upstream         → DoT / DoH / DoQ / plain (round-robin + failover)
            6. CNAME check      → a blocked CNAME target blocks the answer too

The blocklist step is per device: a profile's own allow and block entries and its services come first, then the allowlists, then default-deny if the profile has it on, then the network-wide entries, services and lists. A profile outside its schedule steps aside until its window opens.

Tunnels. When a domain matches a routing rule, ferrite answers DNS with its own LAN IP, so the client connects to ferrite. The listeners peek the SNI (:443) or Host (:80) — without terminating TLS — re-match the rule on the real hostname, and splice the connection through the chosen egress:

Egress What it does
direct Connect straight out (resolved via ferrite's upstream — no DNS leak).
socks5 Forward through a SOCKS5 proxy (hostname sent as ATYP=domain). Point it at a local Tor (127.0.0.1:9050) to route over Tor.
wireguard Built-in WireGuard — paste a .conf. Kernel-backed when allowed, userspace otherwise. DNS for routed names resolves through the tunnel.
evasion Like direct, but splits the TLS ClientHello at the SNI across TCP segments so DPI can't read the hostname.

Rules match a domain or a catalog service and can be scoped to specific devices (by name/MAC/IP) — route a kid's tablet through a tunnel while everything else goes direct. A blocklisted domain stays blocked even when a routing rule covers it (routing a service through a tunnel doesn't unblock its tracker subdomains); whitelist it to route it anyway. Rules, egresses and listener ports hot-reload — no restart.

Install

Docker Compose (recommended — see docker-compose.yml in the repo root):

services:
  ferrite:
    image: ghcr.io/koroblabs/ferrite-server:latest
    container_name: ferrite
    restart: unless-stopped
    network_mode: host          # so ferrite sees real client IPs (see note below)
    cap_add: [NET_BIND_SERVICE]
    volumes:
      - ferrite-data:/var/lib/ferrite
volumes:
  ferrite-data:
# Docker — image on GHCR (not Docker Hub)
docker run -d --name ferrite \
  --restart unless-stopped \
  --network host \
  --cap-add=NET_BIND_SERVICE \
  -v ferrite-data:/var/lib/ferrite \
  ghcr.io/koroblabs/ferrite-server:latest

Static binary. Every release ships musl binaries for x86_64 and aarch64 with a .sha256 next to each, so you can verify before running anything:

VER=v0.2.0; ARCH=$(uname -m)-unknown-linux-musl
BASE=https://github.com/koroblabs/ferrite-server/releases/download/$VER
curl -fsSLO $BASE/ferrite-$VER-$ARCH.tar.gz
curl -fsSL  $BASE/ferrite-$VER-$ARCH.tar.gz.sha256 | sha256sum -c -
tar -xzf ferrite-$VER-$ARCH.tar.gz && sudo install -m755 ferrite /usr/local/bin/ferrite

Install script — sets up a systemd/OpenRC service around that same binary. Read it before you run it; it is not piped into a shell here on purpose:

curl -fsSLO https://raw.githubusercontent.com/koroblabs/ferrite-server/main/install.sh
less install.sh          # see what it does
sudo sh install.sh
# From source (Rust 1.97+)
cargo build --release
cp target/release/ferrite /usr/local/bin/ferrite

Then point your network's DNS (router DHCP, or per device) at the ferrite host, open http://fe.te on the LAN, and set a password.

  • The Docker image is a small Alpine runtime; mount /var/lib/ferrite so config, data, and updates survive restarts. If port 53 fails to bind, add --cap-add=NET_BIND_SERVICE.
  • Prefer host networking. Per-device profiles and routing rules match on the client's LAN IP/MAC — behind a Docker bridge every query arrives from the gateway, so all your devices look like one. If you must use bridge mode, set FERRITE_PANEL_IP=<host LAN IP> so the fe.te shortcut resolves, and publish 443/tcp as well if you use selective routing or DNS over HTTPS, and 853/tcp for DNS over TLS. DHCP needs host networking.
  • fe.te is a built-in DNS record pointing at the detected (or configured) server IP, so the panel is easy to find.

Configuration

Config lives at ~/.config/ferrite/config.toml (user) or /etc/ferrite/config.toml (system). The web UI is the primary editor — ferrite writes the file itself; hand-editing is optional. With no config, ferrite starts with sane defaults (plain UDP to 8.8.8.8/8.8.4.4, API on 127.0.0.1:8080).

[dns]
bind_addr = "0.0.0.0:53"
strip_ecs = true   # don't leak the client subnet upstream
dnssec    = true   # request DNSSEC (DO bit)

[api]
bind_addr = "0.0.0.0:8080"   # default 127.0.0.1 (loopback) — set a password before exposing

Upstreams (round-robin + failover). Each upstream may tunnel through an egress via egress = "<id>" (plain/DoT only):

[[upstream]]
type = "plain"
address = "8.8.8.8"
port = 53

[[upstream]]
type = "tls"
address = "1.1.1.1"
port = 853
tls_name = "cloudflare-dns.com"

[[upstream]]
type = "https"
url = "https://cloudflare-dns.com/dns-query"
bootstrap_ip = "1.1.1.1"

[[upstream]]
type = "quic"
address = "94.140.14.14"
port = 853
tls_name = "dns.adguard-dns.com"

bootstrap_ip is needed for DoH when ferrite is the system resolver (it can't resolve the DoH hostname through itself). DoT/DoQ use a literal IP — no bootstrap.

Blocklists & allowlists — subscribe to any public list by URL (StevenBlack, OISD, AdGuard, Hagezi…); formats (hosts / Adblock ||domain^ / plain) are auto-detected and compiled into one fast FST. Allowlists work the same way in reverse — subscribe to a community false-positive feed (e.g. anudeepND) and its domains are never blocked; for Adblock-format lists the @@||domain^ exception rules are the allow entries. Manage everything live in the UI.

[blocklist]
enabled = true
domains = ["*.doubleclick.net", "ads.example.com", '/^ads?[0-9]+\./']   # always blocked: names, wildcards, /regex/
client_bypass   = ["192.168.1.50", "aa:bb:cc:dd:ee:ff"]   # these clients skip filtering

[[blocklist.lists]]
name = "StevenBlack"
url  = "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts"
enabled = true

[allowlist]
domains = ["safe.example.com", "*.internal.corp"]   # never blocked (manual entries)

[[allowlist.lists]]
name = "anudeepND Whitelist"
url  = "https://raw.githubusercontent.com/anudeepND/whitelist/master/domains/whitelist.txt"
enabled = true

Per-device profiles — apply a subset of the block/allow subscriptions to specific devices (by IP/MAC), with per-profile manual block/allow overrides. A profile can also flip to default-deny: everything is blocked except what's explicitly allowed. Local network names — PTR/reverse zones, .lan, .local, your [[zones]] — stay resolvable automatically, no extra configuration.

[[blocklist.profiles]]
id = "iot"
name = "IoT Devices"
clients      = ["aa:bb:cc:dd:ee:ff"]
allowlists   = ["anudeepND Whitelist"]   # empty = all enabled allowlists apply
default_deny = true                      # block everything not explicitly allowed

A profile can also block catalog services, run on a schedule (box-local time; a window may cross midnight) and force SafeSearch — the same three exist network-wide under [blocklist]:

[[blocklist.profiles]]
id = "kids"
name = "Kids"
clients  = ["aa:bb:cc:dd:ee:01", "aa:bb:cc:dd:ee:02"]
services = ["tiktok", "roblox", "fortnite"]     # GET /api/services lists the catalog
safe_search = true
youtube_restricted = "moderate"                 # off | moderate | strict

[[blocklist.profiles.schedule]]                 # no windows = always on
days = ["sun", "mon", "tue", "wed", "thu"]
from = "21:30"
to = "07:00"

Custom records (take priority over blocklist + upstream):

[[custom_records]]
domain = "router.lan"
type = "A"
value = "192.168.1.1"
ttl = 300

[[custom_records]]
domain = "*.home.lan"
type = "A"
value = "192.168.1.100"

Selective routing — an egress + a rule. Paste a standard WireGuard .conf:

[proxy]
enabled = true
max_connections = 256
# advertise_ipv4 / advertise_ipv6 auto-detect when unset

[[proxy.egresses]]
id = "nl-proton"
name = "NL Proton"
enabled = true
kind = "wireguard"        # direct | socks5 | wireguard | evasion
buffer_kb = 512           # wireguard per-connection window (throughput vs RAM; 256–1024 KiB)
config = """
[Interface]
PrivateKey = <your key>
Address = 10.2.0.2/32
DNS = 10.2.0.1
[Peer]
PublicKey = <peer key>
Endpoint = 146.70.86.114:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
"""

[[proxy.rules]]
pattern = "example.com"   # exact = domain + all subdomains; "*.example.com" = subdomains only
egress  = "nl-proton"
fail_closed = true        # if the egress is down, refuse rather than leak the connection directly
clients = []              # empty = all devices; or restrict by IP/MAC

A rule can name a catalog service instead of a pattern — service = "reddit" — for every domain the service uses.

The HTTP listener is shared with the panel on :80 (demuxed by Host); TLS is :443. Binding 80/443 needs privilege — ferrite already binds :53, so deploy with CAP_NET_BIND_SERVICE.

Notifications — tunnel outages and new devices, shaped for the receiver:

[notifications]
webhook = "https://ntfy.sh/my-ferrite"
format  = "ntfy"          # json | ntfy | discord | slack | telegram (+ chat_id) | gotify
tunnel_alerts = true
new_device    = true

config.toml.example documents every key.

Authentication

ferrite passwd                 # set a web UI password (Argon2id hash, stored in config)

If neither a password nor an api_key is set, the API/panel is open — fine on loopback, set a password before binding to the LAN (ferrite warns you if you don't). A browser session lasts 7 days since it was last used and survives restarts and updates; Settings → Access lists the signed-in browsers and signs any of them out. Changing the password signs out every other session.

For scripts, set api_key and send Authorization: Bearer <key> or X-Api-Key: <key>. A password login returns a session token too:

TOKEN=$(curl -s -X POST http://localhost:8080/api/auth \
        -H 'Content-Type: application/json' -d '{"password":"…"}' | jq -r .token)
curl -s http://localhost:8080/api/stats/summary -H "Authorization: Bearer $TOKEN"

API

Everything in the web UI is this REST API — anything you do by hand, a script can too. Base URL http://127.0.0.1:8080, all under /api, behind auth when configured. The full reference is docs/API.md; the main groups:

/auth, /auth/sessions                 sign in and out, signed-in browsers
/stats/*, /queries, /logs, /metrics    counters, charts, the query log, server logs, Prometheus
/clients, /clients/{device}/stats      devices, names, one device in detail
/blocklist/*, /lists, /allowlists      entries, subscriptions, profiles, services, SafeSearch, why-blocked
/services                              the service catalog
/blocking/pause, /blocking/resume      pause filtering, box-wide or per device
/custom-records                        local DNS records
/proxy, /proxy/stats                   tunnels: egresses, rules, live counters
/notifications                         webhook target and events; a test send
/settings, /config/export|import       runtime settings, backup and restore
/tools/*                               DNS lookup, DNSSEC, WHOIS, certificate and reachability probes
/update/*                              update checks and self-update
/mcp                                   Model Context Protocol endpoint (off by default)

More

Data files

Configuration lives in config.toml and only there — settings, subscriptions, manual block/allow entries, profiles, custom records, client aliases, tunnels, notifications. Everything the UI changes is written straight back to it, so backing up that one file backs up the whole setup. The database holds what ferrite observed (the query log, the device inventory and when each device was first seen) and the panel's login sessions: delete it and you lose history and have to sign in again, never configuration.

All under ~/.local/share/ferrite/:

File Contents
ferrite.db SQLite — query log, rollups, device inventory, login sessions
(absent entirely with storage.backend = "memory")
blocklist.fst Compiled blocklist (rebuilt on list changes)
allowlist.fst Compiled subscribed allowlists
lists/<name>.* Per-blocklist parsed-domain cache
allowlists/<name>.* Per-allowlist parsed-domain cache
state.bin Warm-restart snapshot (DNS cache + counters)
web/ Web UI static files

License

MIT

Requirements

Ports 53 TCP/UDP and 80 TCP must be available on the selected Unraid network/IP; the selective-routing (tunnels) feature additionally needs 443 TCP reachable unmapped. A custom Docker network or fixed IP (br0) is recommended: the Unraid web UI often already uses host ports 80/443, and routed domains require clients to reach Ferrite on the real ports 80/443. All Ferrite config, SQLite data, blocklist cache, snapshots, and web assets are stored under /mnt/user/appdata/ferrite. The template grants NET_BIND_SERVICE (to bind port 53 without running as root) and NET_ADMIN (for kernel-mode WireGuard tunnels: Ferrite creates its own wg interface over netlink and routes only its own traffic through it, never touching the host routing table). Kernel mode also needs the wireguard module on the Unraid host, which is present on 6.9 and newer; without it Ferrite falls back to the userspace tunnel automatically and logs which backend it picked.

Related apps

Details

Repository
ghcr.io/koroblabs/ferrite-server:latest
Last Updated2026-11-09
First Seen2026-11-09

Runtime arguments

Web UI
http://[IP]:[PORT:80]/
Network
bridge
Shell
sh
Privileged
false
Extra Params
--cap-add=NET_BIND_SERVICE --cap-add=NET_ADMIN

Template configuration

AppdataPathrw

Stores Ferrite config, SQLite database, blocklist cache, snapshots, and web assets.

Target
/var/lib/ferrite
Default
/mnt/user/appdata/ferrite
Value
/mnt/user/appdata/ferrite
DNS TCPPorttcp

DNS TCP listener for name resolution.

Target
53
Default
53
Value
53
DNS UDPPortudp

DNS UDP listener for name resolution.

Target
53
Default
53
Value
53
Web UIPorttcp

Ferrite API and web UI. Also intercepts HTTP for routed domains (demultiplexed by Host header).

Target
80
Default
80
Value
80
HTTPS RoutingPorttcp

HTTPS (SNI) intercept for the selective-routing/tunnels feature. Only bound while routing is enabled. Must stay 443:443 for routing to work; leave unmapped if tunnels are not used.

Target
443
Default
443
Value
443
Server Release VersionVariable

Use latest to install the newest Ferrite release on container start. Set a fixed version, for example 0.1.1, to disable automatic server release updates on restart.

Target
FERRITE_SERVER_VERSION
Default
latest
Value
latest
Web Release VersionVariable

Optional web UI release version. Leave blank to follow Server Release Version; set a fixed version to pin the web UI separately.

Target
FERRITE_WEB_VERSION
Panel IPVariable

Optional LAN IP for the built-in fe.te A record. In bridge networking set this to the Unraid host IP, for example 192.168.1.5. Leave blank to auto-detect.

Target
FERRITE_PANEL_IP
Panel DomainVariable

Optional override for the built-in panel shortcut domain. Leave blank for the default fe.te.

Target
FERRITE_PANEL_DOMAIN
Panel URLVariable

Optional startup-log URL for the panel shortcut. If Web UI is mapped to a non-80 host port, set this to http://fe.te:port.

Target
FERRITE_PANEL_URL
GitHub Release TokenVariable

Optional GitHub token used when the container downloads server and web releases on start. Leave blank for public releases. Set a token only if you hit GitHub API rate limits (anonymous requests are capped at 60/hour per IP) or pull from a private mirror.

Target
FERRITE_RELEASE_TOKEN