If you have ever launched a containerized service on a Linux server and found it unreachable -- or worse, found it reachable when it should have been blocked -- you have run into a container firewall conflict. The problem is architectural: container runtimes like Docker and Podman need to manipulate the kernel's packet filtering subsystem to make networking work, and they do this in ways that can collide with the host firewall you already have in place.
These conflicts have become more common as the Linux ecosystem migrates from iptables to nftables. Every major distribution shipped since 2020 defaults to nftables under the hood, but container runtimes were built around iptables and have only recently begun native nftables support. The result is a transitional period where administrators regularly encounter mixed-backend rule sets, phantom NAT entries, and firewall policies that silently fail to apply to container traffic.
Why Container Firewall Conflicts Happen
The core issue is that container runtimes and host firewalls operate on the same kernel subsystem -- netfilter -- but they do not coordinate with each other. When you run a container with a published port (-p 8080:80), the container runtime inserts DNAT (Destination Network Address Translation) rules into the kernel's nat table. These rules redirect incoming traffic from the host port to the container's internal IP and port.
The critical detail: DNAT happens at the prerouting stage of the netfilter pipeline. By the time the packet reaches the filter chains where firewalld, UFW, or your custom nftables rules evaluate it, the destination address has already been rewritten. The packet no longer looks like traffic to port 8080 on the host -- it looks like forwarded traffic to an internal bridge address. If your firewall rules only inspect the INPUT chain (which handles traffic destined for the host itself), they never see container-bound packets at all.
The diagram makes the problem concrete: DNAT fires in nat PREROUTING (orange), rewrites the destination before anything else runs, then the packet travels the FORWARD path through Docker's own chains (green), and firewalld or UFW's filter rules (red annotation) only evaluate after the destination has already been changed. Anything in INPUT never sees the packet at all because it was routed to the bridge, not to the host process.
Published container ports can be accessible from the public internet even when your firewall is configured to block them. This is not a bug in firewalld or UFW -- it is a fundamental consequence of how NAT interacts with filter rules at the kernel level. Always verify access from an external host after deploying containers.
The iptables-to-nftables Transition
The second layer of complexity comes from the ongoing transition between firewall backends. Modern Linux distributions ship with iptables-nft, a compatibility shim that accepts iptables syntax but translates it into nftables rules in the kernel. This means Docker's iptables calls still work, but the resulting rules live alongside native nftables rules in the kernel's netfilter subsystem.
Problems appear when both legacy iptables modules and native nftables rules are loaded simultaneously. A system might have legacy rules left over from a previous Docker installation, native nftables rules from firewalld, and iptables-nft translated rules from the currently running Docker daemon. These three sets of rules can evaluate the same packet in an unpredictable order, creating networking behavior that appears random.
# Determine which iptables backend the system is using $ iptables --version iptables v1.8.10 (nf_tables) # <-- nftables backend via compatibility layer # Check for leftover legacy iptables rules $ iptables-legacy -L -n 2>/dev/null # Check for legacy NAT rules $ iptables-legacy -t nat -L -n 2>/dev/null # Inspect the full nftables ruleset $ sudo nft list ruleset
If iptables --version outputs (nf_tables), the system is using the compatibility layer. If it shows (legacy), the system still uses the original iptables kernel modules. Having rules in both backends simultaneously is the single most common cause of unpredictable container networking behavior.
Docker-Specific Firewall Conflicts
Docker has always managed its own firewall rules. When the daemon starts, it creates chains in the filter and nat tables, sets up masquerading for outbound container traffic, and inserts DNAT rules for every published port. On systems running firewalld, Docker also creates a dedicated docker zone with target ACCEPT and assigns all Docker bridge interfaces to it.
Conflict 1: Published Ports Bypass firewalld
The most frequently reported issue is that published container ports are reachable from external networks despite firewalld rules that should block them. This happens because Docker's DNAT rules redirect traffic before firewalld's filter rules evaluate it. A firewalld rule blocking port 8080 in the public zone has no effect on traffic that has already been DNAT'd to a container's internal address.
# This container port will be reachable even if firewalld blocks 8080 $ docker run -d -p 8080:80 nginx # Verify from an external host $ curl http://your-server-ip:8080 # Returns nginx welcome page despite firewalld blocking 8080
The simplest mitigation is to bind published ports to localhost so they are not exposed on external interfaces:
For a more complete solution, firewalld 2.3.0 introduced the StrictForwardPorts configuration option. The firewalld project's announcement describes the default behavior concisely: "Published container ports are implicitly allowed." Setting StrictForwardPorts=yes reverses that: firewalld rejects DNAT'd traffic it did not itself generate, so container-published ports are blocked unless explicitly forwarded through firewalld. This requires nftables v1.0.7 or later on the host due to a kernel fix the feature depends on.
# When set to yes, firewalld blocks DNAT'd traffic from container runtimes # unless the port is explicitly forwarded via firewall-cmd StrictForwardPorts=yes
With StrictForwardPorts=yes, Docker's published ports stop working until you explicitly allow them through firewalld. This gives administrators full control over which container ports are externally accessible.
The default value of StrictForwardPorts is no, which preserves the existing behavior where Docker and Podman integrate seamlessly with firewalld. Changing it to yes is a deliberate trade-off: you gain strict firewall control but must manually configure forwarding for every container port you want exposed. Also note that enabling StrictForwardPorts affects Podman only when Netavark uses its iptables or nftables driver. According to the Netavark documentation, if Podman is configured to use the native firewalld driver, it currently bypasses StrictForwardPorts enforcement entirely -- container ports will still be accessible even with the setting enabled. This behavior may change in a future Netavark release.
Conflict 2: Docker's FORWARD Policy Blocks Other Traffic
When Docker enables IP forwarding on the host (which it does automatically), it also sets the default policy of the iptables FORWARD chain to DROP. This is a security measure -- it prevents the host from acting as an unintended router. But it also means that any non-Docker forwarded traffic (VPN tunnels, virtual machines, additional bridge networks) gets dropped.
# Check the FORWARD chain policy $ iptables -L FORWARD Chain FORWARD (policy DROP) target prot opt source destination DOCKER-USER all -- anywhere anywhere DOCKER-ISOLATION-STAGE-1 all -- anywhere anywhere ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED DOCKER all -- anywhere anywhere ACCEPT all -- anywhere anywhere ACCEPT all -- anywhere anywhere
If you need forwarding for non-Docker traffic, add explicit ACCEPT rules in the DOCKER-USER chain (which Docker creates specifically for user-defined rules). Alternatively, if you want Docker to stop setting the policy to DROP, add "ip-forward-no-drop": true to /etc/docker/daemon.json. For a deeper look at how Linux routing tables and the FORWARD chain interact, that article covers the kernel forwarding model in full.
The DOCKER-ISOLATION Chain Mechanism
The FORWARD chain output shown above includes two chains that often confuse administrators: DOCKER-ISOLATION-STAGE-1 and DOCKER-ISOLATION-STAGE-2. These implement Docker's inter-network isolation -- the guarantee that containers on different Docker networks cannot communicate with each other by default.
The mechanism is a two-stage match. STAGE-1 matches traffic arriving from any Docker bridge interface and sends it to STAGE-2. STAGE-2 then checks whether the traffic is also leaving through a Docker bridge interface -- but a different one. If both conditions are true, the packet is dropped. Traffic that arrives from a Docker bridge and leaves through the same bridge (same network communication) passes through.
$ iptables -L DOCKER-ISOLATION-STAGE-1 -n Chain DOCKER-ISOLATION-STAGE-1 (1 references) target prot source destination DOCKER-ISOLATION-STAGE-2 all -- 0.0.0.0/0 0.0.0.0/0 -- matches: any packet leaving through docker0 or br-* goes to stage 2 $ iptables -L DOCKER-ISOLATION-STAGE-2 -n Chain DOCKER-ISOLATION-STAGE-2 (1 references) target prot source destination DROP all -- 0.0.0.0/0 0.0.0.0/0 -- matches: if the packet is also entering a different Docker bridge, DROP it RETURN all -- 0.0.0.0/0 0.0.0.0/0 -- otherwise RETURN to FORWARD (allow within same network)
This matters for troubleshooting because custom Docker networks (created with docker network create) each get their own bridge interface (e.g., br-a1b2c3d4e5f6). If containers on different custom networks are unexpectedly unable to communicate -- even when you have explicitly linked them or set --network flags -- DOCKER-ISOLATION-STAGE-2 is usually the cause. The fix is to connect a container to both networks explicitly, not to try to route between bridges.
The Hidden Prerequisite: bridge-nf-call-iptables
There is a kernel-level prerequisite that must be met for Docker's iptables rules to function at all, and it is almost never mentioned in firewall troubleshooting guides. The sysctl parameter net.bridge.bridge-nf-call-iptables controls whether traffic crossing a Linux bridge is sent through the iptables/nftables netfilter hooks. If this is set to 0, bridge traffic bypasses netfilter entirely -- Docker's DNAT rules, FORWARD rules, and ISOLATION chains all stop working, and containers lose published port functionality silently.
# Check the current value -- must be 1 for Docker networking to function $ cat /proc/sys/net/bridge/bridge-nf-call-iptables 1 # If it is 0, the br_netfilter kernel module may not be loaded $ lsmod | grep br_netfilter br_netfilter 32768 0 # If the module is missing, load it first, then set the sysctl # (the sysctl key does not exist until the module is loaded) $ sudo modprobe br_netfilter $ sudo sysctl -w net.bridge.bridge-nf-call-iptables=1 $ sudo sysctl -w net.bridge.bridge-nf-call-ip6tables=1 # Make persistent across reboots $ echo "br_netfilter" | sudo tee /etc/modules-load.d/br_netfilter.conf $ sudo tee /etc/sysctl.d/99-bridge-netfilter.conf <<'EOF' net.bridge.bridge-nf-call-iptables=1 net.bridge.bridge-nf-call-ip6tables=1 EOF $ sudo sysctl -p /etc/sysctl.d/99-bridge-netfilter.conf
Docker normally loads br_netfilter and sets bridge-nf-call-iptables=1 automatically at daemon startup. But if Docker is started before the module loads (e.g., in some minimal container orchestration environments or after a kernel update that changed module paths), it may silently skip this step. Kubernetes also requires bridge-nf-call-iptables=1 and fails in confusing ways if it is absent -- which is why kubeadm's preflight checks explicitly verify it.
Docker Engine 29.0.0 introduced experimental opt-in support for a native nftables firewall backend. When enabled, Docker creates its rules in dedicated nftables tables (ip docker-bridges and ip6 docker-bridges) instead of relying on the iptables-nft compatibility layer. According to the Docker Engine v29 release notes, nftables support is planned to become the default in a future release, at which point iptables support will be deprecated. This is a clean separation, but the migration itself introduces a transition conflict.
{
"firewall-backend": "nftables"
}
When switching from the iptables backend to nftables tables on restart, Docker will delete its old iptables chains and create new nftables tables on restart. There are four critical differences to understand.
First, the DOCKER-USER chain does not exist in the nftables backend. Any custom rules you placed there need to be migrated to a separate nftables table with appropriate chain priorities. The Docker documentation states: "In nftables, an 'accept' rule is not final. It terminates processing for its base chain, but the accepted packet will still be processed by other base chains, which may drop it." This means you cannot simply add an accept rule in your custom table to override Docker's drop rules -- you must use a firewall mark via --bridge-accept-fwmark on the daemon, then set that mark in your custom chain before Docker's rules evaluate the packet.
Second, with the nftables backend, Docker does not automatically enable IP forwarding -- unlike the iptables backend. If IP forwarding was previously enabled by Docker and you reboot, Docker will fail to start until you manually set net.ipv4.ip_forward=1 in sysctl.
Third, as of Docker Engine 29, nftables mode does not support Docker Swarm. Swarm nodes must continue using the iptables backend until a future release adds support.
Fourth, on hosts running firewalld, Docker with nftables still creates firewalld zones and policies for its bridge interfaces, but it writes nftables rules directly rather than going through firewalld's deprecated "direct" iptables interface -- a cleaner integration that avoids one class of conflicts entirely.
The nftables backend in Docker is still marked as experimental. The nftables table structure and rule priorities may change between Docker Engine releases. Do not rely on the internal structure of Docker's nftables tables -- place your custom rules in separate tables using the documented priority values.
When placing custom rules that must run before or after Docker's nftables chains, you need the exact priority values Docker uses. These are documented in the Docker nftables migration guide and are stable within Docker Engine 29.x:
| Chain name | Hook | Priority value | Notes |
|---|---|---|---|
filter-forward |
forward | filter (0) | Main forwarding rules. Custom rules at priority -1 run before this. |
filter-prerouting |
prerouting | filter (0) | DNS filtering for container namespaces. |
nat-prerouting |
prerouting | dstnat (-100) | DNAT rules for published ports. Cannot be intercepted before DNAT without matching original dst. |
nat-postrouting |
postrouting | srcnat (100) | MASQUERADE rules for outbound container traffic. |
forward hook at priority -1. To inspect or modify traffic after DNAT has already been applied (so you can match on the container's actual IP and port rather than the host port), use a forward hook at any priority, since DNAT runs in prerouting at -100 and is complete by the time forward hooks fire.
UFW (Uncomplicated Firewall) is the default firewall tool on Ubuntu and Debian derivatives, and it is the most common firewall used on Docker hosts. The interaction between UFW and Docker is a well-known problem: UFW rules do not apply to published container ports at all.
The reason is the same DNAT mechanism described above, but UFW makes it worse by design. UFW manages iptables rules in the INPUT, OUTPUT, and FORWARD chains of the filter table. Docker inserts its DNAT rules into the PREROUTING chain of the nat table. Because nat PREROUTING runs before filter INPUT, traffic to a published container port is redirected before UFW ever evaluates it. A UFW rule blocking port 8080 does nothing.
There are two reliable approaches. The first is to bind published ports to localhost and use a reverse proxy (nginx, Caddy) as the only externally exposed entry point -- UFW rules on the proxy's port then work correctly. The second is to configure Docker to not manage its own iptables rules and instead manage all container NAT and forwarding through UFW directly, though this requires manually writing the forwarding rules UFW would otherwise not handle.
The simplest practical fix for most deployments is the localhost-binding approach:
# Bind the Docker daemon to localhost only so UFW controls external access { "ip": "127.0.0.1" } # Then use a reverse proxy listening on 0.0.0.0 with UFW rules on its port
Setting "ip": "127.0.0.1" in daemon.json changes the default bind address for all containers that do not explicitly specify an interface in their -p flag. Containers that specify -p 0.0.0.0:8080:80 explicitly will override this default and still bind to all interfaces.
Conflict 5: docker-compose Up Opens Ports You Did Not Expect
A common and disorienting scenario: you run docker-compose up and suddenly a service is reachable from the internet that was blocked before. This happens because Compose creates a new bridge network for each project, and each new Docker bridge interface lands in a firewalld zone. Depending on your firewalld configuration, that zone may have a permissive target.
When Docker creates a bridge interface for a Compose project, it registers the interface with firewalld and assigns it to the docker zone (which Docker creates with target ACCEPT). If you have previously configured firewalld to be strict using StrictForwardPorts, but your Compose service's bridge lands in the docker zone before StrictForwardPorts evaluates, the port is effectively open. The effect is amplified if multiple Compose projects each create separate bridge interfaces, since each one is independently registered.
# List all bridge interfaces Docker has created (docker0 + br-* for custom networks) $ ip link show type bridge | awk -F': ' '/docker0|br-/{print $2}' | cut -d'@' -f1 # Check which firewalld zone each bridge is assigned to $ for iface in $(ip link show type bridge | awk -F': ' '/docker0|br-/{gsub(/@.*$/, "", $2); print $2}'); do echo "$iface -> $(firewall-cmd --get-zone-of-interface="$iface" 2>/dev/null || echo 'unassigned')" done # List what ports firewalld is actually permitting forwarded to containers $ iptables -t nat -L DOCKER -n | grep DNAT # For a Compose project: identify which bridge belongs to which project $ docker network ls --format '{{.Name}}\t{{.Driver}}\t{{.ID}}' | grep bridge $ docker network inspect NETWORK_ID --format '{{.Options}}'
The fix is the same as for standard Docker: use StrictForwardPorts=yes and explicitly forward only the ports you intend to expose, or bind all Compose service ports to 127.0.0.1 in your docker-compose.yml and front them with a reverse proxy. For environments where Compose projects are created and destroyed frequently, also add a periodic audit to your runbook checking for bridge interfaces that have been assigned to the trusted zone inadvertently.
Conflict 6: Host Network Mode Bypasses Container Firewalling Entirely
When a container runs with --network=host (or network_mode: host in Compose), it does not get its own network namespace. It shares the host's network stack directly. There is no bridge interface, no DNAT, no NAT of any kind, and no DOCKER-USER chain involvement. The container's process binds directly to the host's IP addresses as if it were a regular host process.
This has two significant consequences for firewall management. First, host-network containers are subject to the host firewall -- firewalld INPUT rules, UFW rules, and raw iptables INPUT rules all apply normally. This is the opposite of the standard DNAT bypass problem. Second, it means that any port the container listens on is indistinguishable from a host service at the network level, so your firewall policy for container traffic and host service traffic merges into one. A misconfigured host-network container can expose ports that administrators assumed were protected by the container networking layer.
# List all containers using host networking $ docker ps --filter network=host --format '{{.Names}}\t{{.Image}}\t{{.Ports}}' # Check what the container is actually listening on (runs inside the host netns) $ ss -tlnp | grep -v '127.0.0.1' # Confirm a container is using host network mode $ docker inspect my-container --format '{{.HostConfig.NetworkMode}}' host
Host network containers are not protected by the DOCKER-USER chain, the DOCKER-ISOLATION chains, or any of Docker's per-container firewall rules. They must be managed exclusively through host-level firewall rules (firewalld zones, UFW, or direct iptables/nftables). If you are running host-network containers in a shared hosting or multi-tenant environment, treat each one as a host process for security policy purposes.
Podman's firewall behavior depends on whether it runs as root or as a regular user, and which networking backend it is configured to use.
Podman-Specific Firewall Conflicts
Rootless Podman: Minimal Conflict
Rootless Podman (running as a regular user) handles networking through user-space tools like slirp4netns or pasta. It does not inject kernel-level firewall rules. Published ports are proxied through user-space, which means host firewall rules apply normally. If firewalld blocks port 8080, a rootless Podman container publishing on that port will not be reachable from external hosts.
This makes rootless Podman the least conflict-prone container runtime on Linux. The trade-off is that user-space networking adds a small amount of latency compared to kernel-level NAT, and some advanced networking features (like running containers with static IP addresses on custom bridge networks) require rootful mode.
Rootful Podman: Similar to Docker
Rootful Podman uses Netavark (or the older CNI plugins) for networking and creates bridge interfaces similar to Docker. It inserts kernel-level NAT and forwarding rules, which means it has the same class of firewall conflicts as Docker. Published ports bypass firewalld's filter rules through the same prerouting DNAT mechanism.
Podman's Netavark backend can use either iptables or nftables for rule creation. The firewall driver is configured in containers.conf:
[network] firewall_driver="nftables"
When Netavark uses the nftables driver, all container rules are placed in a dedicated netavark table. This isolates them from other nftables rules and reduces the chance of conflicts with firewalld or custom rulesets. Fedora 41 and later ship with nftables as the default Netavark firewall driver. The Netavark maintainers have stated that the iptables driver is planned for deprecation and removal -- if you are building new infrastructure, use nftables or the firewalld driver. Supported values for firewall_driver are nftables, iptables, firewalld, and none.
Do not change the firewall driver while containers are running. Switching from iptables to nftables with active containers will leave orphaned iptables rules that are never cleaned up. Stop all containers, change the driver, and reboot to ensure a clean state.
Podman with the Native firewalld Driver
Netavark also offers a native firewalld driver that interacts with firewalld through its D-Bus API instead of manipulating nftables or iptables directly. This provides tighter integration but has limitations: it does not support network isolation between container networks, and connections to forwarded ports from the same host only work through the IPv4 localhost address (127.0.0.1).
[network] firewall_driver="firewalld"
If you have StrictForwardPorts=yes enabled in firewalld.conf, be aware that the Netavark firewalld driver currently bypasses that protection. The Netavark documentation states explicitly: "the firewalld driver presently bypasses this protection, and will still allow traffic through the firewall when StrictForwardPorts is enabled without manual forwarding through firewall-cmd. This may be changed in a future release." If strict port enforcement is required, use the nftables driver instead of the firewalld driver in Netavark.
Running Docker and Podman on the Same Host
Some environments run both Docker and Podman on the same host -- Docker for legacy workloads, Podman for newer ones. This creates a specific conflict class that neither runtime anticipates: both runtimes claim ownership of overlapping parts of the netfilter subsystem.
The most common symptom is that one runtime's containers lose connectivity after the other runtime starts, restarts, or modifies its rules. Docker's FORWARD chain policy of DROP can block traffic that Netavark expected to pass through. Netavark's nftables rules in the netavark table can interact unpredictably with Docker's rules in the filter table if both runtimes share the same backend.
The cleanest approach is backend isolation: configure Docker to use the iptables backend and Podman/Netavark to use the nftables backend, or vice versa. Because iptables-nft and nftables rules both live in the kernel's netfilter subsystem, they still see each other's traffic -- but they operate in separate tables, which reduces the chance of one runtime accidentally overwriting the other's chains.
# Keep Docker on the iptables backend (default) # /etc/docker/daemon.json -- no firewall-backend entry needed # Force Podman/Netavark to use nftables so it writes to a separate table # /etc/containers/containers.conf.d/50-netavark-nftables.conf [network] firewall_driver="nftables" # Verify Docker's chains are present $ iptables -L DOCKER -n 2>/dev/null # Verify Netavark's nftables table is present $ sudo nft list tables | grep netavark
The Fedora project's NetavarkNftablesDefault change documentation specifically addresses the Docker-and-Podman coexistence scenario and notes that separating the backends is the recommended approach when both runtimes must operate on the same host.
Diagnosing the Conflict
When container networking breaks or behaves unexpectedly, follow a systematic approach. The goal is to identify which rules are active, which backend created them, and where in the netfilter pipeline the conflict occurs. The ip command is essential here -- it replaced ifconfig as the standard tool for inspecting interfaces, addresses, and routes.
Step 1: Identify Active Firewall Backends
Before inspecting rules, confirm which firewall components are active on the host. If you are not sure which process is holding a port open, finding the process using a port with ss or lsof is a useful first step.
# Which iptables variant is installed? $ iptables --version # Is firewalld running? $ systemctl status firewalld # What firewalld backend is configured? $ grep FirewallBackend /etc/firewalld/firewalld.conf # Are there legacy iptables rules present? $ iptables-legacy -L -n 2>/dev/null | head -20 # Full nftables ruleset (can be long) $ sudo nft list ruleset | head -100 # Which firewall driver is Podman using? $ podman info 2>/dev/null | grep -i firewall
Step 2: Inspect Container NAT and Forwarding Rules
# Show NAT rules (iptables-nft or legacy) $ iptables -t nat -L -n -v # Show nftables NAT table directly $ sudo nft list table ip nat 2>/dev/null # Check Docker-specific nftables tables $ sudo nft list table ip docker-bridges 2>/dev/null # Check Netavark nftables tables $ sudo nft list tables | grep netavark # Check the FORWARD chain policy and rules $ iptables -L FORWARD -n -v # Check which interfaces are in which firewalld zones $ firewall-cmd --get-active-zones
Step 3: Test Connectivity from the Right Perspective
Testing from the host itself is misleading because loopback traffic follows a different netfilter path than traffic arriving from external interfaces. Always test from a separate machine or use tcpdump on the host's physical interface to observe what actually arrives. For a full treatment of remote packet capture piped through SSH, see Wireshark with a Remote Linux Capture.
# On the host: capture traffic on the physical interface $ sudo tcpdump -i eth0 port 8080 -n # On the host: capture traffic on the container bridge $ sudo tcpdump -i docker0 port 80 -n # From an external machine: test if the port is reachable $ nmap -p 8080 your-server-ip # Trace the netfilter path for a specific packet $ sudo nft monitor trace
The nft monitor trace command shows you every nftables rule that evaluates a packet in real time. Combined with nft add rule ... meta nftrace set 1 on a specific chain, this is the most powerful tool for understanding exactly where in the pipeline a packet is being accepted, dropped, or redirected. For even deeper kernel-level tracing without adding firewall rules, eBPF-based tracing can observe packet flow at every hook point with minimal overhead.
The Conntrack Asymmetry Problem
One of the most disorienting diagnostic scenarios is when a firewall rule change appears to take effect immediately for new connections but existing connections continue to work as if the rule was never added. This is not a bug -- it is conntrack (connection tracking) behaving correctly.
The FORWARD chain rule ctstate RELATED,ESTABLISHED ACCEPT (which Docker inserts) means that any packet belonging to an already-established connection is accepted unconditionally, regardless of what other rules say. When you add a DROP rule to block a container port, it only affects new connection attempts. Existing TCP sessions that were established before your rule change will continue flowing until they close naturally.
This has a practical implication during incident response: if you add a blocking rule to contain a compromised container and then check whether it is working by running the same connection test you were already running, you may see traffic continue and incorrectly conclude the rule failed. Always test with a fresh connection from a new client, or flush conntrack state explicitly:
# List current tracked connections to a container port $ conntrack -L -p tcp --dport 8080 2>/dev/null # Delete a specific tracked connection (terminates it immediately) $ sudo conntrack -D -p tcp --dport 8080 # Flush ALL tracked connections (nuclear option -- disrupts all active sessions) $ sudo conntrack -F # conntrack may not be installed by default $ sudo apt install conntrack # Debian/Ubuntu $ sudo dnf install conntrack-tools # RHEL/Fedora
Runtime vs. Firewall Compatibility at a Glance
This is a distinct symptom that differs from the published-port-bypass problem. If curl localhost:8080 works from the host but the same port is unreachable from another machine on the network, the issue is almost always one of three things: IP forwarding is disabled, masquerading (SNAT) for outbound container traffic is not configured, or the container's bridge interface is assigned to a firewalld zone with a restrictive policy.
# Check IP forwarding -- must be 1 for containers to be reachable externally $ cat /proc/sys/net/ipv4/ip_forward # If 0, enable it: $ sudo sysctl -w net.ipv4.ip_forward=1 # Check that masquerading (SNAT) is configured for the Docker bridge subnet $ iptables -t nat -L POSTROUTING -n -v | grep -i masq # Should show a MASQUERADE rule for 172.17.0.0/16 (default Docker subnet) # Check which zone the Docker bridge is in and what its target is $ firewall-cmd --get-zone-of-interface=docker0 $ firewall-cmd --zone=$(firewall-cmd --get-zone-of-interface=docker0) --list-all # Verify the container is actually listening on 0.0.0.0, not 127.0.0.1 $ ss -tlnp | grep 8080
IP forwarding being disabled is particularly common after switching from the Docker iptables backend to the nftables backend, since the iptables backend enabled forwarding automatically and the nftables backend does not. It also occurs when a system update or kernel parameter hardening script resets net.ipv4.ip_forward to 0. Make the setting persistent by adding it to /etc/sysctl.d/:
Resolving Common Conflicts
firewalld Reload Destroying Docker Rules
Running firewall-cmd --reload flushes and rewrites all iptables rules managed by firewalld. Because Docker's rules live in the same iptables tables, a firewalld reload destroys them. Containers lose network connectivity immediately, and the problem persists until the Docker daemon restarts and rebuilds its chains.
This is a common operational hazard on RHEL, CentOS, and Fedora systems where administrators routinely reload firewalld after configuration changes. Docker is aware of the issue and has a partial mitigation: when firewalld is running, Docker registers a D-Bus listener and rebuilds its rules automatically after a reload. However, this only works if the Docker daemon is already watching the D-Bus signal, which requires Docker to have started after firewalld. If the services start in the wrong order, the listener is never registered.
The rp_filter Interaction with Container Bridge Networks
This problem appears in virtually no troubleshooting guides despite causing genuine production outages. The Linux kernel's reverse path filter (rp_filter) validates that a packet's source address is reachable via the same interface the packet arrived on. When set to strict mode (1), the kernel drops any packet whose source address does not match the expected route back out through the arrival interface.
Container bridge networking creates a situation where rp_filter can silently drop legitimate traffic. A packet from an external client arrives on eth0, gets DNAT'd to a container address on 172.17.0.0/16, then the kernel checks the reverse path for 172.17.0.0/16 and finds it should exit via docker0, not eth0. In strict mode, this asymmetry causes the packet to be dropped with no log entry, no conntrack entry, and no indication from iptables or nftables that anything went wrong.
The symptom is containers being unreachable from external clients even though curl localhost:8080 works, forwarding rules look correct, and tcpdump on eth0 shows the packets arriving. The fix is to set rp_filter to loose mode (2) on the bridge interface -- not to disable it entirely, which would weaken your spoofing protection on all interfaces:
# Check current rp_filter settings for relevant interfaces $ cat /proc/sys/net/ipv4/conf/all/rp_filter $ cat /proc/sys/net/ipv4/conf/docker0/rp_filter # Set loose mode on the docker bridge (2 = loose, 1 = strict, 0 = disabled) $ sudo sysctl -w net.ipv4.conf.docker0.rp_filter=2 # Make persistent for docker0 and any custom bridge interfaces $ sudo tee /etc/sysctl.d/99-docker-rp-filter.conf <<'EOF' net.ipv4.conf.docker0.rp_filter=2 net.ipv4.conf.all.rp_filter=2 EOF $ sudo sysctl -p /etc/sysctl.d/99-docker-rp-filter.conf # Verify a new container bridge interface has the right setting after creation $ docker network create test-net && cat /proc/sys/net/ipv4/conf/br-*/rp_filter
Docker does not set rp_filter for bridge interfaces it creates. The all sysctl value applies as the effective setting when no interface-specific override exists, so setting net.ipv4.conf.all.rp_filter=2 is the most reliable approach. If your host security policy requires rp_filter=1 on all interfaces, you can instead add a specific route for each Docker bridge subnet via the relevant interface, which makes the reverse path check pass in strict mode -- though this adds maintenance overhead when bridge subnets change.
The Conntrack Zone Problem in Asymmetric Container Routing
There is a subtler conntrack problem that rarely appears in documentation: when the same connection is NATted multiple times across different bridge interfaces, the kernel's connection tracking can end up in an inconsistent state because it identifies connections by a tuple that includes the interface zone, not the full routing context.
This manifests specifically when a container needs to reach another container on a different Docker network via the host's IP address (hairpin routing), or when a host process forwards traffic between two container networks without going through Docker's published-port mechanism. The kernel sees what appears to be two different connections with overlapping tuples and either drops reply packets as INVALID or, worse, routes replies to the wrong container.
The diagnostic signature is ctstate INVALID drops in the FORWARD chain combined with tcpdump showing SYN-ACK packets arriving but never completing the handshake:
# Watch for INVALID state drops (add logging rule temporarily) $ sudo iptables -I FORWARD 1 -m conntrack --ctstate INVALID -j LOG --log-prefix "CT_INVALID: " --log-level warning # On systemd hosts, read from the kernel log (more reliable than dmesg) $ sudo journalctl -k --since "5 minutes ago" | grep CT_INVALID # On non-systemd hosts: $ sudo dmesg | grep CT_INVALID | tail -20 # Check conntrack table for ambiguous entries $ sudo conntrack -L 2>/dev/null | grep -v ESTABLISHED | head -40 # The fix: explicitly accept INVALID packets on the DOCKER-USER chain # (only when you have confirmed the INVALID drops are from known-legitimate flows) $ sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate INVALID -j ACCEPT # Remove the logging rule when done $ sudo iptables -D FORWARD 1
The longer-term fix is to avoid routing container-to-container traffic via the host IP entirely. Connect containers that need to communicate to the same Docker network, and use Docker's internal DNS (container-name:port) for service discovery. Traffic on the same Docker network never crosses the FORWARD chain or involves NAT, which eliminates this class of conntrack problem entirely.
The reliable fix is to ensure proper service ordering and to confirm Docker rebuilt its rules after any firewalld reload:
# After any firewall-cmd --reload, confirm Docker's chains are present $ iptables -L DOCKER -n 2>/dev/null && echo "Docker chains present" || echo "Docker chains missing" # If missing, restart Docker to rebuild them $ sudo systemctl restart docker # Make Docker start after firewalld to ensure the D-Bus listener registers correctly # Add this to /etc/systemd/system/docker.service.d/override.conf $ sudo mkdir -p /etc/systemd/system/docker.service.d $ sudo tee /etc/systemd/system/docker.service.d/override.conf <<'EOF' [Unit] After=firewalld.service Wants=firewalld.service EOF $ sudo systemctl daemon-reload
When Docker is configured with the nftables backend (Docker Engine 29+), this problem is reduced because Docker writes its rules to separate nftables tables that firewalld does not own. However, firewalld still manages its own nftables tables and a reload can still disrupt zone assignments for Docker bridge interfaces. Verify bridge interface zone assignments after any firewalld reload regardless of the backend in use.
Mixed iptables-legacy and iptables-nft Rules
If both backends have active rules, consolidate onto a single backend. On modern distributions, iptables-nft is the right choice since it keeps everything in the nftables kernel subsystem.
# Flush all legacy iptables rules $ sudo iptables-legacy -F $ sudo iptables-legacy -t nat -F $ sudo iptables-legacy -t mangle -F # On Debian/Ubuntu: set the alternatives to the nft variant $ sudo update-alternatives --set iptables /usr/sbin/iptables-nft $ sudo update-alternatives --set ip6tables /usr/sbin/ip6tables-nft # Restart Docker to regenerate rules under the correct backend $ sudo systemctl restart docker
Creating a Dedicated firewalld Zone for Docker
Instead of fighting Docker's rule management, work with it by creating a dedicated firewalld zone for the Docker bridge interface. This lets firewalld recognize container traffic without interfering with Docker's NAT rules.
# Create a zone for Docker traffic $ sudo firewall-cmd --permanent --new-zone=docker $ sudo firewall-cmd --permanent --zone=docker --set-target=ACCEPT $ sudo firewall-cmd --reload # Assign the Docker bridge to this zone $ sudo firewall-cmd --zone=docker --change-interface=docker0 --permanent $ sudo firewall-cmd --reload # Control external access through the public zone $ sudo firewall-cmd --zone=public --add-port=8080/tcp --permanent $ sudo firewall-cmd --reload
Disabling Docker's iptables Management
For environments where you need complete firewall control, you can disable Docker's firewall rule management entirely. This is an advanced configuration that requires you to set up all NAT and forwarding rules manually.
{
"iptables": false
}
After setting "iptables": false, published ports will not work and containers will not have outbound internet access until you manually create the necessary NAT and forwarding rules. Restarting Docker is not sufficient to clean up pre-existing rules -- a full reboot is required. This approach is not recommended unless you have a specific requirement for it.
Using nftables Chain Priorities to Control Evaluation Order
When you need custom filtering rules that run before container NAT rules, nftables chain priorities let you control exactly when your rules evaluate. Docker's nftables tables use well-known priority values, so you can create a separate table with a higher priority (lower number) to intercept traffic first.
table inet container-filter { chain forward { type filter hook forward priority -1; policy accept; # Block external access to containers except ports 80 and 443 # nftables requires regex (=~) for wildcard interface matching, not glob patterns iifname != "docker0" oifname "docker0" tcp dport != { 80, 443 } drop iifname !~ "br-.*" oifname =~ "br-.*" tcp dport != { 80, 443 } drop } }
The priority -1 ensures this chain evaluates before Docker's forwarding rules (which use priority 0). Place your custom rules in a dedicated table rather than modifying Docker's tables directly -- Docker expects full ownership of its tables and may overwrite your changes.
Blocking a Specific Container Port Without Disrupting Others
This is the question the DNAT bypass explanation raises but never answers: if DROP rules in firewalld and UFW do not work on container traffic, what does? The answer is the DOCKER-USER chain, which Docker inserts into the FORWARD chain specifically so administrators can add rules that evaluate before Docker's own accept rules. Any packet entering a Docker bridge interface passes through DOCKER-USER unconditionally.
To block external access to a specific container port while keeping other containers working, target by destination IP address and port. Get the container's IP with docker inspect first:
# Get the container IP $ docker inspect my-container --format '{{.NetworkSettings.IPAddress}}' 172.17.0.3 # Block external access to that container on port 80 (the container's internal port) # (traffic from outside Docker's own subnet to this container IP) $ sudo iptables -I DOCKER-USER -d 172.17.0.3 -p tcp --dport 80 -j DROP # To block all external traffic to a container (allow only same-host access) $ sudo iptables -I DOCKER-USER -d 172.17.0.3 ! -s 172.17.0.0/16 -j DROP # To block by source IP (e.g., block a specific external address) $ sudo iptables -I DOCKER-USER -s 203.0.113.50 -d 172.17.0.3 -j DROP # Verify the rule is in place $ sudo iptables -L DOCKER-USER -n -v # For nftables backend (Docker Engine 29+): use a separate table at priority -1 # See the nftables chain priorities section above for the correct table structure
Container IPs assigned by Docker are not persistent -- they change every time the container restarts unless you assign a static IP via docker run --ip on a custom network. For production blocking rules, either assign a static IP to the container or use the container's subnet (e.g., the entire 172.17.0.0/16 default bridge) rather than a specific address. Custom named networks are more reliable: they let containers reach each other by name and give you a predictable subnet to write rules against.
Making Custom Firewall Rules Survive Docker Restarts
Any rule you add manually to DOCKER-USER or the FORWARD chain is stored in the kernel's in-memory rule table. When Docker restarts -- whether from a package update, a daemon crash, or a manual systemctl restart docker -- it flushes and rebuilds all of its iptables chains. Rules you added to DOCKER-USER survive this process because Docker only flushes its own chains (DOCKER, DOCKER-USER, DOCKER-ISOLATION-STAGE-1, DOCKER-ISOLATION-STAGE-2) and then recreates them from scratch, reinserting the DOCKER-USER jump rule into FORWARD. Your rules inside DOCKER-USER are gone. The DOCKER-USER chain itself is recreated empty.
The reliable fix is to restore your custom rules via a systemd unit that fires after Docker starts:
[Unit] Description=Restore custom Docker firewall rules After=docker.service BindsTo=docker.service [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/local/sbin/docker-custom-rules.sh [Install] WantedBy=multi-user.target
#!/bin/bash set -e # Wait until Docker has rebuilt its chains (max 30 seconds) timeout=30 until iptables -L DOCKER-USER -n >/dev/null 2>&1; do timeout=$((timeout - 1)) [ "$timeout" -le 0 ] && { echo "Timed out waiting for DOCKER-USER chain"; exit 1; } sleep 1 done # Re-apply custom rules (runs as root via systemd) iptables -I DOCKER-USER -d 172.17.0.3 -p tcp --dport 80 -j DROP # For nftables backend (Docker Engine 29+): load the custom table instead # nft -f /etc/nftables.d/container-filter.nft
$ sudo chmod +x /usr/local/sbin/docker-custom-rules.sh $ sudo systemctl daemon-reload $ sudo systemctl enable --now docker-custom-rules.service
The BindsTo=docker.service directive ensures the custom rules service stops and restarts together with Docker, so the script always runs after Docker has rebuilt its chains. The until loop in the script guards against a race where Docker has started but hasn't yet created the DOCKER-USER chain.
Rate-Limiting Traffic to a Container Port
Exposed container services are common targets for brute-force attempts and scanning. You can rate-limit incoming connections at the netfilter layer using the hashlimit or recent modules in iptables, or using nftables meters. The rate limit must go into DOCKER-USER for the same reason blocking rules do -- DNAT has already run by the time the packet reaches the FORWARD chain, and DOCKER-USER is the earliest point where you can act on forwarded container traffic.
# Allow max 20 new connections per minute per source IP to container on port 80 # Anything above that is dropped -- protects against brute force and scanning $ sudo iptables -I DOCKER-USER -d 172.17.0.3 -p tcp --dport 80 --syn \ -m hashlimit \ --hashlimit-name docker-port80-limit \ --hashlimit-upto 20/min \ --hashlimit-burst 5 \ --hashlimit-mode srcip \ -j ACCEPT $ sudo iptables -A DOCKER-USER -d 172.17.0.3 -p tcp --dport 80 --syn -j DROP # nftables equivalent using a meter (priority -1 table, before Docker's rules) # Place in /etc/nftables.d/container-ratelimit.nft
table inet container-ratelimit { chain forward { type filter hook forward priority -1; policy accept; # Rate limit new TCP SYN packets to container at 172.17.0.3:80 # nftables rules must be on a single line -- no backslash continuation ip daddr 172.17.0.3 tcp dport 80 tcp flags & syn == syn meter container-port80 { ip saddr timeout 60s limit rate 20/minute } accept ip daddr 172.17.0.3 tcp dport 80 tcp flags & syn == syn drop } }
Both approaches track connections per source IP independently, so a single scanner hitting rate limits does not affect legitimate users from other addresses. The nftables meter approach is preferred on Docker Engine 29+ with the nftables backend because it uses atomic rule updates and avoids the DOCKER-USER restart problem described above.
Kubernetes and kube-proxy Considerations
Kubernetes adds another layer to the conflict. The kube-proxy component manages service routing by inserting rules into the kernel's packet filtering subsystem. Historically, kube-proxy used iptables exclusively. A nftables proxy mode was introduced as an alpha feature in Kubernetes 1.29, reached beta in 1.31, and reached stable (GA) status in Kubernetes 1.33 (released April 23, 2025) -- confirmed in the Kubernetes project's own announcement. It requires Linux kernel 5.13 or later. As of Kubernetes 1.35 (released December 17, 2025), nftables is the recommended kube-proxy mode for Linux nodes, and IPVS mode was formally deprecated in 1.35. The upstream Kubernetes 1.35 changelog states removal will occur in a future version; the upstream removal version has not been formally confirmed. The Kubernetes v1.35 sneak-peek post states directly that IPVS mode is being deprecated "to streamline the kube-proxy codebase" and that nftables is the recommended replacement on Linux. However, iptables remains the default for backward compatibility even in 1.35; you must explicitly opt in by setting mode: nftables in the kube-proxy configuration.
The nftables mode addresses fundamental performance problems with the iptables approach. In iptables mode, kube-proxy generates a number of rules proportional to the total number of services and endpoints in the cluster. Adding a single rule requires downloading the entire ruleset from the kernel, inserting the new rule, and re-uploading everything. In large clusters with tens of thousands of services, this becomes a significant bottleneck. The Kubernetes project's published benchmarks show that in a 30,000-service cluster, the worst-case per-packet latency for nftables is lower than the best-case latency for iptables. The improvement comes from nftables' use of verdict maps for service lookups rather than traversing a linear chain of rules, so processing time stays roughly constant as cluster size grows. For context on the broader Linux kernel tuning parameters that affect network throughput alongside these firewall changes, that guide covers the network stack in depth.
A particularly dangerous conflict occurs when different networking components on the same Kubernetes node use different versions of iptables. If the host uses iptables 1.8.8 but a CNI plugin ships with iptables 1.8.7, the older version can corrupt rules written by the newer version. The result is a KUBE-FIREWALL rule that drops all traffic, making the node unreachable. Ensure every networking component on the node uses the same iptables version and the same backend (legacy vs. nftables).
When switching kube-proxy from iptables to nftables mode, be aware of intentional behavior differences. In iptables mode, NodePort services are reachable on all local IP addresses by default -- including management interfaces. In nftables mode, the default changes to --nodeport-addresses primary, meaning NodePort services are only reachable on the node's primary addresses. The Kubernetes project considers this a security improvement, not a regression. CNI plugins and NetworkPolicy implementations also need to support the nftables kube-proxy mode, so verify compatibility with your specific network plugin before switching.
Preventing Conflicts Proactively
The best approach is to design your container networking and firewall configuration together from the start, rather than bolting one onto the other.
Standardize on a single firewall backend. Pick either iptables-nft or native nftables and ensure every component on the host -- container runtime, host firewall, fail2ban, VPN software -- uses the same backend. Flush rules from the other backend and remove the alternative binaries if possible.
Bind sensitive containers to localhost. If a container only needs to be accessible through a reverse proxy on the same host, publish it on 127.0.0.1 instead of all interfaces. This eliminates the DNAT bypass problem entirely because the traffic never crosses the host firewall.
Use Docker's internal network for backend services. Containers that should not be reachable from outside the Docker host (databases, caches, message queues) should be placed on internal networks with no published ports. They communicate with front-end containers through Docker's internal DNS, and no firewall rules are needed.
networks: frontend: driver: bridge backend: driver: bridge internal: true # no external access services: web: image: nginx ports: - "127.0.0.1:8080:80" # localhost only networks: - frontend - backend db: image: postgres:16 networks: - backend # internal only, no published ports
Test from outside the host. After every deployment, verify your firewall configuration by scanning published ports from an external machine. Do not rely on curl localhost or firewall-cmd --list-all alone -- these can report a state that does not match what external clients experience.
Persist custom nftables rules with a systemd service. Container runtimes recreate their firewall rules on restart, which may overwrite or conflict with rules you added manually. Place your custom rules in /etc/nftables.d/ and ensure they are loaded by a systemd unit that runs after the container runtime starts.
IPv6 and Container Firewalls
IPv6 is a common source of undetected firewall gaps in container environments. By default, Docker enables IPv6 only if you explicitly add "ipv6": true to daemon.json. But on dual-stack hosts, containers can receive IPv6 traffic via the host's physical interface even if Docker has not configured an IPv6 bridge -- and ip6tables rules are entirely separate from iptables rules.
When Docker is running with the iptables backend, it manages ip6tables rules separately from iptables rules. With the nftables backend, Docker creates a separate ip6 docker-bridges table alongside ip docker-bridges. In both cases, omitting IPv6 from your custom filtering rules leaves a gap that is easy to miss: traffic blocked at the IPv4 level may still reach containers over IPv6.
# Check whether Docker has created IPv6 NAT rules $ ip6tables -t nat -L -n 2>/dev/null # Check Docker's nftables IPv6 table (nftables backend only) $ sudo nft list table ip6 docker-bridges 2>/dev/null # Check whether IPv6 forwarding is enabled $ cat /proc/sys/net/ipv6/conf/all/forwarding # Check if the host has an IPv6 address on its external interface $ ip -6 addr show scope global
If your host has a globally routable IPv6 address and you are not intentionally exposing containers over IPv6, the safest option is to ensure Docker's IPv6 support is disabled in daemon.json and to add an ip6tables or nftables ip6 rule dropping forwarded traffic to Docker bridge subnets. If you do need IPv6 container networking, treat ip6tables rules with the same scrutiny as their IPv4 equivalents -- every custom filtering rule written for IPv4 needs a corresponding IPv6 rule.
Auditing What Is Actually Exposed Right Now
Before hardening or after any change, the first question to answer is: which container ports are actually reachable from outside this host? The answer is not obvious from firewall-cmd --list-all or docker ps alone, because DNAT bypasses firewalld's view of what is open. You need to look at the actual NAT rules the kernel is enforcing, not what any management tool reports.
# 1. What ports has Docker published and on which host addresses? $ docker ps --format '{{.Names}}\t{{.Ports}}' | grep -v '^$' # 2. What DNAT rules are actually loaded in the kernel right now? # These are the rules that matter -- firewalld's view is secondary $ iptables -t nat -L DOCKER -n --line-numbers | grep DNAT # For nftables backend: $ sudo nft list table ip docker-bridges 2>/dev/null | grep dnat # 3. Which of those ports are bound to 0.0.0.0 (externally reachable)? $ ss -tlnp | grep '0.0.0.0' # 4. Are any containers using host network mode (bypasses all of the above)? $ docker ps --filter network=host --format '{{.Names}}: {{.Command}}' # 5. External scan to verify what is actually reachable (run from another machine) $ nmap -sT -p- --open your-server-ip -oG - # 6. Check IPv6 exposure separately (often missed) $ ip6tables -t nat -L DOCKER -n 2>/dev/null | grep DNAT $ ss -6 -tlnp | grep -v '::1'
Run steps 1–4 from the host and step 5 from an external machine. If step 5 shows ports that steps 1–4 do not explain, you likely have a host-network container or a legacy firewall rule that is forwarding to something unexpected. The combination of the kernel DNAT table (step 2) and an external scan (step 5) is the authoritative answer to what is reachable -- everything else is a management layer view that may not reflect actual kernel state.
The Safe Production Exposure Pattern
For any container service that needs to be reachable from the internet, the pattern that eliminates the most failure modes is: bind the container to localhost, terminate TLS at a reverse proxy on the host, and firewall the proxy port directly using host-level rules that firewalld or UFW can actually enforce.
# Step 1: Bind the container to localhost only $ docker run -d -p 127.0.0.1:8080:80 --name myapp my-image # Step 2: Configure nginx as a reverse proxy on the host # /etc/nginx/conf.d/myapp.conf server { listen 443 ssl; server_name myapp.example.com; ssl_certificate /etc/letsencrypt/live/myapp.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/myapp.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } # Step 3: Open only 443 and 80 (for ACME) in firewalld $ sudo firewall-cmd --permanent --zone=public --add-service=https $ sudo firewall-cmd --permanent --zone=public --add-service=http $ sudo firewall-cmd --reload # Step 4: Verify the container port is NOT directly reachable from outside # (run from a separate machine -- nc exits immediately if the port is closed) $ nc -zv your-server-ip 8080 # Should report: Connection refused or timed out # Verify the proxy IS reachable: $ curl -I https://myapp.example.com
This pattern works correctly because the container port (8080) never crosses the host firewall -- it is a loopback connection between nginx and Docker. The only ports firewalld needs to manage are 443 and 80, which are host-level services that firewalld's INPUT chain handles perfectly. There is no DNAT bypass, no DOCKER-USER complexity, and no container-networking-specific firewall logic required. Rate limiting, IP blocking, and access control all go on the nginx side using standard web server configuration rather than netfilter rules.
Wrapping Up
Container firewall conflicts are not edge cases -- they affect virtually every production Linux host running containers alongside a host firewall. The root cause is always the same: container runtimes and host firewalls both manipulate netfilter, and they do not coordinate with each other.
The most impactful single fix is understanding that DNAT bypasses filter rules. Once you internalize this, the behavior of published ports becomes predictable rather than mysterious. From there, the resolution is systematic: identify which backends are active, inspect the rules each component created, and align everything on a single backend with clear separation between container-managed and administrator-managed rules. For teams looking to go further, a zero-trust architecture on Linux treats every container as untrusted by default and enforces explicit allow-listing at the network layer -- a good north star for hardened environments.
The Linux ecosystem is converging on nftables as the standard. Docker Engine 29 (released November 2025) offers opt-in native nftables support with full adoption and iptables deprecation planned for a future release. Podman's Netavark has already made nftables the default on Fedora 41+ and is deprecating the iptables driver. Kubernetes kube-proxy nftables mode reached GA in version 1.33 (released April 2025) and is the recommended choice for Linux nodes as of 1.35, with IPVS mode deprecated in 1.35 and the removal version not yet formally confirmed upstream. As this transition completes, many of the iptables-era conflicts described in this guide will disappear. Until then, the diagnostic approach here will get you through the transition.
How to Troubleshoot Container Firewall Conflicts
Step 1: Identify Which Firewall Backends Are Active
Run iptables --version to determine whether the system uses iptables-legacy or iptables-nft. Then use nft list ruleset to inspect nftables rules, and iptables-legacy -L -n to check for leftover legacy rules. Having both backends active simultaneously causes unpredictable rule conflicts that must be resolved before troubleshooting container networking.
Step 2: Inspect Container NAT and Forwarding Rules
Use iptables -t nat -L -n and nft list table ip nat to examine which NAT rules the container runtime has inserted. Check the FORWARD chain policy with iptables -L FORWARD to confirm whether it is set to ACCEPT or DROP. A DROP policy without matching ACCEPT rules for container bridge interfaces will silently block all container traffic.
Step 3: Test Connectivity from an External Host
Testing from the host itself is misleading because loopback traffic follows a different netfilter path than external traffic. Always test from a separate machine using nmap or curl. Use tcpdump on the physical interface to observe what actually arrives, and use nft monitor trace to watch which nftables rules evaluate a packet in real time.
Step 4: Verify IP Forwarding and Masquerade Rules
Check IP forwarding with cat /proc/sys/net/ipv4/ip_forward -- it must be 1. Verify masquerading with iptables -t nat -L POSTROUTING -n -v and confirm a MASQUERADE rule exists for the Docker bridge subnet. Also verify net.ipv4.conf.docker0.rp_filter is set to 2 (loose mode) so that asymmetric routing from bridge interfaces does not cause silent packet drops with no log entry.
Step 5: Align the Container Runtime with the Host Firewall
For Docker, configure the firewall backend in daemon.json to match the host. For Podman, set the firewall_driver in containers.conf to either nftables or firewalld. Create a dedicated firewalld zone for container bridge interfaces and assign those interfaces to it with firewall-cmd. If using StrictForwardPorts=yes in firewalld.conf, be aware that the Netavark native firewalld driver does not enforce this setting; use the nftables driver if strict port control is required. Reboot after changing the firewall driver to clear stale rules from the previous backend.
Step 6: Prevent Future Conflicts with Backend Standardization
Standardize on a single firewall backend -- either iptables-nft or native nftables -- across all components on the host. Bind sensitive containers to 127.0.0.1 to eliminate the DNAT bypass problem. Persist custom nftables rules with a systemd service that loads after the container runtime. Test all firewall changes from an external host, not from localhost, and flush conntrack state with conntrack -F before testing new blocking rules to avoid false passes from established-connection bypass.
Sources and Further Reading
The following upstream documentation and official project resources were consulted and verified in the production of this guide:
- Docker with nftables -- Docker Engine documentation. Covers the
firewall-backenddaemon option, theip docker-bridgestable structure, IP forwarding requirements, the--bridge-accept-fwmarkmechanism, and migration from the DOCKER-USER chain. - Packet filtering and firewalls -- Docker Engine documentation. Official reference covering Docker's interaction with firewalld zones, the UFW incompatibility, and the FORWARD chain policy behavior.
- Docker Engine v29: Foundational Updates for the Future -- Docker Blog. Official announcement of experimental nftables support, Swarm limitation, firewalld direct-interface replacement, and the plan to make nftables the default backend in a future release.
- Docker Engine v29 release notes -- Docker Docs. Full changelog confirming the nftables experimental flag, containerd image store default, and IP forwarding behavior change.
- netavark-firewalld(7) man page -- containers/netavark on GitHub. Authoritative reference for how Netavark interacts with firewalld, the behavior of StrictForwardPorts, the known bypass in the native firewalld driver, and the limitations of that driver.
- Fedora Changes: NetavarkNftablesDefault -- Fedora Project Wiki. Documents the switch to nftables as the default Netavark firewall driver in Fedora 41 and the interaction between Docker and Podman when run together on the same host.
- Strict Forward Ports -- firewalld.org. Official announcement and documentation for the StrictForwardPorts feature introduced in firewalld 2.3.0, including how it alters the DNAT accept rules and its nftables v1.0.7 kernel requirement.
- Strict Filtering of Docker Containers -- firewalld.org. Practical guide to disabling Docker iptables management and performing all container networking natively through firewalld policies.
- NFTables mode for kube-proxy -- Kubernetes Blog. The Kubernetes project's own explanation of the performance improvements, the alpha-to-GA path, and the intentional behavior differences from iptables mode.
- Virtual IPs and Service Proxies -- Kubernetes Documentation. Official reference confirming Kubernetes v1.33 stable status for nftables proxy mode, kernel 5.13 requirement, IPVS deprecation in 1.35, and NodePort addressing differences.
- Kubernetes v1.35 Sneak Peek -- Kubernetes Blog. Confirms IPVS mode deprecation in 1.35 (released December 17, 2025) and that nftables is the recommended kube-proxy mode for Linux nodes. The upstream Kubernetes 1.35 changelog states removal will occur "in a future version of Kubernetes"; the Amazon EKS release notes for 1.35 named 1.36 as the target, but IPVS was not removed in 1.36 and the upstream removal version has not been formally confirmed.
Frequently Asked Questions
Why can I reach my container port even though firewalld should be blocking it?
Container runtimes like Docker and rootful Podman insert NAT rules that forward traffic to published container ports before the host firewall evaluates filter rules. Because DNAT happens at the prerouting stage, the packet is already redirected to the container before firewalld or UFW can block it. Use StrictForwardPorts in firewalld 2.3.0 and later, or bind published ports to localhost with -p 127.0.0.1:8080:80 to prevent external access. Note that StrictForwardPorts does not protect against containers using Podman's native firewalld driver, which bypasses this enforcement.
How do I check whether my system uses iptables-legacy or iptables-nft?
Run iptables --version on the host. If the output includes (nf_tables), the system is using the iptables-nft compatibility layer, which translates iptables commands into nftables rules behind the scenes. If it shows (legacy), the system is using the original iptables kernel modules. Having both active simultaneously causes unpredictable rule behavior and should be resolved by standardizing on one backend.
Does rootless Podman bypass the host firewall?
No. Rootless Podman uses slirp4netns or pasta for networking and handles port forwarding through user-space proxying rather than kernel-level NAT rules. This means published ports on rootless Podman containers are still subject to the host firewall. If firewalld blocks port 8080, a rootless Podman container publishing on that port will not be reachable from external hosts.
What changed in Docker Engine 29 with respect to nftables?
Docker Engine 29 (released November 2025) introduced experimental opt-in support for a native nftables firewall backend, enabled by setting firewall-backend to nftables in daemon.json. When active, Docker creates rules directly in dedicated nftables tables named ip docker-bridges and ip6 docker-bridges, rather than relying on the iptables-nft compatibility layer. The DOCKER-USER iptables chain does not exist in this mode; custom rules must be placed in separate nftables tables using chain priorities. Unlike iptables, an accept verdict in nftables is not final -- to override Docker's drop rules you must use a firewall mark via --bridge-accept-fwmark rather than a simple accept rule. Docker with nftables does not automatically enable IP forwarding on the host. On firewalld hosts, Docker still creates firewalld zones for its bridge interfaces but writes nftables rules directly instead of going through firewalld's deprecated direct interface. Swarm mode is not supported with the nftables backend. In a future release, nftables is expected to become the default firewall backend.
What is the default Netavark firewall driver in Podman?
Netavark selects a firewall driver automatically based on the system. On Fedora 41 and later, nftables is the default. The iptables driver is planned for deprecation and removal. You can set the driver explicitly by placing a firewall_driver line under the [network] section in a file under /etc/containers/containers.conf.d/. Supported values are nftables, iptables, firewalld, and none. The firewalld driver uses the firewalld D-Bus API but has limitations including no support for network isolation.
Does enabling StrictForwardPorts in firewalld block all container ports including those managed by Podman's firewalld driver?
No. StrictForwardPorts=yes in firewalld.conf blocks DNAT traffic from Docker and from Podman when using the nftables or iptables Netavark driver. However, if Podman is configured to use Netavark's native firewalld driver, that driver currently bypasses StrictForwardPorts enforcement entirely. Container ports will remain accessible even with the setting enabled. The Netavark firewalld documentation explicitly notes this as a known limitation that may be addressed in a future release. If strict port control is required, use the nftables driver in Netavark rather than the firewalld driver.
Why do UFW rules not block Docker container ports?
UFW manages rules in the filter table's INPUT, OUTPUT, and FORWARD chains. Docker inserts DNAT rules into the nat table's PREROUTING chain, which runs before the filter table is evaluated. Traffic destined for a published container port is redirected by Docker's DNAT rule before UFW sees it, so UFW rules blocking that port have no effect. The most reliable fix is to bind Docker's published ports to 127.0.0.1 and use a reverse proxy as the externally exposed entry point, then apply UFW rules to the proxy's port instead.
My container is reachable from the host but not from other machines. What is wrong?
This symptom usually means IP forwarding is disabled, masquerading (SNAT) is not configured, or the container's bridge interface is in a restrictive firewalld zone. Check IP forwarding with cat /proc/sys/net/ipv4/ip_forward -- it must be 1. Verify masquerading with iptables -t nat -L POSTROUTING -n -v and confirm a MASQUERADE rule exists for the Docker bridge subnet. Also confirm the container is listening on 0.0.0.0 and not 127.0.0.1 by checking ss -tlnp. IP forwarding being disabled is especially common after switching to the Docker nftables backend, which does not enable forwarding automatically.
Why does my container become unreachable even though all forwarding rules look correct?
A common but rarely documented cause is the Linux kernel reverse path filter (rp_filter) set to strict mode. When DNAT redirects a packet to a container on a bridge subnet, the kernel's reverse path check compares the arrival interface against the expected route back -- and the asymmetry causes the packet to be silently dropped with no log entry and no conntrack entry. Check the setting with cat /proc/sys/net/ipv4/conf/docker0/rp_filter and set it to 2 (loose mode) with sysctl -w net.ipv4.conf.docker0.rp_filter=2. Make it persistent in /etc/sysctl.d/. The tell-tale symptom pattern is: containers unreachable from external hosts, curl localhost works, tcpdump shows packets arriving on eth0, but all forwarding rules appear correct. See The rp_filter Interaction with Container Bridge Networks for the full diagnostic workflow.
Why does connecting between two Docker networks cause dropped packets or incomplete TCP handshakes?
This is the conntrack zone problem caused by asymmetric container routing. When traffic routes between two Docker bridge networks via the host IP (hairpin routing), the kernel sees overlapping connection tuples and marks reply packets as INVALID state, dropping them. The diagnostic signature is ctstate INVALID drops in the FORWARD chain combined with tcpdump showing SYN-ACK packets that never complete the handshake. The permanent fix is to place containers that need to communicate on the same Docker network and use Docker's internal DNS for service discovery. Traffic within the same Docker network never crosses the FORWARD chain or involves NAT, eliminating this class of conntrack problem entirely. See The Conntrack Zone Problem in Asymmetric Container Routing for diagnostics.
How do I correctly block a specific container port using iptables without breaking other containers?
Use the DOCKER-USER chain, which Docker inserts into the FORWARD chain specifically so administrators can add rules that evaluate before Docker's own accept rules. All container bridge traffic passes through DOCKER-USER unconditionally. Get the container's IP with docker inspect my-container --format '{{.NetworkSettings.IPAddress}}', then insert a rule: iptables -I DOCKER-USER -d 172.17.0.3 -p tcp --dport 80 -j DROP. To block all external access while keeping same-host connectivity: iptables -I DOCKER-USER -d 172.17.0.3 ! -s 172.17.0.0/16 -j DROP. These rules are cleared when Docker restarts -- see Making Custom Firewall Rules Survive Docker Restarts for the systemd-based persistence approach. The DOCKER-USER chain does not exist when using the Docker nftables backend; use a priority -1 nftables table instead.
Why do my custom Docker firewall rules disappear after Docker restarts or updates?
When Docker restarts, it flushes and recreates its iptables chains from scratch, including DOCKER-USER, which is rebuilt empty. Any rules you added manually to DOCKER-USER are lost. The fix is a systemd service unit with After=docker.service and BindsTo=docker.service that runs a restore script after Docker starts. The script should poll until the DOCKER-USER chain exists before inserting rules, guarding against a race between systemd starting Docker and Docker finishing chain creation. See Making Custom Firewall Rules Survive Docker Restarts for the complete service unit and script.
Does --network=host mode bypass container firewall rules?
Yes, completely. A container running with --network=host shares the host's network namespace directly. There is no bridge interface, no DNAT, and no involvement of the DOCKER-USER or DOCKER-ISOLATION chains. The container process binds directly to host IP addresses exactly as a host service would, and is subject to the host firewall (firewalld INPUT rules, UFW rules) rather than Docker's container firewall rules. You cannot use DOCKER-USER to control host-network container traffic. Identify host-network containers with docker ps --filter network=host and manage their access through host-level firewall zones and services directly.
How do I audit which container ports are actually reachable from the internet right now?
Check the kernel's actual DNAT rules rather than relying on management tool output. Run iptables -t nat -L DOCKER -n to see every active port forward Docker has installed in the kernel. Run ss -tlnp and look for ports bound to 0.0.0.0 (externally reachable) versus 127.0.0.1 (localhost only). Check for host-network containers separately with docker ps --filter network=host. Audit IPv6 with ip6tables -t nat -L DOCKER -n. The authoritative answer requires an external scan: nmap -sT -p- --open your-server-ip from a separate machine. If the scan shows ports the kernel DNAT table does not account for, you likely have a host-network container or an unexpected forwarding rule.
What is the safest way to expose a container service to the internet in production?
Bind the container to localhost with -p 127.0.0.1:8080:80, then route external traffic through a reverse proxy (nginx or Caddy) running directly on the host. The proxy handles TLS termination and forwards requests to the localhost container port via a loopback connection that never crosses the host firewall. Firewalld or UFW only need to open port 443, which they manage correctly through the INPUT chain with no DNAT bypass involved. This pattern eliminates all container firewall complexity -- no DOCKER-USER rules, no risk of port exposure through network recreation, and no IPv6 gap. Rate limiting and IP blocking go on the proxy. See The Safe Production Exposure Pattern for a complete nginx + firewalld example.