Foghorn is a versatile DNS server designed for flexibility and performance. Built on a robust caching forwarder or recursive resolver foundation, it seamlessly integrates with YAML-based configuration and modular plugins to transform into a powerful ad-blocker, local hosts service, kid-safe filter. Fine-tune its capabilities with a Redis backend, InfluxDB logging, and customizable function-cache sizes tailored to your specific needs.
With built-in admin and API server support, Foghorn empowers you to monitor and manage its operations efficiently. Plugins extend its functionality by providing their own status pages, seamlessly integrated into the admin dashboard. Newer releases add DNSSEC signing helpers, zone transfers (AXFR/IXFR), RFC 8914 Extended DNS Errors (EDE), and SSH host key utilities so you can treat DNS as a first-class security and operations tool.
The configuration file is validated against a JSON Schema, but you rarely need to read the schema directly. This guide walks through the main sections (vars, server, upstreams, logging, stats, and plugins), then shows concrete examples for every built‑in plugin.
- 0. Thanks
- 1. Quick Start
- 2. Configuration layout overview
- 3. Listeners and upstreams by example
- 4. Plugin cookbook
- 4.1 Access control (acl)
- 4.2 Docker containers (docker)
- 4.3 Hosts files (hosts)
- 4.4 List downloader (lists)
- 4.5 Domain filter / adblock (filter)
- 4.6 Flaky upstream simulator (flaky)
- 4.7 mDNS / Bonjour bridge (mdns)
- 4.8 Rate limiting (rate)
- 4.9 Per-domain upstream routing (router)
- 4.10 Inline and file-based records (zone)
- 5. Example Plugins
- 6. Variables
- 7. Sample configurations
- Developer notes and contribution guide
- Makefile targets and build helpers
- Pi-hole replacement example configuration
- OpenSSL make targets (certificate helpers)
- DNS RFC compliance, EDNS/EDE, and AXFR/IXFR notes
- SSH host keys, SSHFP records, and DNSSEC integration
With special thanks to Fiona Weatherwax for their contributions and inspiration, to the dnslib team for the low level / on wire primitives, and to dnspython for the DNSSEC implementation. Additional shout outs to the whole python community, and the teams of fastapi, pydantic, black, ruff, pytest, and every other giant on whose shoulders I stand.
Also thanks to my junior developers, AI from both local and remote models, some via warp.dev, who keeps my docstrings and unit tests up to date, creates good commit messages, and other janitorial tasks. Also a lot of help with the all the HTML/JS. Because I'm just not good at it.
Foghorn can be installed a few different ways, depending on how you prefer to run services:
• From PyPI (recommended for most users)
Install the latest released version into your Python environment:
pip install foghorn
This gives you the foghorn CLI and library directly on your host system.• From source (GitHub) If you want to track development, hack on plugins, or run a specific commit/branch, clone the repository and install it in editable mode:
git clone https://github.com/zallison/foghorn.git
cd foghorn
pip install -e .This keeps your local checkout and installed code in sync as you make changes.
• Prebuilt Docker images (amd64 and armhf) If you prefer to run Foghorn in a container, prebuilt images for both amd64 and armhf are available on Docker Hub at https://hub.docker.com/r/zallison/foghorn. Pull the image for your architecture and run it with your configuration mounted as /foghorn/config.yaml, along with any port mappings you need for DNS, DoT/DoH, and the admin web UI.
docker run --name foghorn -v ./config/:/foghorn/config/ -p 53:53/udp -p 53:53/tcp -p 5380:5380 --privileged zallison/foghorn:latestThis example listens on all interfaces for UDP/TCP DNS and forwards to a public DoT resolver. It also enables a simple in-memory cache.
# config/config.yaml
vars:
ENV: prod
server:
listen:
dns:
udp:
enabled: true
host: 0.0.0.0
port: 53
tcp:
enabled: true
host: 0.0.0.0
port: 53
cache:
module: memory # memory | sqlite | redis | memcached | mongodb | none
upstreams:
strategy: failover # failover | round_robin | random
max_concurrent: 1 # 1 | 2 | 4 ...
endpoints:
- host: 1.1.1.1
port: 853
transport: dot # udp | tcp | dot
tls:
server_name: cloudflare-dns.com
plugins: []You can start Foghorn with:
foghorn --config config/config.yamlFrom here you layer in plugins to get adblocking, hosts files, per-user allowlists, and more.
For local development there is a Makefile with a few convenience targets:
make run– create a venv if needed and start Foghorn withconfig/config.yaml.make env/make env-dev– create the virtualenv in./venvand install dependencies (with dev extras forenv-dev).make build– prepare the development environment (keeps the JSON schema up to date).make schema– regenerateassets/config-schema.jsonfrom the Python code.make test– run the test suite with coverage.make dnssec-sign-zone– sign a BIND-style zone file with DNSSEC using the bundled helper script, writing a signed zone that can be served by the ZoneRecords plugin.make clean– remove the venv, build artefacts, and temporary files.make docker,make docker-build,make docker-run,make docker-logs,make docker-clean,make docker-ship– build and run Docker images/containers.make package-build/make package-publish/make package-publish-dev– build and (optionally) publish Python packages.make ssl-cert– generate a self-signed TLS key and certificate under./varusingopenssl req -x509.
- OpenSSL make targets (made easy)
- DNS RFC compliance and protocol notes (including EDE and AXFR)
- SSH host keys, SSHFP records, and DNSSEC integration
At the top level the schema defines these keys:
vars: key/value variables for interpolation inside the rest of the file.server: listener, DNSSEC, resolver, cache, and admin HTTP settings.upstreams: how outbound DNS queries are sent.logging: global logging level and outputs.stats: runtime statistics and query-log persistence.plugins: the ordered list of plugins that wrap each query.
Conceptually, a request flows like this:
client ---> UDP/TCP/DoH listener
---> DNS cache (memory, redis, etc) (optional)
---> plugins (pre_resolve chain)
---> [maybe upstream DNS calls or recursive resolving]
---> plugins (post_resolve chain)
---> response or deny
Note: when a pre_resolve plugin returns an override decision the generated
response is sent immediately and the post_resolve chain is skipped entirely; a
post_resolve override short-circuits any later post_resolve plugins for that
query.
Key parts of server:
server.listendns.udp/dns.tcp: classic DNS listeners.dns.dot: DNS-over-TLS listener.doh: DNS-over-HTTPS listener.
server.cachemodule: which cache plugin to use.config: plugin-specific cache settings.- For the in-memory DNS cache (
module: memory), the underlyingFoghornTTLCachesupports optional capacity and eviction controls when instantiated from Python code:maxsize(positive integer, unbounded whenNoneor non-positive) andeviction_policy(one ofnone,lru,lfu,fifo,random, oralmost_expired).
- For the in-memory DNS cache (
modify/decorated_overrides/func_caches: optional overrides for internal helper caches (functions decorated withregistered_cached,registered_lru_cached,registered_foghorn_ttl, orregistered_sqlite_ttl). These let you tune TTL and maxsize for specific helpers without code changes.- Valid
backendvalues forfunc_cachesentries are:ttlcache(cachetools.TTLCache)lfu_cache(cachetools.LFUCache)rr_cache(cachetools.RRCache)fifo_cache(cachetools.FIFOCache)lru_cache(cachetools.LRUCache)foghorn_ttl(FoghornTTLCache-based helpers via registered_foghorn_ttl)sqlite_ttl(SQLite3TTLCache-based helpers via registered_sqlite_ttl)
- Valid
Example: increase the TTL and maxsize for a DNSSEC helper cached via
cachetools.TTLCache:
server:
cache:
module: memory
config:
min_cache_ttl: 60
func_caches:
- module: foghorn.dnssec.dnssec_validate
name: _find_zone_apex_cached
backend: ttlcache # ttlcache | lru_cache | foghorn_ttl | sqlite_ttl | lfu_cache | rr_cache
ttl: 300 # seconds; applies to TTL-style backends
maxsize: 2048 # logical max size for this helper cache
reset_on_ttl_change: true # clear TTLCache once when ttl changesNote: the backend field here selects the desired cache backend type for
that helper (matching the "Backend" column in the admin "Decorated caches"
table). For cachetools-backed helpers wrapped with registered_cached, Foghorn
rebuilds the function's cache at startup so you can switch between
ttlcache, lfu_cache, rr_cache, fifo_cache, and lru_cache purely from
configuration.
server.dnssec- Mode and DNSSEC validation knobs (e.g., UDP payload size).
On Linux systems that use glibc, applications (including ssh when using SSHFP records) only see the DNSSEC AD bit when the resolver is explicitly configured to trust it. If you run Foghorn (or another validating resolver that honors the upstream AD bit) and want glibc clients to accept that AD as trustworthy, point /etc/resolv.conf at Foghorn and add trust-ad to the options line:
nameserver 127.0.0.1
options edns0 trust-ad
Without trust-ad, glibc clears the AD flag before handing answers to applications, so tools like OpenSSH will ignore SSHFP records even when Foghorn has validated them.
server.enable_ede- Optional toggle for RFC 8914 Extended DNS Errors; when true and the client advertises EDNS(0), Foghorn can attach EDE options to certain policy or upstream-failure responses and surface per-code stats in the admin UI.
server.resolver- Timeouts, recursion depth, and whether Foghorn runs as a forwarder or recursive resolver.
server.http- Admin web UI listener configuration.
upstreams describes how Foghorn talks to other DNS servers:
strategy:failover(try in order),round_robin, orrandom.max_concurrent: maximum simultaneous outstanding upstream queries.endpoints: list ofupstream_hostdefinitions.
An upstream_host entry:
upstreams:
endpoints:
- host: 9.9.9.9
port: 53 # 853 for DoT
transport: udp # udp | tcp | dot
tls:
server_name: dns.quad9.net
verify: true # true | falseAn upstream_host entry: using DNS-over-HTTP(s)
upstreams:
endpoints:
- transport: doh
url: https://dns.example.com/dns-query
method: POST # POST | GET
tls:
verify: true # true | false
ca_file: /etc/ssl/certs/ca-certificates.crtlogging controls both the process-wide Python logger and the
statistics/query-log backends.
Python logging (global defaults):
logging:
python:
level: info # debug | info | warn | error | critical
stderr: true # true | false
file: ./var/foghorn.log
syslog: false # false | true | {address, facility, tag}Plugins can also override logging per-instance via their own logging block on
the plugin entry, using the same shape as logging.python.
logging.backends describes where persistent stats/query-log data is written.
Each entry maps to a statistics backend such as SQLite or MQTT logging:
logging:
async: true # default async behaviour for stats backends
query_log_only: false # false | true
backends:
- id: local-log
backend: sqlite
config:
db_path: ./config/var/stats.db
batch_writes: true
- id: backup-mqtt
backend: mqtt_logging
config:
host: mqtt.internal
port: 1883
topic: foghorn/query_logThe stats section controls runtime statistics behaviour and selects which
logging backend to read from. When logging.async is true, writes to
stats/query-log backends are performed by a background worker so request
handling stays fast; setting it to false forces synchronous writes.
Important fields include:
enabled: master on/off switch for statistics.source_backend: whichlogging.backends[*].id(or backend alias) to treat as the primary read backend.
Example:
logging:
async: true
query_log_only: false # false | true
backends:
- id: local-log
backend: sqlite
config:
db_path: ./config/var/stats.db
batch_writes: true
stats:
enabled: true
source_backend: local-log
interval_seconds: 300
ignore:
include_in_stats: true # Just don't display them
ignore_single_host: false
top_domains_mode: suffix # exact | suffix
top_domains:
- example.comIn the plugins list each entry is a PluginInstance:
plugins:
- type: filter
id: main-filter
enabled: true
setup:
abort_on_failure: true
hooks:
pre_resolve:
enabled: true
priority: 50
logging:
level: info
config:
# plugin-specific config hereYou normally care about:
type: short alias for the plugin.id: optional stable identifier for this plugin instance (surfaced in stats, logs, and admin UI).enabled: whether it runs.hooks: per-hook enable/priority overrides (optional).setup: one-time setup behaviour;abort_on_failurecontrols whether a failing setup() aborts startup.logging: per-plugin logging overrides (level, file, stderr, syslog) using the same shape as the globalloggingblock.config: the actual configuration for that plugin.
Common plugin‑wide config options:
Targeting:
- Client targeting
config.targets: list (or single string) of CIDR/IPs to explicitly target. When set, only matching clients are affected.config.targets_ignore: list (or single string) of CIDR/IPs to skip. When onlytargets_ignoreis set, all other clients are targeted by default.
- Listener / transport targeting:
config.targets_listener: restrict a plugin to specific listeners. Accepts one or more of"udp","tcp","dot","doh", or aliases:"secure"→["dot", "doh"]"unsecure"/"insecure"→["udp", "tcp"]"any","*", ornull→ no listener restriction.
- Domain targeting:
config.targets_domains: list (or single string) of domain names to match.config.targets_domains_mode:"exact"(qname must equal one oftargets_domains),"suffix"(qname is that domain or ends with"." + domain), or"any"(no domain restriction).
- qtype targeting:
config.target_qtypes: list of qtype names or'*'for all types, e.g.['A', 'AAAA'],['MX']or['*'].
Logging
- Per-plugin logging:
- The
loggingstanza on the plugin instance lets you bump log level or direct output differently from the global logger.
- The
Example with all common knobs:
plugins:
- type: some-plugin
id: example
enabled: true
logging:
level: debug
config:
# Client IP targeting
targets:
- 192.168.0.0/16
- 10.10.10.0/24
# JSON Lines
targets_ignore: [ "192.168.0.10" ]
# Listener / transport targeting: only DoT/DoH
targets_listener:
- dot
- doh
# Domain targeting: only *.corp.example
targets_domains:
- corp.example
targets_domains_mode: suffix # any | exact | suffix
# qtype targeting: A/AAAA only
target_qtypes: ['A', 'AAAA'] # '*' | ['A'] | ['A', 'AAAA']A typical server.listen.dns configuration:
server:
listen:
dns:
udp:
enabled: true
host: 0.0.0.0
port: 53
tcp:
enabled: true
host: 0.0.0.0
port: 53Turn TCP off if you never want to accept TCP DNS:
tcp:
enabled: false # true | falseTo talk to an upstream DoT resolver:
upstreams:
strategy: round_robin # failover | round_robin | random
endpoints:
- host: 1.1.1.1
port: 853
transport: dot
tls:
server_name: cloudflare-dns.com
verify: trueYou can mix DoT and plain UDP endpoints in the same list; the strategy decides how they are chosen.
To expose a DoH listener directly from Foghorn (for example on port 8053 with TLS termination):
server:
listen:
# ... dns.udp / dns.tcp here ...
doh:
enabled: true
host: 0.0.0.0
port: 8053
cert_file: /etc/foghorn/tls/server.crt
key_file: /etc/foghorn/tls/server.keyWhen Foghorn itself is running behind an HTTP reverse proxy (for example, nginx or Envoy), you typically terminate TLS at the proxy and run the DoH listener as plain HTTP on localhost. The proxy handles https:// and forwards /dns-query to Foghorn:
server:
listen:
# ... dns.udp / dns.tcp here ...
doh:
enabled: true
host: 127.0.0.1
port: 8443
# No cert_file/key_file here; TLS is terminated at the reverse proxy.Your reverse proxy is then configured to listen on 443 with TLS and proxy requests such as https://dns.example.com/dns-query to http://127.0.0.1:8443/dns-query.
For quick local testing, the Makefile includes convenience targets that generate a small CA and server key material under ./keys:
make ssl-ca– createfoghorn_ca.keyand a self-signedfoghorn_ca.crtwith CA key usage.make ssl-ca-pem– export the CA certificate asfoghorn_ca.pemfor use as a trust anchor (e.g.upstreams.*.tls.ca_file).make ssl-cert CNAME=myserver– create a server key and certificate signed by the local CA, namedfoghorn_${CNAME}.key/.crt.make ssl-server-pem CNAME=myserver– build a combinedfoghorn_${CNAME}.pemcontaining the server certificate and key.
Use ca.pem when Foghorn is a TLS client and needs to trust an internal CA (for example for DoT/DoH upstreams via tls.ca_file). Use server.pem when Foghorn is acting as a TLS server and you need a single file containing both cert and key for a listener.
These are intended for development and lab environments only; for production, use your normal PKI or certificate management.
Below are the built‑in plugins, with short descriptions and minimal configs. All examples assume they live in the shared plugins: list.
IP-based allow/deny control at the edge.
plugins:
- type: acl
config:
default: allow # allow | deny
allow:
- 192.168.1.1 # Overrides the deny below.
- 10.0.0.0/8
deny:
- 192.168.0.0/16
- 172.16.0.0/12Expose Docker container names as DNS answers.
plugins:
- type: docker
config:
endpoints:
- url: unix:///var/run/docker.sock
- url: tcp://my.server.lan:2375
ttl: 60
health: ['healthy', 'running']
discovery: true # false | trueServe additional records from one or more hosts-style files.
plugins:
- type: hosts
config:
file_paths:
- /etc/hosts
- ./config/hosts.
ttl: 300
watchdog_enabled: true # null | true | falseFetches remote blocklists/allowlists on a schedule and stores them as files for other plugins.
plugins:
- type: lists
hooks: # Setup early so other plugins have their files available.
setup: { priority: 10 }
config:
download_path: ./config/var/lists
interval_days: 1
hash_filenames: true # false | true - Multiple "hosts.txt" files easily handled by hashing the URL
urls:
- https://example.com/ads.txt
- https://example.com/hosts.txt
- https://serverA/hosts.txt
- https://serverB/hosts.txtFlexible domain/IP/pattern filter used to build adblockers and kid-safe DNS.
plugins:
- type: filter
config:
hooks:
pre_resolve: { priority: 25 } # Run early in block queries so other plugins don't do anything
post_resolve: { priority: 25 } # Post-resolve IP filtering and policy
default: allow # deny | allow
targets:
- 10.0.1.0/24 # Kids subnet
ttl: 300
# When a pre_resolve deny happens, synthesize an IP response pointing at a sinkhole address
deny_response: ip # nxdomain | refused | servfail | ip | noerror_empty
deny_response_ip4: 0.0.0.0
# Post-resolve IP filtering rules (answer inspection)
blocked_ips:
- ip: 203.0.113.10 # Replace a specific IP with a safer landing page
action: replace
replace_with: 0.0.0.0
- ip: 203.0.113.0/24 # Strip an entire subnet from answers
action: remove
- ip: 198.51.100.42 # Block a single IP entirely (maps to deny)
action: deny
blocked_domains_files:
- ./config/var/lists/*.txt
allowed_domains:
- homework.example.org
blocked_domains:
- how-to-cheat.org
- current-game-obession.io
Injects DNS errors and timeouts for testing client behaviour.
plugins:
- type: flaky
id: dev-servfail-5-A-AAAA
config:
servfail_percent: 5
nxdomain_percent: 0
timeout_percent: 0
truncate_percent: 0
noerror_empty_percent: 0
apply_to_qtypes: ['A', 'AAAA']Expose mDNS / DNS-SD services as normal DNS records.
Note For mDNS / DNS-SD / Bonjour / Zeroconf / Avahi you must be on the same L2 network in order to receive (e.g. in Docker you might need to run with --net=host or --net=macvlan)
plugins:
- type: mdns
config:
domain: '.'
ttl: 120
include_ipv4: true # true | false
include_ipv6: true # true | false
network_enabled: trueAdaptive rate limiting per client or per (client,domain).
plugins:
- type: rate
config:
mode: per_client # per_client | per_client_domain | per_domain
window_seconds: 10
warmup_windows: 6
burst_factor: 3.0
min_enforce_rps: 50.0
deny_response: nxdomain # nxdomain | refused | servfail | noerror_empty | ip
deny_response_ip4: 0.0.0.0
db_path: ./config/var/rate_limit.dbSend different domains to different upstreams.
upstreams:
strategy: failover
endpoints:
- host: 9.9.9.9
port: 53
transport: udp
plugins:
- type: router
config:
routes:
- domain: internal.example
upstreams:
- host: 10.0.0.53
port: 53
- suffix: corp.
upstreams:
- host: 192.168.1.1
port: 53Define custom records either:
- Inline using the
recordslist and the pipe-delimited format<domain>|<qtype>|<ttl>|<value>. - From one or more custom records files using
file_paths(same pipe-delimited format as above). - From one or more RFC‑1035 style BIND zonefiles using
bind_paths(parsed via dnslib; supports$ORIGIN,$TTL, and normal RR syntax).
All sources are merged into a single internal view per (name, qtype):
- The TTL for each (name, qtype) pair comes from the first occurrence of that pair across all sources in configuration order.
- Values are kept in first-seen order with duplicates dropped.
- An SOA record at a name marks the apex of an authoritative zone; inside such a zone the plugin behaves like an authoritative server, including correct NXDOMAIN/NODATA and ANY semantics with SOA in the authority section.
- For zones with an SOA apex and a TCP/DoT listener enabled, Foghorn also answers AXFR/IXFR for that zone using the same ZoneRecords data.
- IXFR server side currently implemented as a full AXFR-style transfer.
- IXFR client side is not yet supported.
plugins:
- type: zone
config:
# Optional: pipe-delimited records files
file_paths:
- ./config/zones.d/internal.records
# Optional: native BIND zonefiles
bind_paths:
- ./config/zones.d/internal.zone
# Optional: inline records
records:
- 'printer.lan|A|300|192.168.1.50'
- 'files.lan|AAAA|300|2001:db8::50'
ttl: 300Some resolver filters are shipped as examples only and now live under
foghorn.plugins.resolve.examples (for example the DNS prefetch, example
rewrites, greylist, new-domain WHOIS filter, file-over-DNS helper, and
finger-style user lookup over DNS).
These plugins are functional, but they are intended as reference implementations
for advanced users. They are not wired into a running server by default; a
config that references these example types will not work unless you first move
or copy the corresponding module into foghorn.plugins.resolve (or build your
own plugin based on them).
Treat them as starting points: read the code end‑to‑end, decide whether the behaviour and trade‑offs match your environment, and in most cases prefer creating your own plugin that uses these as an example rather than dropping them directly into production.
Prefetches the most popular names from statistics to keep the cache warm. Not very efficient yet, but it shows the concept.
plugins:
- type: prefetch
config:
interval_seconds: 60
prefetch_top_n: 100
max_consecutive_misses: 5
qtypes: ['A', 'AAAA']A playground plugin that can rewrite responses or demonstrate behaviours.
plugins:
- type: examples
config:
base_labels: 2
max_subdomains: 5
apply_to_qtypes: ['A']Introduces a delay window before new names are allowed. The origin of the project! Started as a greylist for security researchers working on phishing protection.
plugins:
- type: greylist_example
config:
db_path: ./config/var/greylist.db
cache_ttl_seconds: 300
duration_hours: 24Blocks domains that appear too new according to WHOIS data.
plugins:
- type: new_domain
config:
threshold_days: 7
whois_db_path: ./config/var/whois_cache.db
whois_cache_ttl_seconds: 3600Serves byte ranges from configured files over TXT DNS queries using a pattern
like <name>.<start>.<end>.<rest-of-domain>.
plugins:
- type: file_over_dns
config:
files:
- name: readme
file_path: ./README.md
ttl: 300
max_chunk_bytes: 1024
format: base64 # base64 | rawExposes a finger-style view of user information over TXT records for
<user>.<domain> when enabled, reading from per-user files such as
$HOME/.finger or a configured path template.
Exposes a finger-style view of user information over TXT records for
<user>.<domain> when enabled, reading from per-user files such as
$HOME/.finger or a configured path template.
Foghorn supports a simple variable system so you can avoid repetition, keep secrets out of committed configs, and adapt the same config to multiple environments (local dev, CI/CD, containers, different sites, and so on).
Variables are resolved in this order (highest priority last):
- Command-line
--var/--varsflags. - Environment variables.
- The
vars:section at the top of the config file.
Later sources override earlier ones for the same variable name.
At the top of your config:
vars:
PROFILE: lan
MAIN_SUBNET: 192.168.0.0/16
TEST_SUBNET: 192.168.50.0/24You can then reference these variables elsewhere using ${NAME} syntax:
server:
listen:
dns:
udp: { enabled: true, host: 0.0.0.0, port: 53 }
plugins:
- type: filter
id: main-filter
config:
targets: ${MAIN_SUBNET}
- type: filter
id: test-filter
config:
targets: ${TEST_SUBNET}This keeps common values (like subnets) in one place so they are easy to update and reuse.
vars:
LAN: 192.168.0.0/16
ADULTS: 192.168.1.0/24
KIDS: 192.168.2.0/24
server:
listen:
dns:
udp: { enabled: true, host: 0.0.0.0, port: 53 }
plugins:
- type: filter
id: adblock
config:
targets: ${LAN}
default: allow
# ... normal adblock rules ...
- type: filter
id: kids-filter
config:
targets: ${KIDS}
default: deny
# ... strict allowlist rules for kids ...- Less repetition: Define shared values (subnets, hostnames, DNS names) once and reuse them throughout the config.
- Safer secrets: Keep API tokens, passwords, and other sensitive data out of the main config by passing them via environment variables or command-line overrides.
- Environment-specific overrides: In CI/CD or container setups you can keep
a single base config file and override only the variables per environment.
- In local dev, you might rely on the
vars:section. - In staging/prod, environment variables or
--varflags can redefine a few keys (for example, stats database DSN, upstreams, or listen addresses).
- In local dev, you might rely on the
- Caveat for IDEs: Some editors/IDEs run JSON/YAML schema validation on the
raw file and do not interpolate
${VAR}placeholders first. In that case the config may show spurious validation errors even though Foghorn will load it correctly at runtime.
These sketches show how all the pieces fit together. Adjust paths and IPs to match your environment.
Goals:
- Cache locally for speed.
- Forward to a public DoT resolver.
- No plugins yet, no logging, no
vars:
PROFILE: local
server:
listen:
dns:
udp: {enabled: "true", host: "127.0.0.1", port: 5353}
cache:
module: memory # Default
upstreams:
strategy: failover
endpoints:
- host: 1.1.1.1
port: 853
transport: dot
tls: {server_name: "cloudflare-dns.com", verify: true}
logging:
python:
level: info
plugins: []Goals:
- Listen on all interfaces.
- Use filter+lists to block ads.
- Use a second filter instance as a stricter allowlist for kids.
- Route internal corp domains to a separate upstream.
vars:
PROFILE: lan
LAN: 192.168.0.0/16
KIDS: 192.168.2.0/24
server:
listen:
dns:
udp: {enabled: true, host: 0.0.0.0, port: 53}
cache:
module: sqlite
config:
db_path: ./config/var/dns_cache.db
upstreams:
strategy: round_robin
endpoints:
- host: 9.9.9.9
port: 53
transport: udp
- host: 1.1.1.1
port: 53
transport: udp
logging:
python:
level: info
async: true
query_log_only: false
backends:
- id: lan-log
backend: sqlite
config:
db_path: ./config/var/stats_lan.db
stats:
enabled: true
source_backend: lan-log
plugins:
- type: lists
id: blocklists
config:
pre_priority: 20
download_path: ./config/var/lists
interval_days: 1
urls:
- https://example.com/ads.txt
- type: filter
id: adblock # Ad block for everyone
config:
pre_priority: 40
default: allow
blocked_domains_files:
- ./config/var/lists/ads.txt
- type: filter
id: kids # Filter just for the kids.
config:
pre_priority: 50
targets: ${KIDS}
default: deny
allowed_domains:
- homework.example.org
- library.example.org
deny_response: ip
deny_response_ip4: 0.0.0.0
- type: router
id: corp-router
config:
pre_priority: 80
routes:
- suffix: corp
upstreams:
- host: 192.168.100.53
port: 53Goals:
- Persistent DNS cache.
- Local LAN overrides via hosts, mDNS bridge, and zone records.
- Simple access control and rate limiting.
- Query-log persistence in MariaDB/MySQL (configured via logging.backends).
vars:
PROFILE: smb
LISTEN: "0.0.0.0"
LAN: 192.168.0.0/16
FLOOR1: 192.168.10.50
FLOOR2: 192.168.20.50
FLOOR1_NET: 192.168.10.0/24
FLOOR2_NET: 192.168.20.0/24
server:
listen:
dns:
udp: {enabled: true, host: ${LISTEN}, port: 53}
tcp: {enabled: true, host: ${LISTEN}, port: 53}
cache:
module: sqlite
config:
db_path: ./config/var/dns_cache.db
upstreams:
strategy: failover
endpoints:
- host: dot1.myisp.example
port: 853
transport: dot
tls: {server_name: dot1.myisp.example, verify: true}
- host: dot2.myisp.example
port: 853
transport: dot
tls: {server_name: dot2.myisp.example, verify: true}
logging:
python:
level: info
async: true
query_log_only: false
backends:
- id: mariadb
backend: mariadb
config:
host: db.internal
port: 3306
user: foghorn
database: foghorn_stats
stats:
enabled: true
source_backend: mariadb
plugins:
- type: acl
id: lan-only
config:
pre_priority: 10
default: deny
allow:
- ${LAN}
- type: hosts
id: office-hosts
config:
pre_priority: 20
file_paths:
- /etc/hosts
- ./config/hosts.office
- type: mdns
id: office-mdns
config:
pre_priority: 30
domain: 'devices.mycorp'
ttl: 120
- type: zone
id: printers-floor1
config:
pre_priority: 40
targets: ${FLOOR1_NET}
records:
- 'printer.corp|A|300|${FLOOR1}'
- type: zone
id: printers-floor2
config:
pre_priority: 41
targets: ${FLOOR2_NET}
records:
- 'printer.corp|A|300|${FLOOR2}'
- type: docker
id: lan-docker
config:
pre_priority: 50
targets: ${LAN}
endpoints:
- url: unix:///var/run/docker.sock
ttl: 60
- type: rate
id: smb-rate
config:
pre_priority: 5 # Run first thing
mode: per_client
window_seconds: 10
min_enforce_rps: 20.0
deny_response: refusedGoals:
- Redis or Memcached cache for large edge deployments.
- Multiple PostgreSQL statistics backends plus Influx logging.
- Heavy use of plugins (router, filter, docker, mdns, zone).
- Fine-grained client targeting and per-plugin priorities.
vars:
PROFILE: enterprise
LISTEN: 0.0.0.0
LAN: 10.0.0.0/16
OFFICE: 10.10.0.0/16
OFFICE_REMOTE: 10.20.0.0/16
server:
listen:
dns:
udp: {enabled: true, host: ${LISTEN}, port: 53}
tcp: {enabled: true, host: ${LISTEN}, port: 53}
dot:
enabled: true
host: ${LISTEN}
port: 853
cert_file: /etc/foghorn/tls/server.crt
key_file: /etc/foghorn/tls/server.key
cache:
module: redis # redis | memcached | sqlite | memory | mongodb | none
config:
url: redis://redis-cache.internal:6379/0
namespace: foghorn:dns_cache:
upstreams:
strategy: round_robin
max_concurrent: 2
endpoints:
- host: 10.0.0.53
port: 53
transport: udp
pool:
max_connections: 64
idle_timeout_ms: 30000
- host: 10.0.1.53
port: 53
transport: udp
pool:
max_connections: 64
- host: 10.0.2.53
port: 853
transport: dot
tls: {server_name: dns.corp.example, verify: true}
pool:
max_connections: 32
logging:
python:
level: info
async: true
backends:
- id: pg_primary
backend: postgr - id: pg_reporting
backend: postgres
config:
host: pg-reporting.internal
port: 5432
user: foghorn_ro
database: foghorn_stats
- id: influx-logging
backend: influx
config:
write_url: http://metrics.internal:8086/api/v2/write
bucket: dns
org: infra
stats:
enabled: true
source_backend: pg_primary
plugins:
- type: acl
id: lan-only
config:
pre_priority: 10
default: deny
allow:
- ${LAN}
- type: docker
id: lan-docker
config:
pre_priority: 20
targets: ${LAN}
endpoints:
- url: unix:///var/run/docker.sock
ttl: 60
- type: mdns
id: enterprise-mdns
config:
pre_priority: 30
domain: 'devices.lan'
ttl: 120
- type: zone
id: zone-1-office
config:
pre_priority: 40
targets: ${OFFICE}
file_paths:
- ./config/zones.d/zone-1.zone
- type: zone
id: zone-2-remote
config:
pre_priority: 41
targets: ${OFFICE_REMOTE}
file_paths:
- ./config/zones.d/zone-2.zone
- type: router
id: corp-router
config:
pre_priority: 60
routes:
- suffix: corp.example
upstreams:
- host: 10.1.0.53
port: 53
- type: filter
id: global-filter
config:
pre_priority: 80
default: allow
blocked_domains_files:
- ./config/var/lists/global_block.txtFrom here you can mix and match plugins, caches, and stats backends to shape Foghorn into exactly the DNS service you need.
