Outbound network gateway + filtered DNS for Docker application stacks.
This project lets application containers reach only a defined set of external hosts while preserving internal Docker service resolution.
It is intended for stacks where:
- the main application should not have unrestricted outbound internet access
- only specific external domains or IPs should be reachable
- internal Docker service names must continue to resolve normally
- the application may already be attached to multiple Docker networks
The current setup uses two helper containers plus a dedicated transit network:
-
gateway container
- runs in
MODE_RUN=gateway - enables IPv4 forwarding
- installs
iptables+ipsetrules - performs outbound NAT/masquerading
- periodically resolves allowed hosts and refreshes the whitelist
- runs in
-
net_setup sidecar
- runs in
MODE_RUN=net_setup - shares the network namespace of the main application container
- rewrites the default route so traffic exits through the gateway
- can start a local
dnsmasqon127.0.0.1 - can generate internal DNS entries automatically from Docker metadata
- runs in
-
dedicated non-internal transit network
- shared by the app and the gateway
- used only for routed traffic between the app and the gateway
- must not be declared as
internal: true
A typical production layout is:
- an internal app network for app ↔ db ↔ smtp internal traffic
- a transit network for app ↔ gateway traffic
- a public network for gateway ↔ internet traffic
The gateway must not be used through a Docker network marked as internal: true.
Docker isolates internal bridges at host level, so traffic destined outside that subnet may be dropped before it ever reaches the gateway container.
Therefore:
- keep your app's private/internal network internal if you want
- add a separate non-internal network for app → gateway transit
- connect the gateway to:
- the transit network
- the public/outbound network
- do not rely on the internal app network as the routed gateway path
The gateway container maintains an ipset containing:
- IPs resolved from domain names listed in
ALLOWED_HOSTS - literal IPs listed directly in
ALLOWED_HOSTS
Outbound traffic is allowed only when the destination IP is present in that set.
The gateway installs rules that:
- allow
RELATED,ESTABLISHED - allow traffic from any non-external interface to the external interface when destination IP is in the whitelist ipset
- reject traffic from any non-external interface to the external interface when destination IP is not in the whitelist
- masquerade/NAT traffic leaving the external interface
This means the application can still resolve many names, but it can only connect successfully to destinations whose current IPs are whitelisted.
There are two DNS layers.
When DNS_LOCAL=1 is enabled in net_setup:
dnsmasqlistens on127.0.0.1/etc/resolv.confis rewritten to use127.0.0.1- internal Docker service names are served from an autogenerated hosts file
- external names are forwarded to Docker's embedded resolver at
127.0.0.11
This is intentional:
- internal service names stay stable and local
- external name resolution remains compatible with real-world CNAME chains/CDNs
- the real outbound restriction is enforced by the gateway firewall, not by DNS alone
Internal names are generated automatically from Docker metadata when
DNS_INTERNAL_FROM_DOCKER=1 is enabled.
The sidecar creates /run/dnsmasq-internal.hosts using the Docker socket and Compose
metadata.
This typically includes:
- Compose service names (
db,smtp,app, etc.) - explicit network aliases (
smtprelay,main-db, etc.) - useful container names when applicable
This preserves internal service discovery without requiring manual maintenance of internal DNS entries.
Both helper containers need:
cap_add:
- NET_ADMINThe net_setup sidecar also needs the Docker socket when automatic internal DNS entries
are enabled:
volumes:
- /var/run/docker.sock:/var/run/docker.sock:roThe gateway should also enable IPv4 forwarding explicitly:
sysctls:
net.ipv4.ip_forward: "1"Space-separated list of allowed external domains and/or IPs.
Example:
ALLOWED_HOSTS: "api.partner.invalid files.vendor.invalid 203.0.113.25"Notes:
- domain names are resolved periodically by the gateway and added to the whitelist
- literal IPs are added directly to the whitelist
- DNS resolution alone does not imply connectivity; connectivity is still controlled by gateway firewall rules
Runs the container as the outbound network gateway.
Optional:
IPSET_NAME: "whitelist"
REFRESH_INTERVAL: "300"IPSET_NAME: name of the whitelist ipsetREFRESH_INTERVAL: refresh interval in seconds for resolving allowed hosts
The gateway automatically detects its external interface from the default route. Firewall rules are applied dynamically so the setup is resilient even when the container is attached to multiple Docker networks.
Runs the container as the sidecar that configures routing and DNS for the main app.
Required:
GATEWAY_NAME: "gateway"This must resolve to the gateway container on the shared transit network.
Optional:
DNS_LOCAL: "1"
DNS_INTERNAL_FROM_DOCKER: "1"
DOCKER_PROJECT: "acme-portal"
DOCKER_PRIMARY_SERVICE: "app"
DNS_DOCKER_REFRESH_INTERVAL: "5"
DNS_UPSTREAMS_LOCAL: "127.0.0.11"DNS_LOCAL=1: enable localdnsmasqinside the app namespaceDNS_INTERNAL_FROM_DOCKER=1: generate internal DNS entries from Docker metadataDOCKER_PROJECT: Compose project name used to discover related containersDOCKER_PRIMARY_SERVICE: main service whose visible networks define DNS scopeDNS_DOCKER_REFRESH_INTERVAL: refresh interval for autogenerated internal hostsDNS_UPSTREAMS_LOCAL: upstream resolvers used by localdnsmasq(default:127.0.0.11)
Example with fake project names and fake external domains:
services:
app:
image: registry.example.invalid/acme/app:latest
networks:
app_internal:
gateway_transit:
environment:
DB_HOST: main-db
SMTP_SERVER: smtprelay
net_setup:
image: ghcr.io/example/docker-whitelist-gateway:latest
network_mode: "service:app"
cap_add:
- NET_ADMIN
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
environment:
MODE_RUN: "net_setup"
GATEWAY_NAME: "gateway"
DNS_LOCAL: "1"
DNS_INTERNAL_FROM_DOCKER: "1"
DOCKER_PROJECT: "acme-portal"
DOCKER_PRIMARY_SERVICE: "app"
ALLOWED_HOSTS: "api.partner.invalid files.vendor.invalid 203.0.113.25"
gateway:
image: ghcr.io/example/docker-whitelist-gateway:latest
cap_add:
- NET_ADMIN
sysctls:
net.ipv4.ip_forward: "1"
networks:
gateway_transit:
aliases:
- gateway
public:
environment:
MODE_RUN: "gateway"
ALLOWED_HOSTS: "api.partner.invalid files.vendor.invalid 203.0.113.25"
db:
image: postgres:17
networks:
app_internal:
aliases:
- main-db
smtp:
image: mailhog/mailhog
networks:
app_internal:
aliases:
- smtprelay
networks:
app_internal:
internal: true
gateway_transit:
public:app_internalremains private/internalgateway_transitcarries routed traffic fromapptogatewaypublicis used only by the gateway for outbound access- internal service names such as
main-dbandsmtprelaystill resolve locally - only destinations listed in
ALLOWED_HOSTSare reachable externally
With:
ALLOWED_HOSTS: "api.partner.invalid files.vendor.invalid"Expected behavior:
getent hosts api.partner.invalid→ may resolvecurl https://api.partner.invalid→ allowedgetent hosts random-site.example.invalid→ may resolvecurl https://random-site.example.invalid→ blocked by gatewaygetent hosts main-db→ resolves locallygetent hosts smtprelay→ resolves locally
This is expected: DNS resolution and outbound permission are intentionally separate concerns.
This implementation now provides:
- outbound filtering based on destination IP whitelist
- periodic refresh of allowed destination IPs
- local DNS for the application namespace
- automatic internal Docker name resolution from Docker metadata
- compatibility with multi-network application containers
- no need to maintain
DNS_INTERNAL_NAMESmanually in normal Compose setups - correct routing through a dedicated non-internal transit network
- The transit network used between app and gateway must not be Docker-internal.
- The gateway healthcheck should validate the current chain-based
iptablessetup. - Internal DNS generation depends on the Docker socket and Compose metadata.
- DNS may resolve names that are not ultimately reachable; the gateway firewall is the actual enforcement point.
- For CDN-backed services, consider a shorter
REFRESH_INTERVALif target IPs rotate often.