This document describes the default example values. If you change them, update the Docker network, YAML, Compose addresses, host policy, and verification commands together.
| Component | Example |
|---|---|
| Enrolled network | vpn-egress |
| Subnet | 172.30.0.0/24 |
| Docker bridge gateway | 172.30.0.1 |
| Egressy address | 172.30.0.2 |
| Probe address | 172.30.0.5 |
| Host bridge | br-vpn-egress |
| Policy table | 200 |
| WireGuard interface | wg0 |
- Compose selects
vpn-egressas the client's IPv4 default route. - The packet enters
br-vpn-egresswith a source in172.30.0.0/24. - A host source rule selects table 200.
- Table 200 routes through
172.30.0.2on the same bridge. - The gateway's priority-100 source rule selects its table 200.
- Gateway table 200 defaults through
wg0. - Gateway nftables permits the enrolled-subnet-to-tunnel path and
masquerades it on
wg0.
Both policy-routing hops are required. The client is directly connected to the host bridge, so attaching the gateway container alone does not make it the router.
WireGuard decapsulates the response into wg0. Connection tracking reverses
the tunnel masquerade, the gateway routes the enrolled destination to
vpn-egress, and the host bridge delivers it to the client's veth. Established
and related state is accepted; unsolicited forwarding is rejected unless a
current DNAT rule explicitly permits it.
The gateway has a normal Docker uplink for management tasks and an enrolled network address for DNS and dashboard replies. Policy order matters:
priority 90: from 172.30.0.2 lookup main
priority 100: from 172.30.0.0/24 lookup 200
The priority-90 exception keeps replies sourced from the gateway address on the
connected/main path. Without it, DNS and dashboard replies can be sent into
wg0. Other enrolled-subnet sources use the tunnel table. Traffic sourced from
the uplink address continues to use main.
WireGuard remains Table = off; automatic wg-quick routes must not replace
the gateway's management default route.
Clients send UDP or TCP DNS to 172.30.0.2:53. Egressy forwards to the provider
resolver through the tunnel, bounds concurrency and timeouts, and retries a
truncated UDP answer over TCP. Gateway firewall policy rejects enrolled plain
DNS sent to other destinations. Encrypted DNS inside arbitrary HTTPS traffic is
not intercepted.
With dns.local_zones.enabled, single-label names matching a running container
on the enrolled bridge are answered from Docker discovery rather than forwarded.
Without it, enrolled clients can resolve public names only, which makes Egressy
unusable for any stack whose containers address each other by service name.
Three properties make this safe:
- Single-label only. A container named
apicannot shadowapi.example.com; anything with a dot is forwarded untouched. - Bridge addresses only. Answers come from discovery and are always addresses on the enrolled bridge, so this cannot point a client elsewhere.
- Nothing leaks. A name we own is never forwarded.
AAAAand other types for a known name are answeredNOERRORwith no records rather than sent upstream, so the provider resolver never learns internal names exist.
Answers are served before admission control, since they need no upstream capacity, and carry a short TTL because they track container lifecycle.
The host policy permits the intended bridge-to-gateway path and rejects enrolled traffic that would otherwise use Docker's normal uplink/NAT. If the gateway container disappears, the source rule and reject policy prevent silent fallback.
That protection only holds while the host policy is actually installed, and it
is ordinary host state that anything rebuilding netfilter — a Docker daemon
restart, for instance — will remove. render-host-setup installs it, but a
one-shot cannot maintain it: after the state is cleared, enrolled traffic misses
the source rule, falls through to the main routing table and leaves by the
host's default route, with nothing rejecting it.
The host-network agent therefore owns this policy rather than assuming it.
It reads the desired policy from GET /api/v2/host-policy, compares it against
ip rule, the policy routing table and the inet egressy_host table on every
interval, and reinstalls whatever is absent. Drift is logged at warn level
naming exactly which parts were missing. Set EGRESSY_MANAGE_HOST_POLICY=false
to opt out and manage the state yourself.
That endpoint is on the same gateway API as the isolation policy, so
EGRESSY_HOST_POLICY_URL defaults to EGRESSY_ISOLATION_POLICY_URL with the
host-policy path: publish the API on a different port and overriding the
isolation URL alone is enough. Set it explicitly only if the two genuinely
differ.
If the agent cannot reach that endpoint it warns on each interval and, after a sustained run of failures, escalates to an error naming the URL. Take that seriously — it means nothing is maintaining the fail-closed state, and it is not otherwise visible: bridge isolation keeps working and the gateway keeps reporting a healthy tunnel while enrolled traffic is one netfilter rebuild away from leaving on the host's default route.
The gateway table is installed before WireGuard starts. Its forward chain
permits enrolled traffic only toward wg0, return state from wg0, DNS and
management input, and active forwarding state. Other enrolled forwarding is
rejected. If wg0 disappears or table 200 lacks a route, traffic remains
blocked while recovery runs.
Never disable gateway firewall reconciliation while Egressy manages the tunnel; configuration validation rejects that combination.
Egressy requests equal TCP and UDP external ports from the provider NAT-PMP gateway. A mapping is accepted only when the response version, operation, result, internal port, external port, epoch, and lease are valid and both protocols agree.
For one unique compliant target, nftables installs DNAT from wg0 to the
target address and port and permits the corresponding forward/return state.
Target loss, ambiguity, invalid labels, route-intent mismatch, tunnel recovery,
or lease loss removes DNAT. Provider lease refresh occurs before expiry.
The optional external validator tests TCP reachability only. UDP reachability and application-level correctness need separate tests.
An application's management network must not become its preferred default route. If a local UI must remain reachable, use a narrowly scoped proxy or published helper attached to both the management and enrolled networks. The application itself should retain the enrolled default route. Review any such proxy carefully: it is an intentional management path, not a general egress path.
Docker bridge peers can communicate directly without entering the gateway namespace. The optional isolation agent applies host bridge-family policy from a complete inventory and explicit allowances. It is disabled by default and does not provide hostile Layer-2 tenant isolation.
The host client-source rule, bridge enrollment, and leak protections are IPv4 only. A WireGuard profile containing IPv6 does not extend these controls to clients. Do not give enrolled clients a separate IPv6 default route.
Use a disposable client and inspect all layers:
ip rule show
ip route show table 200
sudo nft list table inet egressy_host
docker exec egressy ip rule show
docker exec egressy ip route show table 200
docker exec egressy nft list table inet egressy
docker exec egressy wg show wg0 latest-handshakes
docker exec disposable-client ip -4 route
docker exec disposable-client getent hosts example.com
docker exec disposable-client wget -qO- https://ifconfig.co/ipConfirm the client exit differs from the host's ordinary exit without placing either raw address in public logs. Then test tunnel loss and gateway loss; the client must lose egress rather than fall back.