Skip to content

AlexCherrypi/anchord

anchord

CI Container Go Version License: MIT Ask DeepWiki

One IP per Compose project. No subnet bookkeeping. Real client source IPs.

Built for self-hosted, homelab, and small-fleet workloads that want classical "one server, one service" semantics — Mailcow, Nextcloud, Matrix/Synapse, Gitea, anything that historically ran on its own box.

anchord is a per-project network anchor for Docker Compose. It gives a Compose project a single externally-routable IP (via macvlan + DHCP, with hostname announcement) and dynamically maintains nftables DNAT rules pointing at labelled service-anchor containers — without you ever hard-coding an IP inside the project.

It exists because we wanted "one server, one service-pack" semantics back — the way it used to be when Mailcow lived on its own physical box, Nextcloud on another, and so on — but with the operational ergonomics of Compose.

Status

Beta, feature-complete. Both modes implemented, observability (metrics + health) wired in. 167/167 across unit tests + e2e covering all four DHCP scenarios plus stateful DHCPv6 (the auto-generated report at the bottom of this README is the release-readiness signal). One thing outstanding before a v1 tag: real-host validation on a Linux box with a physical VLAN sub-interface — to confirm the Docker Desktop bridge-flood workaround isn't needed in production.

(Designed in a bathtub conversation. Has held up better than that has any right to.)

The mental model

One Compose project = one classical server.

That's the whole idea. Every service-anchor inside a project shares the same externally-visible IPv4 and IPv6 — exactly as if postfix, dovecot and friends were running side-by-side on a bare-metal host called mailcow. From the outside there is no way to tell them apart; they're just ports on one machine.

Concretely:

  • Inbound traffic — clients connect to the project's single external IP. anchord's DNAT map routes each port to the right service-anchor inside. Postfix sees connections on 25/465/587, dovecot sees 143/993, both arriving on what they perceive as their own interface, with the original client source IP intact.
  • Outbound traffic — every container in the project egresses with the same source IP, via masquerade. This matters for PTR records, SPF, IP reputation, audit trails — anything where "who was that?" needs a single consistent answer.
  • Internal addresses — yes, the service-anchors do have separate Docker-bridge IPs on the transit network, because Docker has to route packets between them somehow. But that's an implementation detail. From the user's perspective, it doesn't exist.

What this implies

A given port can only point at one service-anchor. Two containers that both want to listen on 443 won't work — but that's also exactly what you'd have on a real server. If you need multiple services on the same port (e.g. multiple websites on 443), put a reverse proxy in front as a service-anchor and let it handle the layer-7 multiplexing. anchord stops at layer 4.

This is intentional: anchord doesn't try to be a reverse proxy, an ingress controller, or a service mesh. It gives you a server-shaped abstraction, and you build the rest with whatever tools fit.

How it compares

anchord lives in a niche the usual tools don't quite fill — it's not a reverse proxy, not an ingress controller, not a service mesh. It's a layer-4 NAT shim that gives a Compose project a server-shaped network identity. Quick map:

Approach One IP per project? Real source IPs preserved? DHCP / hostname on the LAN? Internal DNS service discovery?
ports: "1.2.3.4:80:80" manual no (bridge NAT mangles them) no yes
network_mode: host shared with host yes host's only no per-stack
network_mode: macvlan per service no — one per container yes per container broken (each container is its own L2 endpoint)
Traefik / Caddy / nginx in host mode no yes for HTTP(S) only no yes
Kubernetes ingress + LoadBalancer yes (per Service) depends on mode not on bare LAN yes
anchord yes yes yes yes

It's specifically built for "I want this Compose project to look like a real server on my LAN" — the problem nothing else solves cleanly. anchord stops at layer 4 by design; if you need TLS termination, hostname routing, or HTTP-aware load balancing, run a reverse proxy as a service-anchor and let it own ports 80/443.

How it looks

networks:
  transit:  { driver: bridge, internal: true }
  backend:  { driver: bridge, internal: true }

