Skip to content

Network Egress Filtering

github-actions[bot] edited this page Oct 2, 2026 · 1 revision

Network Egress Filtering

An optional, recommended second line of defence. The service already refuses private and reserved addresses in code (see Security Overview). A network rule is the independent backstop for whatever the code might miss - a bug in the address check or in the local proxy, a future Chrome feature that bypasses the proxy switches, any other process in the container: private, link-local (cloud metadata), carrier-grade-NAT, multicast and other reserved addresses become unreachable from the container, whatever Chrome tries.

docker-compose.egress.example.yml does that with a small helper container, egress-guard, that installs firewall rules in the service's network namespace. It adds a layer; it does not replace the code-level checks.

This page is the working summary. The complete behaviour, the restart table and every known limitation are in the deployment guide - read its limitations before you rely on this.

Set it up

  1. Find out where WP_RECEIVER_URL points. A public address (https://your-site.example/...) needs no exception. A private one (WordPress in another container, a LAN host, host.docker.internal) needs its IP address and port. Give a WordPress container a fixed address (ipv4_address: in its network, which needs an ipam subnet), or a recreated container gets a new address and delivery is refused.

  2. Copy the example next to your docker-compose.yml, and replace your_wordpress_network with the network name your own compose file uses.

  3. Add the settings below to the .env next to your docker-compose.yml (see Settings): EGRESS_ALLOW with your receiver's ADDRESS:PORT if it is private, and EGRESS_ALLOW_DNS only if your resolver needs it.

  4. Move networking settings to the guard. The service now shares the guard's network namespace, so ports, expose, dns, dns_search, extra_hosts, hostname and sysctls belong on the egress-guard service, not on critical-css-service. Docker refuses ports, expose, dns, extra_hosts and hostname on a service that uses network_mode: service:... (checked with Docker Engine 29.8.1 and Compose 5.5.1); dns_search is ignored without an error, and a net.* sysctl on the service changes the shared namespace, so neither belongs there.

  5. Start both files together:

    docker compose -f docker-compose.yml -f docker-compose.egress.example.yml up -d

From now on every docker compose command for this stack needs both -f flags. With only the base file, Compose does not know the guard: up -d recreates the service, with or without a new image, from the base file alone, without network_mode: service:egress-guard, and the filtering is silently gone (the only hint is a warning about an orphan container). restart egress-guard fails, logs leaves out the guard and down leaves it running.

To avoid typing both flags you can put COMPOSE_FILE=docker-compose.yml:docker-compose.egress.example.yml in the .env next to your compose file (the separator is ; on Windows). An explicit -f overrides it, so the commands above stay correct either way. After any up -d you can check that the service is still inside the guard's namespace:

docker inspect -f '{{.HostConfig.NetworkMode}}' critical-css-service

It must print container: followed by an ID; a network name such as <project>_<network> means the guard is bypassed.

You need Docker Compose 2.24 or newer.

Settings

Read from the .env next to your docker-compose.yml when the guard is created. Both are optional.

Variable Value
EGRESS_ALLOW Private destinations the service may still open TCP connections to, as space-separated ADDRESS:PORT pairs, e.g. 172.20.0.10:80. IPv6 is written [fd00::10]:80. Leave it empty if WP_RECEIVER_URL is public. List the receiver's exact address and port, nothing wider.
EGRESS_ALLOW_DNS Only if your DNS resolver is itself one of the refused addresses (a private or link-local one, or Azure's 168.63.129.16). Space-separated resolver addresses, port 53 only.

Verify it

docker exec critical-css-service node -e "fetch('http://169.254.169.254/',{signal:AbortSignal.timeout(4000)}).then(()=>console.log('NOT FILTERED'),e=>console.log(e.cause?.code==='ECONNREFUSED'?'OK: egress filtering is active':'NOT CONCLUSIVE: '+(e.cause?.code||e.name)))"
  • OK - the connection was refused locally, at once.
  • NOT FILTERED - the address answered; the rules are not working.
  • NOT CONCLUSIVE (usually a timeout) - nothing refused it. Check docker compose -f docker-compose.yml -f docker-compose.egress.example.yml ps (the guard should be healthy) and docker logs critical-css-egress-guard, which should end with ready.

Then run a normal render (Getting Started) to confirm delivery to your receiver still works, and docker exec critical-css-service node -e "require('dns').lookup('example.com',console.log)" to confirm name resolution still works.

What to expect

  • The guard starts first and gates the service: it only starts once the rules are in the kernel, and the service's own start command also waits until a connection to the metadata address is refused. A guard that cannot install the rules never becomes healthy, so the service never starts.
  • If the guard dies, the service loses its network. That fails closed.
  • Changes to EGRESS_ALLOW and EGRESS_ALLOW_DNS (and every other .env value) are read when a container is created. docker restart does not re-read the .env: apply a change with docker compose -f docker-compose.yml -f docker-compose.egress.example.yml up -d.
  • Restarting only the service keeps the rules. Restarting only the guard with plain Docker leaves the service without a network until you restart it too; the same with both compose files, docker compose -f docker-compose.yml -f docker-compose.egress.example.yml restart egress-guard, does both.
  • If name lookups start failing with SERVFAIL after you switch it on, your DNS resolver is one of the refused addresses. Put it in EGRESS_ALLOW_DNS.

What it does not cover

  • Anything inside the container's own loopback. The service's port, Chrome's DevTools port and 127.0.0.0/8 stay reachable from Chrome because loopback has to stay open; the local proxy keeps Chrome off it.
  • Whatever you put in EGRESS_ALLOW and EGRESS_ALLOW_DNS. Page content can reach those addresses too.
  • The public internet. The service renders your public site and its assets, so those stay open by design.
  • Not tested on a plain Linux host yet, and Compose only: plain docker run, Swarm, Kubernetes and rootless Docker or Podman are untested (on Kubernetes use a NetworkPolicy with an ipBlock except list). Do not combine it with network_mode: host.

Clone this wiki locally