This document defines what anchord must do to be considered complete, independent of how it does it. Use it as the acceptance criteria for v1.0 and as the canonical source of truth when implementation and README drift.
Note (2026-05-18): The v2 pivot from SPEC-v2-DRAFT.md has landed on
main. Macvlan ownership moved from anchord to Docker; anchord now joins an existingexternal: truemacvlan network and runs only the L3/L4 surface. The functional requirements below remain authoritative; the §2.1 deltas (DHCP becomes one of three address modes, macvlan child no longer anchord's concern) are spelled out in the v2 draft. References to "owns the macvlan child" in this document should be read as "joins the macvlan network".
One Compose project = one classical server.
Every observable property of the system must be consistent with the fiction that the project is a single bare-metal host with one IPv4 + one IPv6, running multiple services on different ports.
If a behavior would let an outside observer distinguish "this is actually several containers", that's a bug.
- F-1 Project obtains exactly one IPv4 on the configured macvlan VLAN.
Three sourcing modes selectable via
ANCHORD_ADDRESS_MODE(see SPEC-v2-DRAFT.md):bootstrap(Docker-assigned, default),dhcp-refresh(start on bootstrap, replace with DHCP lease), orslaac-ra-only(Docker-assigned, kernel-driven v6). - F-2 Project obtains zero or one IPv6 (SLAAC, optional stateful DHCPv6 in dhcp-refresh, or Docker-assigned via macvlan IPAM). v6-less environments must not block v4 operation; v4-less environments must not block v6 operation. v4 and v6 are independent throughout — DNAT/MASQUERADE tables exist for both families unconditionally and every address mode is dual-stack capable.
- F-3 External-interface identity is stable across container
recreations. The MAC is declared by the operator via
mac_address:in compose; the DHCP client-id (in dhcp-refresh) is derived fromANCHORD_DHCP_HOSTNAME, so reservation stickiness is independent of the MAC. - F-4 The DHCP hostname announced equals the project name unless overridden.
- F-5 DHCP lease renewals must not interrupt established connections.
- F-6 If DHCP fails at startup, anchord retries with exponential backoff
capped at a configurable maximum (default 5 minutes). Only applies in
dhcp-refreshmode;bootstrapandslaac-ra-onlynever fail this way because they do not run a DHCP client.
- F-7 A container in the same compose project, labelled with
anchord.expose: "<proto>/<port>[,…]", receives all inbound traffic to the project's external IP on those ports. - F-8 Multiple labelled containers may coexist; each (proto, port) tuple maps to exactly one container. Conflicts are reported as a startup error for the offending container, not silently resolved.
- F-9 The original client source IP (v4 and v6) is preserved through DNAT — no MASQUERADE on inbound paths.
- F-10 Both TCP and UDP are supported.
- F-11 IPv6 exposure is automatic by default (mirrors v4 rules onto the
AAAA address). Per-container opt-out via
anchord.expose.v6: off. - F-47 Hairpin DNAT: a sibling container that connects to the
project's external IP on a labelled port reaches the same backend
as an external client does. Implemented by scoping prerouting DNAT
to
daddr type local(matches any address owned by the anchor, regardless of ingress interface) and adding a postrouting SNAT on hairpinned packets so the backend's reply routes back via the anchor instead of L2-bypassing it. F-9 only applies to LAN ingress — hairpin replies necessarily lose the original sibling's source IP, which is acceptable because the "client" is in the same project and spam-scoring / audit / allowlist concerns don't apply. v4 always uses the fib-based guard; v6 uses it when the kernel loadsnft_fib_ipv6(probed at Setup), else falls back to the legacyiifname == extIfacepredicate that scopes DNAT to LAN ingress only. All major production distros (TrueNAS SCALE, Debian, Ubuntu, Alpine on a real kernel) shipnft_fib_ipv6; the fallback exists for stripped-down dev/CI kernels (WSL2).
- F-12 All containers in the project egressing through anchord appear externally with the same source IP (v4 and, where applicable, v6).
- F-13 Egress source IP automatically tracks the current DHCP lease — no reconfiguration needed on lease change.
- F-13a Egress works on
internal: trueDocker bridges (which carry no default route) without the operator pinning the network-anchor's transit IP or writing routing logic in composecommand:strings.
- F-14 Backends are discovered solely from Docker labels, not from config files or static IP tables.
- F-15 Backend IP changes (container restart, recreation) are reflected in the NAT state within 5 seconds under normal load, without manual intervention.
- F-16 A new container appearing with valid
anchord.exposelabels is reachable from outside within 5 seconds of becoming healthy. - F-17 A removed container is no longer reachable within 5 seconds; its former IP is flushed from conntrack so live connections re-evaluate.
- F-18 Reconciliation is driven by Docker events (push) with a periodic polling fallback (default 30 seconds) to recover from missed events.
- F-19 State changes are applied as atomic nftables map replacements; no observable window where DNAT is broken.
- F-20 anchord exits cleanly on SIGTERM/SIGINT, removing its nftables
tables and (in
dhcp-refresh) sendingDHCPRELEASEfor the active lease. The macvlan interface itself is owned by Docker and is reaped when the container exits — anchord does not add or delete the link. - F-21 All log output is structured JSON to stdout.
- F-22 All configuration is via environment variables. No config files.
- F-23 The network-anchor runs as a single container, requiring only
CAP_NET_ADMINand access to a Docker socket (read-only via socket-proxy is the documented default).
The same anchord binary, invoked with ANCHORD_MODE=service-anchor
(or equivalently command: [service-anchor]), runs in service-anchor
mode. This mode is what makes egress and inbound-response paths work
on internal: true Docker bridges (see F-13a).
- F-24 Service-anchor mode resolves the network-anchor's hostname
(
anchordby default; configurable viaANCHORD_GATEWAY_HOSTNAME) via the project's Docker DNS. - F-25 On startup, service-anchor mode installs a default route in its own network namespace via the resolved address — for v4, v6, or both, depending on which families resolve. If neither resolves at startup, it retries with backoff and does not exit.
- F-26 Service-anchor mode re-resolves on a configurable cadence
(default 5 s,
ANCHORD_GATEWAY_RESOLVE_INTERVAL) and replaces its installed default route atomically (netlinkRouteReplace) when the resolved address changes. Existing non-default routes in the namespace are not touched. - F-27 Service-anchor mode requires
CAP_NET_ADMINand read-only access to the Docker DNS resolver (i.e., normal Compose-network membership). It does not need access to the Docker socket. - F-28 Service-anchor mode keeps the namespace alive for the
lifetime of the loop — it is the namespace owner that application
containers join via
network_mode: service:<anchor>. - F-29 On SIGTERM/SIGINT, service-anchor mode removes the default route(s) it installed and exits cleanly.
Both modes expose a Prometheus-format metrics endpoint over HTTP.
Same listener will host /healthz and /readyz (see §2.8) — one
port, three paths.
- F-30 Each anchord container (network-anchor or service-anchor)
serves
/metricsin Prometheus text exposition format on a single configurable address. Default127.0.0.1:9090— loopback-only, to keep the surface off the LAN-facing macvlan child. Operators who want project-internal scraping setANCHORD_METRICS_ADDR=:9090(or a specific transit IP). SettingANCHORD_METRICS_ADDR=""disables the listener entirely. - F-31 All anchord-emitted metrics use the
anchord_prefix. Label cardinality is bounded — labels are restricted tofamily ∈ {v4,v6},proto ∈ {tcp,udp},outcome ∈ {ok,error},result ∈ {ok,error},source ∈ {event,poll}. No per-container, per-IP, or per-port labels. - F-32 A failure to bind the metrics listener is logged at warn but does not abort startup of the data plane. Metrics are observability, not critical path.
The required surface (Prometheus names, types, labels):
Network-anchor mode:
| Metric | Type | Labels |
|---|---|---|
anchord_reconcile_duration_seconds |
histogram | — |
anchord_reconcile_total |
counter | result |
anchord_dhcp_lease_remaining_seconds |
gauge | family |
anchord_dhcp_acquired_total |
counter | family, outcome |
anchord_dhcp_client_restarts_total |
counter | family |
anchord_dnat_entries |
gauge | family, proto |
anchord_conntrack_flushes_total |
counter | family |
anchord_docker_events_total |
counter | source |
anchord_build_info |
gauge (constant 1) | version, commit |
Service-anchor mode:
| Metric | Type | Labels |
|---|---|---|
anchord_gateway_resolve_total |
counter | family, outcome |
anchord_gateway_route_replaces_total |
counter | family |
anchord_default_route_present |
gauge (0/1) | family |
anchord_build_info |
gauge (constant 1) | version, commit |
Both modes serve /healthz (liveness) and /readyz (readiness) on
the same HTTP listener as /metrics. Disabling the metrics listener
(ANCHORD_METRICS_ADDR="") disables health endpoints too — they
share one socket by design.
- F-33
/healthzreturns200 OKwith bodyokwhenever the process is running and serving HTTP. It is a pure liveness signal: it does not check downstream state, so a stuck reconciler or a lost lease does NOT flip it. Liveness probes failing on transient data-plane issues would cause restart storms — wrong shape. - F-34
/readyz(network-anchor) returns200 OKwith bodyreadyiff (a) the nftables tables have been installed AND (b) at least one Reconcile has completed successfully. While not ready, returns503 Service Unavailablewith a body listing the unmet conditions. DHCP lease state is not part of readiness — the DNAT path is interface-bound and works regardless of external IP, and thenone-DHCP scenario must be reachable to "ready". - F-35
/readyz(service-anchor) returns200 OKwith bodyreadyiff at least one default route (v4 OR v6) has been successfully installed. While not ready, returns503with the unmet conditions. This is the gate Composedepends_on: condition: service_healthyneeds so an app container that joins vianetwork_mode: service:<anchor>does not start before egress works. - F-36 Both endpoints respond in
text/plain; format is newline-separated<key>: <value>lines. JSON is overkill for two bits.
- N-1 The container image is ≤ 20 MB compressed.
- N-2 Resource footprint at idle: ≤ 30 MB RSS, ≤ 1% of one CPU.
- N-3 Reconciliation latency from Docker event to nftables map updated: ≤ 500 ms p99 under normal load.
- N-4 No external runtime dependencies beyond the host kernel (≥ 4.18) and Docker daemon (≥ 20.10).
- N-5 The metrics endpoint adds ≤ 5 MB to RSS at idle. Process
Go-runtime metrics are emitted by
client_golang; no additional collectors or exporters bundled.
- Layer-7 routing, TLS termination, hostname-based dispatch.
- Multi-host meshing or replication.
- Automatic certificate management.
- Web UI, dashboard, or interactive CLI.
- Automatic firewall hardening beyond what DNAT/MASQUERADE require.
- Support for non-DHCP external addressing (static, SLAAC-only, etc.). May come in a later release.
- iptables-legacy fallback. nftables only.
- Running outside a container (no "anchord on the host" mode).
- Host ↔ project communication via the macvlan — a Linux kernel limitation; document the macvlan-shim workaround, do not work around it.
- Multiple anchord instances per project — undefined behavior. Document as unsupported, do not detect or guard.
- Two projects requesting the same DHCP hostname — DHCP server's problem, not anchord's.
These are the integration tests that, when all green, signal v1.0.
- Compose up a project with anchord + one labelled service-anchor.
- Within 30s, the service-anchor is reachable from another VLAN host on the labelled port.
- The project's external IP is visible in the DHCP server's lease table under the configured hostname.
- Connect to a labelled TCP port from a known source IP.
- The service-anchor logs the connection.
- The logged source IP equals the original client IP (not the transit-bridge gateway).
- Project is running and reachable.
docker restart <service-anchor>.- Within 5s of the container being healthy, external connections succeed again. Existing connections to the old IP are gracefully terminated (not held open in conntrack).
- Force the DHCP server to issue a new IP to the project.
- Outbound traffic from the project carries the new source IP within one renewal cycle.
- Inbound traffic to the new IP works without restart.
- Run two compose projects on the same host, both using anchord against the same VLAN parent.
- Each gets a distinct DHCP lease.
- Each is independently reachable. Neither's NAT state interferes with the other.
docker compose down.- anchord's nftables tables are gone from the host.
- The macvlan child interface is gone.
- The DHCP lease is released.
- One service-anchor labelled
tcp/25,tcp/465,tcp/587. - All three ports are independently reachable.
- All three see correct client source IPs.
- Project has both v4 and v6 external addresses.
- Service-anchor labelled
tcp/443without v6 override. - Connections on v4:443 and v6:443 both succeed and reach the same container.
- Project is running, service-anchors reachable.
docker compose up -d --force-recreate anchord— Docker IPAM may hand the network-anchor a different transit IP.- Within
ANCHORD_GATEWAY_RESOLVE_INTERVAL+ 1 s, every service-anchor's default route is updated to the new transit IP. - Inbound and outbound paths work without restarting any service-anchor or application container.