services:
  anchord:
    image: ghcr.io/alexcherrypi/anchord:latest
    cap_add: [NET_ADMIN]
    sysctls:
      # Required: anchord forwards between macvlan and transit.
      net.ipv4.ip_forward: "1"
      net.ipv6.conf.all.forwarding: "1"
      # accept_ra=2 lets the kernel still take SLAAC from upstream RAs
      # while forwarding is enabled.
      net.ipv6.conf.all.accept_ra: "2"
      # arp_ignore=1 / arp_announce=2 keep anchord-ext's IP from being
      # ARP-claimed by the macvlan parent — without these, inbound TCP
      # can land on the parent interface and miss the DNAT rule.
      net.ipv4.conf.all.arp_ignore: "1"
      net.ipv4.conf.all.arp_announce: "2"
    environment:
      ANCHORD_PROJECT: ${COMPOSE_PROJECT_NAME}
      ANCHORD_VLAN_PARENT: eth0.42
      ANCHORD_DHCP_HOSTNAME: mailcow
      DOCKER_HOST: tcp://docker-proxy:2375
    networks: [transit]

  smtp-anchor:
    image: ghcr.io/alexcherrypi/anchord:latest
    cap_add: [NET_ADMIN]
    environment:
      ANCHORD_MODE: service-anchor
    networks: [transit, backend]
    labels:
      anchord.expose: "tcp/25,tcp/465,tcp/587"

  postfix:
    image: postfix:latest
    network_mode: "service:smtp-anchor"

That's it. No IPs, no subnets, no port-mappings on the host. anchord watches the docker socket, finds containers in the same compose project that carry the anchord.expose label, and wires up nftables DNAT entries pointing at their current bridge-network IPs. When containers restart and get new IPs, the maps update atomically and stale conntrack entries are flushed.

One image, two modes

The anchord image plays two roles in a project:

  • Network-anchor (ANCHORD_MODE=network-anchor, the default). One per project. Owns the macvlan child, runs the DHCP client, and maintains the nftables NAT state.
  • Service-anchor (ANCHORD_MODE=service-anchor). One per exposed service. Resolves the network-anchor via Docker DNS, installs and maintains a default route via it, and serves as the namespace owner that real application containers join via network_mode: service:<anchor>.

Both roles run the same binary; the mode is just an env var. As an alternative spelling, command: [service-anchor] does the same as setting ANCHORD_MODE.

Architecture

For the full picture — the three-role model (network-anchor, service-anchors, backends), how traffic flows end-to-end, and the invariants the code relies on — read ARCHITECTURE.md. The sketch below is the one-screen version.

Two companion docs round out the picture: SPEC.md is the contract anchord must meet (functional requirements, acceptance scenarios, non-goals), and CONTEXT.md records the design rationale and the alternatives that were considered and rejected.

flowchart TD
    %% Three roles in vertical layers: LAN -> network-anchor ->
    %% transit-bridge -> service-anchors (+ app containers joined
    %% via netns share) -> backend-bridge -> DBs.
    %% Edge styles: solid = traffic flow, thick = bridge membership,
    %% dashed = netns share via network_mode service.

    %% Shapes by role:
    %%   [/.../]   = boundary  (the LAN, the DBs)
    %%   {{ ... }} = bridge    (Docker L2 broadcast domain)
    %%   [ ... ]   = container (anchord + service-anchors)
    %%   ( ... )   = process   (app containers — share netns, no own IP)
    LAN[/External LAN - VLAN eth0.42/]
    Anchord[anchord network-anchor mode<br>macvlan + nftables<br>DNAT-by-map + masquerade]
    Transit{{transit-bridge<br>Docker bridge, internal: true}}
    Smtp[smtp-anchor<br>service-anchor mode<br>namespace owner]
    Imap[imap-anchor<br>service-anchor mode<br>namespace owner]
    Postfix(postfix)
    Dovecot(dovecot)
    Backend{{backend-bridge<br>Docker bridge, internal: true}}
    DBs[/mysql, redis, .../]

    LAN -->|macvlan + DHCP - one IP per project| Anchord
    Anchord ==> Transit
    Transit ==> Smtp
    Transit ==> Imap
    Smtp -.->|network_mode service| Postfix
    Imap -.->|network_mode service| Dovecot
    Smtp ==> Backend
    Imap ==> Backend
    Backend ==> DBs
