docker-compose.unbound.yml
Rama master · réplica de lectura del repositorio original.
# 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 "</dev/tcp/127.0.0.1/5300"']
interval: 15s
timeout: 5s
retries: 5
start_period: 30s
logging:
driver: 'json-file'
options:
max-size: '10m'
max-file: '3'
compress: 'true'
api:
environment:
<<: *unbound-env
networks:
- default
- unbound-net
depends_on:
unbound:
condition: service_healthy
worker:
environment:
<<: *unbound-env
networks:
- default
- unbound-net
depends_on:
unbound:
condition: service_healthy
scheduler:
environment:
<<: *unbound-env
networks:
- default
- unbound-net
depends_on:
unbound:
condition: service_healthy
networks:
unbound-net:
driver: bridge
ipam:
config:
# Must not overlap any network already on this Docker host: a
# collision aborts `up -d` with "invalid pool request: Pool
# overlaps with other one on this address space", before any
# container starts.
#
# 10.89.0.0/24 is deliberately OUTSIDE Docker's default address
# pool (172.16.0.0/12). The previous literal 172.21.0.0/24 lived
# inside that pool, so any other user-defined network on the host
# could collide with it — which is exactly what broke the README
# quick-start on stock Docker Desktop. When it still collides (for
# example with a host LAN on the same range), override the whole
# thing with DOMINUS_UNBOUND_SUBNET.
- subnet: ${DOMINUS_UNBOUND_SUBNET:-10.89.0.0/24}