Ferrite

Ferrite

Docker app from syntlyx's Repository

Overview

Ferrite is a Rust DNS sinkhole and filtering API server. It blocks ads and trackers at the DNS level, supports encrypted upstream resolvers, stores query statistics, and provides a web/API management surface.

ferrite

Self-hosted DNS that blocks ads & trackers — and routes any device through a tunnel. It's a Pi-hole-style sinkhole that goes further: send chosen domains, or whole devices, through WireGuard, SOCKS5, or Tor with DPI evasion and encrypted upstreams. Filtering and a privacy tunnel in one binary, no root, written in Rust.

Pi-hole keeps your DNS clean. ferrite keeps it clean and lets you decide, per device, what leaves your network and how.

Why ferrite

  • Filtering and per-device routing in one box. No glue between a DNS blocker and a VPN — it's the same server.
  • Anti-censorship built in. Route blocked domains through Tor or a tunnel, with TLS-ClientHello (SNI) fragmentation to defeat DPI — per device, your choice.
  • No root, no TUN device. WireGuard runs in userspace (boringtun + smoltcp), fully in-process.
  • Fast and small. Rust, single binary, sub-2 ms cache/block decisions, tens-of-thousands of QPS on a home server.
  • Self-hosted and private. No telemetry, no phone-home. Your queries stay on your box.

Screenshots

Images live in the web UI repo.

Dashboard Tunnels

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 (and blocklist fetches 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. The query log is SQLite on your disk. Retention is configurable, and verbose logging is a toggle.
  • 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. selective routing→ matches a rule? answer with ferrite's own IP
            3. blocklist        → blocked? NXDOMAIN
            4. cache            → fresh? cached answer
            5. upstream         → DoT/DoH/DoQ/plain (round-robin + failover)

Selective routing / 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 userspace WireGuard — paste a .conf. 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 can be scoped to specific devices (by name/MAC/IP) — route a kid's tablet through a tunnel while everything else goes direct. Routing is independent of blocking (a routed domain is never blocked). Rules, egresses, and listener ports hot-reload — no restart.

Install

# Linux — release install (systemd / OpenRC service)
curl -fsSL https://raw.githubusercontent.com/syntlyx/ferrite-server/main/install.sh | sudo sh
# Docker — image on GHCR (not Docker Hub)
docker run -d --name ferrite \
  --restart unless-stopped \
  -p 53:53/tcp -p 53:53/udp \
  -p 80:80/tcp \
  -v ferrite-data:/var/lib/ferrite \
  ghcr.io/syntlyx/ferrite-server:latest
# From source (Rust 1.88+)
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. In bridge mode set FERRITE_PANEL_IP=<host LAN IP> so the fe.te shortcut resolves.
  • 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 — 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. Manage them live in the UI.

[blocklist]
enabled = true
whitelist       = ["safe.example.com", "*.internal.corp"]
wildcard_block  = ["*.doubleclick.net"]
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

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

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.

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). For scripts, set api_key and send Authorization: Bearer <key> or X-Api-Key: <key>. Password login returns a 24 h session token:

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.

GET/POST/DELETE  /api/auth                     session status · log in · log out

GET    /api/stats/summary                      live counters
GET    /api/stats/timeseries                   24 h, 144 × 10 min buckets
GET    /api/stats/top-blocked|top-domains|top-clients
GET    /api/stats/system                       host/process metrics
GET    /api/queries        DELETE /api/queries query log (filterable) · clear
GET    /api/logs                               recent in-memory server logs

GET    /api/clients                            clients grouped by name (IPs + MACs)
GET/POST/DELETE  /api/clients/aliases[/{ip}]   manual client aliases

GET/POST  /api/blocklist/whitelist|blacklist   list · add (exact or *.wildcard)
DELETE    /api/blocklist/whitelist|blacklist/{domain}
GET    /api/blocklist/check/{domain}           why-blocked: which list/rule matched

GET/POST  /api/lists      PATCH/DELETE /api/lists/{name}    subscriptions
POST   /api/lists/refresh · /api/lists/{name}/refresh

GET/POST  /api/custom-records   DELETE /api/custom-records/{domain}

GET    /api/proxy   PUT /api/proxy             selective-routing config (secrets redacted)

GET    /api/tools/resolve?name=&type=          DNS lookup (any record type)
GET    /api/tools/whois?query=                 WHOIS

GET    /api/settings   PATCH /api/settings      config (secrets redacted; restart-fields flagged)
GET    /api/update/check   POST /api/update/server|web

More

Data files

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

File Contents
ferrite.db SQLite — query log, stats, whitelist/blacklist, custom records
blocklist.fst Compiled blocklist (rebuilt on list changes)
lists/<name>.* Per-list parsed-domain cache
state.bin Warm-restart snapshot (DNS cache + counters)
web/ Web UI static files

License

MIT

Install Ferrite on Unraid in a few clicks.

Find Ferrite in Community Apps on your Unraid server, review the template, and click Install. Unraid handles the Docker app or plugin setup from the published template.

Open the Apps tab on your Unraid server Search Community Apps for Ferrite Review the template variables and paths Click Install

Requirements

Ports 53 TCP/UDP and 80 TCP must be available on the selected Unraid network/IP. A custom Docker network or fixed IP is recommended because the Unraid web UI often already uses host port 80. All Ferrite config, SQLite data, blocklist cache, snapshots, and web assets are stored under /mnt/user/appdata/ferrite.

Related apps

Details

Repository
ghcr.io/syntlyx/ferrite-server:latest
Last Updated2026-07-07
First Seen2026-06-12

Runtime arguments

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

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.

Target
80
Default
80
Value
80
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 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