This guide walks through building a Foghorn DNS configuration incrementally. You’ll start with a minimal DNS forwarder and gradually add plugins to achieve a Pi-hole-like experience: blocklists, local hosts, zone records, and dynamic sources such as Docker.
Every Foghorn configuration begins with:
- Listeners – where Foghorn accepts DNS queries
- Upstreams – DNS servers to forward unresolved queries to
Minimal configuration:
server:
listen:
udp:
enabled: true
host: 0.0.0.0
port: 5335
upstreams:
endpoints:
- host: 8.8.8.8
port: 53
transport: udpFoghorn listens on UDP port 5335 and forwards all DNS queries to Google DNS.
+--------+ +-----------+ +------------+
| Client | → | UDP :5335 | → | 8.8.8.8:53 |
+--------+ +-----------+ +------------+
At this stage, Foghorn behaves as a simple caching DNS proxy. See CachePlugin
for more information about caching.
Plugins extend Foghorn’s behavior. Each plugin is listed under plugins and
configured independently. All plugins support targets with ips/ignore_ips
to decide whether the plugin applies to a given client.
Common plugin categories include:
- Data sources – download lists, read files, discover hosts
- Decision logic – filter, block, allow, override
- Authoritative data – zones, local records, override upstream
To build a Pi-hole-like setup, we start with two core plugins:
FileDownloaderFilter
Pi-hole-style blocking relies on regularly updated domain lists. In Foghorn,
this is handled by the FileDownloader plugin.
Example configuration:
plugins:
- type: file_downloader
hooks:
setup: { priority: 10 } # Run early so files are available for other plugins
config:
download_path: ./config/var/lists
urls:
- https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
interval_days: 1
hash_filenames: true- Downloads a hosts-style blocklist
- Refreshes it every 24 hours
- Stores it locally for other plugins to consume
- What if I have multiple files named "hosts.txt"?
See the option
hash_filenames, which uses the first 12 digits of the sha1 of the url. In our example here the url hashes tob14d900f67a6.....so the file will be saved as "hosts-b14d900f67a6.txt"
At this point, no domains are blocked yet; the data is only being fetched.
The Filter plugin evaluates every DNS query and decides whether it should be
allowed, blocked, or forwarded upstream. Decisions are cached.
Example configuration:
plugins:
- type: filter
hooks:
setup: { priority: 20 } # Load after the files have been downloaded
priority: 20 # Run early (pre + post)
config:
default: allow
deny_response: nxdomain
blocked_domains_files:
- ./config/var/lists/* # Globs supported
# If using hashed filenames:
# - ./config/var/lists/hosts-....-.txt
# If not using hashed filenames:
# - ./config/var/lists/hosts.txt- Uses an allow-by-default policy
- Blocks domains found in the downloaded blocklist(s)
- Returns
NXDOMAINfor blocked queries (similar to Pi-hole) - Next:
- If desired add the
targetsoption (available on all plugins). This lets you choose which client IPs the filter applies to. - Add more filter files or rules
- If desired add the
DNS decision flow is now:
+-------+ +---------+ +----------+
+ Query | → | Filter | → | Upstream | → answer
+-------+ +---------+ +----------+
|
+--→ Blocked → NXDOMAIN
To support local DNS overrides (such as /etc/hosts), enable the EtcHosts
plugin.
Example configuration:
plugins:
- type: etc_hosts
hooks:
pre_resolve: 10 # Resolve local names first
config: # Default values below
file_paths:
- /etc/hosts
ttl: 300- Reads one or more hosts files
- Creates authoritative DNS answers
- Adds PTRs for reverse lookups
/etc/hosts
|
v
+-----------+ +----------+
| EtcHosts | → | Upstream | → answer
+-----------+ +----------+
|
+→ Local DNS answers
The following configuration combines downloading blocklists, local hosts, and filtering behavior.
server:
listen:
udp:
enabled: true
host: 0.0.0.0
port: 53
upstreams:
endpoints:
- host: 1.1.1.1
port: 53
transport: udp
plugins:
- type: file_downloader
hooks:
setup: { priority: 10 }
config:
download_path: ./config/var/lists
urls:
- https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts
interval_days: 1
hash_filenames: true
- type: etc_hosts
hooks:
pre_resolve: 10 # Resolve local names first
config:
file_paths:
- /etc/hosts
ttl: 300
- type: filter
hooks:
setup: { priority: 20 }
priority: 20
config:
default: allow
deny_response: nxdomain
blocked_domains_files:
- ./config/var/lists/hosts-*With all plugins enabled (and with EtcHosts configured to run before Filter),
DNS resolution works as follows:
+----------+ +-------------+
| EtcHosts | | Block Lists |
+----------+ +-------------+
↓ ↓
Client → EtcHosts → Filter → Forward Upstream → Answer
| |
| +→ Query Blocked → NXDOMAIN+
+→ Answer
For static internal DNS zones, use the ZoneRecords plugin.
Example configuration:
plugins: # priorities default to 100 (out of 255)
- type: zone
config:
file_paths:
- ./config/var/zone-records.txt
ttl: 300Example ./config/var/zone-records.txt entries:
router.home|A|300|192.168.1.1
nas.home|A|300|192.168.1.10
30.1.168.192.in-addr.arpa|PTR|server.home
override.some.domain|CNAME|60|my.other.domain
+----------+ +-------------+ +--------------------+
| EtcHosts | | Block Lists | | Zone Record / File |
+----------+ +-------------+ +---------------------
↓ ↓ ↓
Client → EtcHosts → Filter → Zone Records → Forward Upstream → Answer
| |
| +→ Query Blocked → NXDOMAIN
+→ Answer
To automatically generate DNS records for Docker containers, use the
DockerHosts plugin.
Example configuration:
plugins:
- type: docker_hosts
config:
# Optional: append a suffix so container "web" becomes "web.docker.local".
suffix: docker.lan
endpoints:
- url: unix:///var/run/docker.sock
reload_interval_second: 30
ttl: 300- Watches running Docker containers
- Automatically creates DNS records for them
container_name.docker.local → container IP
If you want to use a different IP (for example, the host IP instead of the container IP), you can use the use_ipv4 or use_ipv6 option per endpoint.
+-------------+ +------------+ +--------------------+
| Block Lists | | /etc/hosts | | Docker instance(s) |
+-------------+ +------------+ +--------------------+
↓ ↓ ↓
Client → Filter → EtcHosts → DockerHosts → Upstream → Answer
|
+→ Blocked → NXDOMAIN
You have built a Pi-hole-style DNS server using Foghorn by:
- Creating a basic DNS forwarder
- Downloading blocklists
- Blocking domains with filters
- Adding local host overrides
- Defining authoritative zones
- Discovering dynamic hosts from Docker
Each step adds functionality while keeping the configuration declarative and schema-validated.
- Enable logging and metrics
- Add DoT or DoH upstreams for encrypted DNS
- Add TCP, DoT, or DoH downstreams
- Create per-client / subnet filtering policies