# DOMINUS Unbound Recursor — ADR-0072 Hardened DNS Architecture. # # Single source of truth for DNS: one properly configured Unbound recursive # resolver with DNSSEC validation, DoT upstream, and anycast-free resolution. # Replaces the multi-leg consensus architecture (DoH/DoT/tertiary). # # Usage (Community): # docker compose -f docker-compose.yml -f docker-compose.unbound.yml up -d # # Usage (Cloud): # docker compose -f docker-compose.yml -f docker-compose.prod.yml -f docker-compose.unbound.yml up -d # # The recursor listens on port 5300 (plain UDP/TCP) and 853 (DoT) on the # compose subnet. Application services query it via # DNS_UNBOUND_HOSTS=unbound:5300 with DNS_UNBOUND_ENABLED=true (default). # That hop is PLAIN DNS on the internal compose network — node:dns has no DoT # capability, so DNS_UNBOUND_TLS is reserved for a future transport and has # no effect today (see its JSDoc in src/config.ts). The DoT that is actually # active is Unbound's own upstream leg to Cloudflare/Google/Quad9 below. # # Privacy: with this architecture, NO candidate domain name leaves the host # except to the pinned Unbound recursor. The recursor itself queries # authoritative servers directly (root → TLD → authoritative) with DNSSEC # validation, eliminating the commercial signal leakage of public DoH/DoT. # # Upstreams (DoT): Cloudflare (1.1.1.1), Google (8.8.8.8), Quad9 (9.9.9.9) # are used as fallback forwarders when authoritative resolution fails or for # zones not in the cache. All DoT connections use TLS 1.3 with certificate # validation (tls-upstream: yes). # # Image (ADR-0046): mvance/unbound@sha256:... — community-maintained image # recommended by the Unbound project, digest-pinned. Multi-arch (amd64/arm64). x-unbound-env: &unbound-env DNS_UNBOUND_ENABLED: 'true' DNS_UNBOUND_HOSTS: 'unbound:5300' # The sidecar provides validated resolution, so strict mode is safe and # desired here on both editions (ADR-0075 hardened path). The base file # leaves STRICT unset for the context-aware default (ADR-0076); this # overlay opts into the hardened posture explicitly. DNS_UNBOUND_STRICT: 'true' # Reserved for a future app-to-Unbound DoT transport; currently a no-op # (see JSDoc in src/config.ts). Left false to not overclaim encryption. DNS_UNBOUND_TLS: 'false' DNS_UNBOUND_TIMEOUT_MS: '1500' DNS_PARKING_CHECK_ENABLED: 'false' services: unbound: image: mvance/unbound@sha256:76906da36d1806f3387338f15dcf8b357c51ce6897fb6450d6ce010460927e90 restart: always # No static ipv4_address on unbound-net: nothing addresses this service # by IP (the app connects to `unbound:5300` by name), and a hardcoded # address silently falls outside the range when DOMINUS_UNBOUND_SUBNET # is changed, which fails `up -d` with an address-out-of-range error. networks: - unbound-net deploy: resources: limits: cpus: '0.25' memory: 512M reservations: cpus: '0.1' memory: 128M volumes: - ./deploy/unbound/unbound.conf:/opt/unbound/unbound.conf:ro # NOTE: `command` replaces the image CMD (`/unbound.sh`), so the image # entrypoint's config generation and `unbound-anchor` bootstrap never run. # The anchor bootstrap is reproduced here explicitly: without a root # trust anchor the validator treats every zone as insecure and the app's # negative-control probe cannot prove DNSSEC validation — strict boot # fails closed. The anchor path is chroot-relative (`var/root.key` under # /opt/unbound/etc/unbound): an absolute /tmp path fails checkconf # because unbound resolves it inside the chroot. Ownership mirrors the # image entrypoint (_unbound) so RFC 5011 auto-updates keep working on # long-lived deployments. The fetch is best-effort (`|| true`): unbound # itself performs RFC 5011 tracking from the initial key once it has # egress, and a missing key fails visibly at the app readiness probe # instead of wedging container startup on exit codes. command: - sh - -c - mkdir -p /opt/unbound/etc/unbound/var && chown _unbound:_unbound /opt/unbound/etc/unbound/var && /opt/unbound/sbin/unbound-anchor -a /opt/unbound/etc/unbound/var/root.key || true; exec unbound -d -c /opt/unbound/unbound.conf # Liveness probe via a bash TCP connect to the plain-DNS listener. # The previous `ps aux | grep unbound` probe always failed: the # mvance/unbound image ships no `ps` binary (exit 127), so the service # never became healthy and every compose profile depending on it # (E2E, smoke) failed at startup. A TCP connect proves the accept # loop is alive without requiring upstream egress (a `drill` query # would couple container health to external network/DNSSEC state). healthcheck: test: ['CMD-SHELL', 'bash -c "