Unbound DNS resolver image with DoT/DoH forwarders preconfigured
50K+
A Unbound DNS resolver in a container, based on Debian 13. Unbound runs as a fully recursive, validating and caching resolver: it walks the DNS hierarchy from the root servers itself instead of forwarding to an upstream provider.
unbound-anchor and the
root hints are refreshed on every container start.docker logs.deny-any,
rate limiting, and hidden identity/version.Dieses Image bringt keine eigene /etc/unbound/unbound.conf mit. Die Datei
des Debian-Pakets bleibt unangetastet; sie enthaelt nichts ausser:
include-toplevel: "/etc/unbound/unbound.conf.d/*.conf"
Die Einstellungen dieses Images liegen als Drop-in unter
/etc/unbound/unbound.conf.d/unbound-docker.conf. Unbound arbeitet den Glob
alphabetisch sortiert ab, und bei skalaren Optionen gewinnt der zuletzt
gelesene Wert. Daraus ergeben sich drei Schichten, allein ueber die
Dateinamen:
| Datei | Herkunft |
|---|---|
remote-control.conf | Debian-Paket |
root-auto-trust-anchor-file.conf | Debian-Paket |
unbound-docker.conf | dieses Image |
zz-*.conf | Anpassungen pro Deployment |
Ein Deployment kann damit jede Einstellung des Images ueberschreiben — es
haengt eine zz-*.conf ein, in Kubernetes etwa als ConfigMap per subPath:
# /etc/unbound/unbound.conf.d/zz-mailstack.conf
server:
do-ip6: no
prefer-ip6: no
Ziffernpraefixe waeren hier falsch: Ziffern sortieren vor Buchstaben, ein
10-*.conf laege also vor den Paketdateien und wuerde von ihnen
ueberschrieben. Daher unbound-docker (nach remote- und root-) und fuer
Deployments zz- (nach unbound-).
Zwei Folgen dieser Reihenfolge:
control-enable bleibt no, obwohl remote-control.conf aus dem Paket
yes sagt — unbound-docker.conf wird danach gelesen.root-auto-trust-anchor-file.conf bleibt wirksam, weil das Image
auto-trust-anchor-file nicht selbst setzt. Die DNSSEC-Validierung ist
damit aktiv, und docker-entrypoint.sh legt den Anker unter
/var/lib/unbound/root.key an — dort erwartet ihn das Paket, waehrend
Debians unbound-anchor ohne -a nach /usr/share/dns/root.key schreiben
wuerde. Fehlt die Datei, startet Unbound nicht.docker run -d --name unbound \
-p 53:53/udp -p 53:53/tcp \
unbound-docker
The container listens on 0.0.0.0:53 for UDP and TCP.
docker-entrypoint.sh runs before Unbound itself:
unbound-anchor fetches or updates the DNSSEC root trust anchor.internic.net to
/etc/unbound/root.hints.unbound-control-setup generates the control certificates.exec, so it becomes PID 1 and receives signals directly.Steps 1 and 2 need outbound network access at start time. Without it the container falls back to the root hints baked into the image at build time.
The shipped unbound.conf is a complete configuration, not a fragment. To change
it, mount your own file over /etc/unbound/unbound.conf.
Recursion is only allowed for loopback and the private address ranges (RFC 1918, ULA, IPv6 loopback). Everything else is refused, so the resolver is not an open resolver even if port 53 is exposed accidentally.
| Setting | Value |
|---|---|
num-threads | 3 |
msg-cache-size | ~136 MB |
rrset-cache-size | ~272 MB |
cache-min-ttl / cache-max-ttl | 300 s / 86400 s |
prefetch / prefetch-key | on |
serve-expired | on |
serve-expired-ttl | 2592000 s (30 days) |
serve-expired-client-timeout | 1800 ms |
serve-expired-reply-ttl | 30 s |
prefetch refreshes popular entries before they expire, and serve-expired
answers from a stale entry while the refresh is in flight — both trade a little
freshness for noticeably lower latency.
The three serve-expired-* values turn that from a latency trick into an
outage measure. Should the authoritative nameservers for a zone become
unreachable, no fallback resolver helps — every resolver asks those same
machines. A stale answer is the only thing left, and these decide how long it
stays available and how quickly a client gets it:
serve-expired-ttl caps how long after expiry an answer may still be served.
Set explicitly because the built-in default has differed between Unbound
versions, and an outage lasting longer than a day would otherwise still fail.
30 days is measured against a typical RRSIG validity window — beyond it a
stale answer would no longer validate anyway.serve-expired-client-timeout is the actual failover switch: hand out the
stale answer after 1.8 s and keep trying upstream in the background, instead
of making the client wait out the full upstream timeout.serve-expired-reply-ttl keeps stale replies short-lived so clients come back
promptly once someone answers again.The cache sizes assume a host with a few hundred MB to spare. Lower them in your own config if the container is memory-constrained.
qname-minimisation sends only the minimum label set to each authoritative server.use-caps-for-id and unwanted-reply-threshold raise the cost of spoofing attempts.private-address entries block DNS rebinding: public names may not resolve to
private addresses.harden-* options reject downgraded, stripped or malformed DNSSEC responses.hide-identity / hide-version suppress CHAOS class fingerprinting.verbosity: 1 with query, reply and SERVFAIL logging enabled. This is chatty on a
busy resolver — set log-queries: no and log-replies: no in your own config if
you only care about failures.
control-enable is no. The entrypoint still generates the control certificates,
so enabling unbound-control only requires flipping that one setting.
services:
unbound:
image: unbound-docker
restart: unless-stopped
ports:
- "53:53/udp"
- "53:53/tcp"
volumes:
- ./unbound.conf:/etc/unbound/unbound.conf:ro
Mounting the configuration read-only is safe: the entrypoint writes only the trust anchor, the root hints and the control certificates, none of which live in that file.
docker build -t unbound-docker .
The image is based on Debian 13 and adds unbound, unbound-anchor, curl,
bash, openssl, ldnsutils and tzdata.
This project uses pre-commit for style and quality checks, Conventional
Commits for the history, and Renovate for dependency updates.
MIT — see LICENSE.
Content type
Image
Digest
sha256:6adc29dad…
Size
68.4 MB
Last updated
about 11 hours ago
docker pull ricariel/unbound