Loading

Three layers, by design:

  1. External — macvlan child interface in the anchord container. DHCP client runs here. MAC is deterministic from the project name (so DHCP reservations are stable across container recreations).
  2. Transit — internal Docker bridge connecting anchord to the service-anchors. internal: true ensures no Docker-managed MASQUERADE meddles with our paths.
  3. Backend — internal Docker bridge for service-to-DB traffic. Most containers live here, never see the transit network.

Why DNAT-by-map?

nftables named maps let us express the entire DNAT table as a single rule that consults a key/value lookup:

iifname "anchord-ext" meta l4proto tcp dnat to tcp dport map @dnat_tcp

When a container restarts and its IP changes, we replace the map's contents in one atomic transaction. No rule deletions, no microsecond windows where packets fall through.

Why masquerade outbound, not SNAT?

Masquerade automatically tracks the current source IP of the egress interface — so when DHCP renews into a new lease, outbound traffic just keeps working. SNAT to a literal IP would need re-pushing on every lease change.

Why no ports: mapping anywhere?

Because ports: invokes Docker's userland proxy and bridge-NAT, which both mangle source IPs. anchord's whole point is to not go through that. Inbound traffic enters the macvlan interface, hits anchord's DNAT in the kernel, and arrives at the service-anchor with the original client IP intact.

Configuration

All via environment variables.

Common (both modes)

Variable Required Default Notes
ANCHORD_MODE no network-anchor network-anchor or service-anchor. command: [service-anchor] is an equivalent override.
ANCHORD_LOG_LEVEL no info debug/info/warn/error
ANCHORD_METRICS_ADDR no 127.0.0.1:9090 Prometheus /metrics listen address. Loopback-only by default to avoid LAN exposure on the macvlan; set :9090 to scrape from other compose services. "" disables.

Network-anchor mode

Variable Required Default Notes
ANCHORD_PROJECT yes $COMPOSE_PROJECT_NAME Scope of containers anchord manages
ANCHORD_VLAN_PARENT yes Host VLAN sub-interface, e.g. eth0.42
ANCHORD_DHCP_HOSTNAME no = project name Announced to DHCP server
ANCHORD_EXT_MAC no sha256(project)[:4] prefixed 02:42: Override only if you must
ANCHORD_EXT_IFACE no anchord-ext macvlan child interface name
ANCHORD_POLL_INTERVAL no 30s Safety-net reconcile cadence
ANCHORD_DHCP_BACKOFF_MAX no 5m Max backoff between DHCP-client retries on protocol errors
DOCKER_HOST no unix socket Set to tcp://docker-proxy:2375 for socket-proxy mode

Service-anchor mode

Variable Required Default Notes
ANCHORD_GATEWAY_HOSTNAME no anchord Compose-network DNS name to look up for the network-anchor's transit IP
ANCHORD_GATEWAY_RESOLVE_INTERVAL no 5s How often the service-anchor re-resolves and reconciles its default route

Container labels

On any container that should be exposed via the project's external IP:

Label Example Notes
anchord.expose "tcp/25,tcp/465,udp/4500" Comma-separated proto/port entries
anchord.expose.v6 auto (default) / off Whether to mirror v4 rules onto AAAA

Building

git clone https://github.com/AlexCherrypi/anchord
cd anchord
go mod tidy
go build ./cmd/anchord
docker build -t anchord:dev .

Testing

The full test suite (Go unit tests + e2e harness across all four DHCP scenarios) is invoked via scripts/update-test-report.sh, which runs host-independently inside a Docker container and rewrites the auto-generated Test report block at the bottom of this README on green. See TESTING.md for the per-platform commands and the release-gate contract.

Observability

