Docker Coexistence
easywall v1 flushed iptables and took Docker’s chains with it. v2 cannot: it owns one table and never looks at another.
An apply flushes and rebuilds table inet easywall. DOCKER, DOCKER-USER and
DOCKER-ISOLATION live elsewhere and are not read, written or deleted.
Turning it on
[docker]
enabled = true # detect Docker bridge interfaces
allow_bridge_networks = true # accept traffic from the detected CIDRs
custom_networks = [] # anything detection misses
published_ports = "open" # "filtered": easywall decides who reaches a
# published container port — see option 2 below
Detection reads the interfaces named docker* or br-* and takes the CIDR of
each. It runs at every apply. It also runs once more on its own, for up to 90
seconds after easywall-core starts. That covers the ordinary case: no such
bridge existed at boot, and Docker started after the daemon. Past that
window, or for a bridge added later still, apply again, or add an entry to
custom_networks.
Entries there are CIDR networks — 172.20.0.0/16, not a single container address —
with # comments allowed. Anything else is refused by name rather than accepted and
silently skipped.
These networks are also what opens the
forwardchain. Container traffic is routed, not addressed to the host. An empty base chain withpolicy dropdestroys it at the hook, whatever Docker’s own chain accepted. Until 2.5.0 that killed every arrangement below. Traffic with a source or destination in these networks now crosses it whateverrouting.modesays: anything else would take a host’s containers off the network on the first upgrade.
The bogon filter exempts them. Bridge ranges are RFC 1918, exactly what that module drops. Before the exemption, switching it on silently undid coexistence.
See firewall filters, and
[routing] if this host routes for
some other reason as well.
Four ways to run them together
| Setup | Inbound to published ports | Outbound from containers | Good for | |
|---|---|---|---|---|
| 1 | enabled = true — the shipped default |
yes, Docker publishes them | works | most hosts |
| 2 | enabled = true, published_ports = "filtered" |
only with a forwarded port rule | works | one firewall on a container host |
| 3 | enabled = true, Docker with {"iptables": false} |
only with a port rule per port | needs a masquerade rule you write yourself | one firewall, one place to look |
| 4 | enabled = false — deliberately |
no | no | a host that runs no containers |
Option 2 is what replaces a cloud firewall
A published port is routed to the container, not addressed to this host, so it
never reaches the input chain and no ordinary port rule can see it. Under
published_ports = "filtered" easywall’s forward chain denies inbound traffic
to a container address, and a port rule with scope forwarded is what opens one.
That is the whole list of ports on the host in one place — which is what a
provider firewall was doing for you.
Switching it on closes every published port that has no forwarded rule. Write the rules first, then flip the key, then apply once. The 120-second acceptance window will not save you here: it proves your own connection, and yours arrives on the
inputchain while your containers do not.
A Docker host with IPv6
A user-defined bridge can carry a ULA /64 (fd…::/64) beside its IPv4
subnet:
docker network create --ipv6 --subnet 172.20.0.0/16 --subnet fd00:20::/64 mynet
A plain -p 993:993 already publishes on both families. With no host IP,
Docker binds 0.0.0.0 and [::] — docker ps shows both. On a network
with IPv6 enabled and ip6tables on, it writes the matching ip6 DNAT rule
the same way it writes the ip one. -p [::]:993:993 publishes IPv6 only;
use it deliberately, to keep a port off IPv4, not to “add” IPv6 to a plain
publish. Ansible’s community.docker.docker_container is the exception: its
default_host_ip is 0.0.0.0, so "25:25" binds IPv4 only until it is set to
"". Docker needs ip6tables enabled in its own daemon config to write
the ip6 DNAT rules; that has been the default since Docker 27.
Once the bridge exists, detection picks up its IPv6 network the same apply it
picks up the IPv4 one. The forwarded port rules that already open a port for
IPv4 open it for IPv6 too — one rule, two families, no separate IPv6 port rule
to write. A bridge’s own fe80:: link-local address never counts as a
container network, so it never opens anything on its own.
ipv6.mode = "block" removes IPv6 from the container networks the forward
chain is built over: no IPv6 twin, deny or exception renders, whatever the
bridge carries. That is not the same as an IPv6 verdict in the forward chain —
block only ever adds one on the input chain. What IPv6 to a container meets
after that follows routing.mode instead. Under routing.mode = "closed" it
meets the forward chain’s policy drop, same as under "networks" unless
routing.networks names a network that covers it. Under routing.mode =
"open" the forward policy is accept, so IPv6 to a container is passed
through, unfiltered, to whatever Docker’s own ip6tables does with it.
Containers on an IPv6 bridge reach the host’s own services over IPv6 as they already do over IPv4 — the input chain’s bridge accept covers both families.
A port published on a bridge gateway is still a published port
-p 172.17.0.1:53:53 publishes to the bridge, not to the world, so it reads
like a container-only service that the forward chain’s deny cannot be about.
It is. A container on another bridge reaches it DNAT’d into the first one, so
it arrives with its destination inside that bridge and its source outside it.
That is the shape of a packet from the internet, because by then it is one. Such
a port needs a forwarded rule like any other published port.
This is the sentence that would have saved the host 2.20.1 came from. Its resolver was published that way, every container lost DNS at the first apply, and all fifteen external probes stayed green.
A forwarded rule that names sources covers only those sources. What the deny closes is the world plus every other bridge. Naming the other bridge networks restores the container in them, and is the right rule where only containers should reach the port. It does not restore the world: a packet to a
0.0.0.0-published port is DNAT’d into a bridge and meets the deny with the same shape. Such a rule is still named in the log, because no source list is ever tested against that whole set.
What the log says at each apply
Since 2.20.1 an apply under filtered names every published port no forwarded
rule covers, read from Docker’s own DNAT rules in the kernel:
53 published on 172.17.0.1 with no forwarded rule: the forward chain drops
everything that reaches it from outside its own bridge — the world, and
containers in another bridge. Give it a port rule with scope "forwarded" —
no sources, or sources naming the other bridge networks if only containers
should reach it — or set docker.published_ports = "open"
A publish that remaps the port — -p 8080:80 — names both numbers, because the
rule has to name the second one. The forward chain runs after Docker’s DNAT, so
the packet arrives there carrying 80, and a rule for 8080 matches nothing:
8080 published on 0.0.0.0 reaches the container on 80: no forwarded rule for
80: the forward chain drops everything that reaches it from outside its own
bridge — the world, and containers in another bridge. Give it a port rule with
scope "forwarded" — no sources, or sources naming the other bridge networks if
only containers should reach it — or set docker.published_ports = "open"
| Once per apply, every time | No folding of repeats. The apply you are reading the log of is the one that has to say it |
| A sourced rule is named too | It covers only the sources it lists, which is never the whole set the deny closes — see the callout above |
| Both families | Docker’s ip nat and ip6 nat DNAT rules are read, so a port published only on IPv6 is named too |
| Loopback publishes are not named | -p 127.0.0.1:8081:80 is reached from the host only, and that connection never crosses the forward chain |
| No Docker socket, no client library | A host whose Docker has stopped with its rules still loaded is exactly the host this is about |
| Also worth knowing | |
|---|---|
| Nothing in the interface sets this key | Edited in easywall.toml only. A press that could take every container off the network, with an acceptance window blind to it, is not a control |
| The ports page reads it instead | A forwarded rule is marked inert there while published_ports is open. It warns; it does not offer to change the key |
filtered needs enabled = true |
The combination is refused at startup and on SIGHUP, by name, rather than accepted and ignored |
| No detected bridge network means nothing is rendered | The forward chain is left exactly as it was, with one warning in the log. A deny with no exceptions beside it would close the host’s container traffic entirely |
| The ports page cannot see that state | It marks a forwarded rule inert while published_ports is open, because that key is one it can read. Whether a bridge was detected is known only in the core, so a forwarded rule under filtered with no bridge reads as enforced on that page while nothing is rendered. The log warning is the signal; check it after an apply |
| Container-to-container and outbound are untouched | The deny matches only traffic whose destination is a container address and whose source is not |
A routing.networks peer loses reach to published ports |
The deny is evaluated before those CIDR exceptions, so a peer you allowed there still needs a forwarded port rule. Expect this as an outage if routing.networks is set |
| IPv6 is filtered like IPv4 | Once a bridge carries a ULA or global IPv6 network, the deny and the forwarded rules cover it. Under ipv6.mode = "block" those don’t render; what then reaches a container follows routing.mode — the forward policy drop under closed/networks unless routing.networks names it, passed through to Docker’s own ip6tables under open |
| A bridge the rules in force do not know is a warning | After docker network create, the dashboard and easywall-core status say Docker network … exists and is not in the rules in force until the next apply |
Option 4 is “no containers”, not “quiet containers”. The
forwardchain stays closed, and a container’s outbound traffic is routed through it like any other. This page used to read as though outbound-only containers were fine here. They are not.
Option 3, measured — not assumed. Docker’s NAT is iptables, and the setting removes it. Against Docker 29.7.2, with a peer reachable only by leaving the bridge: stock daemon → one
MASQUERADErule, sevenDOCKERfilter rules, peer reachable. With{"iptables": false}→ neither, peer unreachable; packets leave with a172.17.x.xsource and nothing comes back. easywall cannot supply the masquerade for you: custom rules go into itsinputchain, and this needs apostroutingchain in a table of your own.
Checking it worked
# easywall's table — rebuilt on every apply
sudo nft list table inet easywall
# Docker's — should be untouched
sudo nft list tables | grep -i docker
For a published port that answers nothing — a resolver, a syslog receiver — no reply-based probe can tell dropped from silent. Read the packet counters instead: proving a port that answers nothing.