Both modes serve /metrics, /healthz and /readyz on the same listener (default 127.0.0.1:9090, loopback-only so the LAN-facing macvlan never sees it; set ANCHORD_METRICS_ADDR=:9090 for project-wide scraping or "" to disable). The surface is small and deliberately bounded — see SPEC §2.7 for the full table — the highlights operators usually want to alert on:

  • anchord_dhcp_lease_remaining_seconds{family} — alert when this drops below your renewal window. Recomputed at scrape time.
  • anchord_reconcile_total{result} — error rate of the main loop.
  • anchord_reconcile_duration_seconds — verifies SPEC N-3 (≤ 500 ms p99).
  • anchord_dnat_entries{family,proto} — sanity gauge; spikes or drops are a strong signal something is off.
  • anchord_gateway_route_replaces_total{family} (service-anchor) — how often the network-anchor's transit IP changed under us.

Label cardinality is bounded by design (no per-container, per-IP, or per-port labels) — that would leak the project's internal structure across the metrics surface, which contradicts the "one project = one server" model.

Health endpoints

Same listener, plain text:

Path Code When
/healthz always 200 ok Process is up and serving HTTP. Pure liveness signal — does not flip on data-plane issues.
/readyz (network-anchor) 200 ready Once nftables tables are installed AND the first reconcile has completed. DHCP lease state is not part of readiness — the DNAT path works without one.
/readyz (service-anchor) 200 ready Once at least one default route (v4 or v6) has been installed. Pair with a Docker HEALTHCHECK so app containers joining via network_mode: service:<anchor> wait for egress.

Both /readyz variants return 503 with the unmet conditions in the body while not ready.

Caveats and known limitations

  • Kernel ≥ 4.18 required for atomic nftables map replaces.
  • CAP_NET_ADMIN is required on every anchord container — the network-anchor for macvlan + nftables, every service-anchor for managing its own default route via netlink.
  • The service-anchor's DNS name must match ANCHORD_GATEWAY_HOSTNAME. Default is anchord, which matches the canonical service name in the example compose. If you rename the network-anchor service, set ANCHORD_GATEWAY_HOSTNAME on each service-anchor to match.
  • One network-anchor per Compose project — the design assumes per-project scoping. Running multiple in the same project will race on nftables tables.

License

MIT — see LICENSE.

Test report (auto-generated)

This block is rewritten by scripts/update-test-report.sh after a green run of the full test suite — every test below was observed to produce the listed status on the source tree whose hash is recorded here. The release pipeline rejects any tag whose recorded hash does not match the current source, so this block is the project's release-readiness signal.

  • Last verified: 2026-05-03T03:40:27Z
  • Code hash: sha256:1d4583cf6e243c3d68c3cdba61d9cc42a3165d4d8c2012adf921d32fe6e4db52
  • Flood-fix flag: E2E_BRIDGE_FLOOD_FIX=1

Summary

Suite Pass Fail Skip Total
go vet ./... clean
Go unit tests 97 0 0 97
E2E (test/e2e, 5 scenarios) 70 0 70
All tests 167 0 0 167
Go unit tests — 97/97 passed
Package Test Status
cmd/anchord TestSelectMode/ANCHORD_MODE=service-anchor
cmd/anchord TestSelectMode/explicit_network-anchor_subcommand
cmd/anchord TestSelectMode/flag-only_args_are_ignored
cmd/anchord TestSelectMode/no_args,_no_env_->_default_network-anchor
cmd/anchord TestSelectMode/subcommand_wins_over_env
cmd/anchord TestSelectMode/unknown_env_errors
cmd/anchord TestSelectMode/unknown_subcommand_errors
internal/config TestDeriveMAC
internal/config TestFingerprintDeterministic
internal/config TestGetenvDefault
internal/config TestLoadServiceAnchor_Defaults
internal/config TestLoadServiceAnchor_Overrides
internal/config TestLoadServiceAnchor_RejectsZeroInterval
internal/config TestLoad_ComposeProjectFallback
internal/config TestLoad_DefaultsAndDerivations
internal/config TestLoad_HostnameOverride
internal/config TestLoad_MACInvalid
internal/config TestLoad_MACOverride
internal/config TestLoad_PollIntervalOverride
internal/config TestLoad_ProjectOverridesCompose
internal/config TestLoad_RequiresProject
internal/config TestLoad_RequiresVLANParent
internal/config TestMetricsAddrFromEnv/explicit_empty_→_disabled
internal/config TestMetricsAddrFromEnv/set_→_value
internal/config TestMetricsAddrFromEnv/unset_→_loopback_default
internal/config TestParseDuration/duration_string
internal/config TestParseDuration/empty_uses_default
internal/config TestParseDuration/invalid
internal/config TestParseDuration/plain_int_=_seconds
internal/conntrack TestFlushDestination_NilIPIsNoop
internal/conntrack TestFlushDestination_NonzeroExitIsSilent
internal/conntrack TestFlushDestination_V4Command
internal/conntrack TestFlushDestination_V6Command
internal/dhcp TestExtractV6Addrs_NoIANAYieldsNil
internal/dhcp TestRenewalInterval_FallsBackToHalfLease
internal/dhcp TestRenewalInterval_UsesT1
internal/dhcp TestSleepBackoff_CapsAtMax
internal/dhcp TestSleepBackoff_DoublesBelowCap
internal/dhcp TestSleepBackoff_RespectsContextCancel
internal/discovery TestBackendEqual/V6_mode_differs
internal/discovery TestBackendEqual/different_IPv4
internal/discovery TestBackendEqual/different_IPv6
internal/discovery TestBackendEqual/identical
internal/discovery TestBackendEqual/rules_differ
internal/discovery TestBackendEqual/rules_different_lengths
internal/discovery TestBackendEqual/rules_order_swapped
internal/discovery TestParseIP
internal/discovery TestPickIPs_NilNetworkSettings
internal/discovery TestPickIPs_NoSharedFallsBackToFirst
internal/discovery TestPickIPs_SharedNetworkAbsentReturnsNil
internal/discovery TestPickIPs_SharedNetworkExplicit
internal/discovery TestPickIPs_V4Only
internal/discovery TestPickIPs_V6Only
internal/discovery TestRuleLess
internal/discovery TestStateEqual
internal/discovery TestTrimName
internal/health TestLiveness_AlwaysOK/fresh_tracker
internal/health TestLiveness_AlwaysOK/tracker_with_state
internal/health TestMarks_AreIdempotent
internal/health TestNetworkAnchorReadiness_ReconcileAloneNotReady
internal/health TestNetworkAnchorReadiness_StateMachine
internal/health TestServiceAnchorReadiness_StateMachine
internal/labels TestParse/absent
internal/labels TestParse/bad_port
internal/labels TestParse/bad_proto
internal/labels TestParse/empty_string_ignored
internal/labels TestParse/missing_port
internal/labels TestParse/mixed_protos_with_whitespace
internal/labels TestParse/port_zero
internal/labels TestParse/single_tcp
internal/labels TestParse/v6_off
internal/metrics TestLeaseRemaining_ClampsNegative
internal/metrics TestLeaseRemaining_ClearDropsSeries
internal/metrics TestLeaseRemaining_DecaysAtScrapeTime
internal/metrics TestRegistryHasAllMetrics
internal/metrics TestServe_BindFailureReturnsError
internal/metrics TestServe_ServesMetrics
internal/nat TestAddressFamily
internal/nat TestFamilyString
internal/nat TestIfaceBytes/empty
internal/nat TestIfaceBytes/short_name_padded
internal/nat TestIfaceBytes/typical_anchord-ext
internal/nat TestMapForFamProto
internal/reconciler TestDesiredFromState_DualStack
internal/reconciler TestDesiredFromState_Empty
internal/reconciler TestDesiredFromState_MultipleBackendsAndProtocols
internal/reconciler TestDesiredFromState_SamePortFromTwoBackends
internal/reconciler TestDesiredFromState_V4OnlyBackend
internal/reconciler TestDesiredFromState_V6Off
internal/reconciler TestDesiredFromState_V6OnlyBackend
internal/serviceanchor TestDefaultRouteFor_Validation
internal/serviceanchor TestReconcile_InstallsBothFamilies
internal/serviceanchor TestReconcile_KeepsLastGoodOnLookupError
internal/serviceanchor TestReconcile_NoOpWhenUnchanged
internal/serviceanchor TestReconcile_ReplacesOnIPChange
internal/serviceanchor TestReconcile_RetriesAfterFailedInstall
internal/serviceanchor TestRun_LoopsAndCleansUp
E2E — 70/70 passed across 5 scenarios
Scenario Assertion Status
v4-only anchord container running
v4-only anchord-ext interface present
v4-only nftables anchord_v4 table installed
v4-only nftables anchord_v6 table installed
v4-only anchord-ext has IPv4 from 10.99.0.0/24
v4-only anchord-ext has no fd99:: address
v4-only anchord_v4 dnat_tcp contains port 25
v4-only S-2 (v4) source IP preserved through DNAT
v4-only S-3 dnat_tcp:25 reflects current transit IP within 8s
v4-only S-3 reachable on tcp/25 after recreate
v4-only S-6 anchord exited cleanly (code 0)
v4-only S-6 logs show graceful shutdown
v4-only S-6 logs show macvlan removed
v4-only S-6 nat teardown clean (no warnings)
v6-only anchord container running
v6-only anchord-ext interface present
v6-only nftables anchord_v4 table installed
v6-only nftables anchord_v6 table installed
v6-only anchord-ext has no IPv4 (10.99.0/24)
v6-only anchord-ext has IPv6 from fd99::/64 (RA)
v6-only anchord_v6 dnat_tcp contains port 25
v6-only S-2 (v6) source IP preserved through DNAT
v6-only S-3 dnat_tcp:25 reflects current transit IP within 8s
v6-only S-3 reachable on tcp/25 after recreate
v6-only S-6 anchord exited cleanly (code 0)
v6-only S-6 logs show graceful shutdown
v6-only S-6 logs show macvlan removed
v6-only S-6 nat teardown clean (no warnings)
both anchord container running
both anchord-ext interface present
both nftables anchord_v4 table installed
both nftables anchord_v6 table installed
both anchord-ext has IPv4 from 10.99.0.0/24
both anchord-ext has IPv6 from fd99::/64 (RA)
both anchord_v4 dnat_tcp contains port 25
both anchord_v6 dnat_tcp contains port 25
both S-2 (v4) source IP preserved through DNAT
both S-2 (v6) source IP preserved through DNAT
both S-3 dnat_tcp:25 reflects current transit IP within 8s
both S-3 reachable on tcp/25 after recreate
both S-6 anchord exited cleanly (code 0)
both S-6 logs show graceful shutdown
both S-6 logs show macvlan removed
both S-6 nat teardown clean (no warnings)
none anchord container running
none anchord-ext interface present
none nftables anchord_v4 table installed
none nftables anchord_v6 table installed
none anchord-ext has no IPv4 lease (expected)
none anchord-ext has no IPv6 (expected)
none S-6 anchord exited cleanly (code 0)
none S-6 logs show graceful shutdown
none S-6 logs show macvlan removed
none S-6 nat teardown clean (no warnings)
dhcpv6-stateful anchord container running
dhcpv6-stateful anchord-ext interface present
dhcpv6-stateful nftables anchord_v4 table installed
dhcpv6-stateful nftables anchord_v6 table installed
dhcpv6-stateful anchord-ext has IPv4 from 10.99.0.0/24
dhcpv6-stateful anchord-ext has IPv6 from fd99::/64 (DHCPv6)
dhcpv6-stateful anchord_v4 dnat_tcp contains port 25
dhcpv6-stateful anchord_v6 dnat_tcp contains port 25
dhcpv6-stateful S-2 (v4) source IP preserved through DNAT
dhcpv6-stateful S-2 (v6) source IP preserved through DNAT
dhcpv6-stateful S-3 dnat_tcp:25 reflects current transit IP within 8s
dhcpv6-stateful S-3 reachable on tcp/25 after recreate
dhcpv6-stateful S-6 anchord exited cleanly (code 0)
dhcpv6-stateful S-6 logs show graceful shutdown
dhcpv6-stateful S-6 logs show macvlan removed
dhcpv6-stateful S-6 nat teardown clean (no warnings)

About

Make a Docker Compose project look like a real server on your LAN — one IP, real client source IPs, no port-mapping gymnastics. For Mailcow, Nextcloud, Matrix, anything self-hosted.

Topics

Resources

License

Code of conduct

Contributing

Security policy

Stars

Watchers

Forks

Packages

 
 
 

Contributors