DEV Community

augustine Egbuna
augustine Egbuna

Posted on • Originally published at fivenineslab.com

Docker's nftables Mode Doesn't Respect Your Drop Rules — Here's the Fix

You enable Docker's experimental nftables support, add a drop rule in /etc/nftables.conf, reload your firewall, and the container port stays wide open. The packet hits your drop rule, then Docker's accept rule fires anyway. This violates everything you thought you knew about packet filtering.

I hit this exact scenario running a multi-tenant LLM API platform where different teams deploy inference containers. One team accidentally exposed their Ollama admin interface on port 3000. Standard nftables drop rules in our firewall config did nothing — the port stayed accessible from the internet.

Why Docker's nftables Chains Bypass Your Rules

Docker 29+ creates its own nftables table (docker) with chains that hook into prerouting, forward, and postrouting. These chains have specific priority values that determine their execution order relative to your custom chains.

Here's the critical part: nftables evaluates chains based on priority within the same hook. A drop rule in your inet filter table with priority 0 doesn't automatically block packets that a docker table chain with priority -100 has already accepted.

Check what Docker actually created:

nft list ruleset | grep -A 20 "table inet docker"
Enter fullscreen mode Exit fullscreen mode

You'll see output like:

table inet docker {
    chain forward {
        type filter hook forward priority -100; policy accept;
        ct state established,related accept
        iifname "docker0" accept
        oifname "docker0" accept
    }
}
Enter fullscreen mode Exit fullscreen mode

That priority -100 means Docker's forward chain runs before your standard filter chain at priority 0. If Docker's chain accepts the packet, your drop rule never even sees it.

The Priority Math Docker Doesn't Tell You

Nftables priorities are integers. Lower (more negative) values run first. Standard filter tables use priority 0. Docker uses:

  • prerouting: priority -300 for DNAT rules
  • forward: priority -100 for container traffic acceptance
  • postrouting: priority 100 for masquerading

Your drop rule in a priority 0 chain fires after Docker has already said "yes, forward this packet to the container". The packet is gone.

Solution 1: Override Docker's Priority

Create a chain with a lower priority than Docker's -100 for the forward hook:

nft add table inet firewall
nft add chain inet firewall forward_early \
    '{ type filter hook forward priority -200; policy accept; }'
Enter fullscreen mode Exit fullscreen mode

Now add your drop rule:

# Block port 3000 to all containers
nft add rule inet firewall forward_early \
    tcp dport 3000 drop

# Or block specific container IPs
nft add rule inet firewall forward_early \
    ip daddr 172.17.0.5 tcp dport 3000 drop
Enter fullscreen mode Exit fullscreen mode

Verify the priority order:

nft list chains | grep forward
Enter fullscreen mode Exit fullscreen mode

You should see your forward_early chain listed with priority -200, which executes before Docker's -100 chain.

Solution 2: Modify Docker's Table Directly

Instead of fighting Docker's priorities, inject rules into Docker's own chains. This approach is cleaner for container-specific policies.

# Insert at the beginning of Docker's forward chain
nft insert rule inet docker forward \
    tcp dport 3000 drop

# Or match by container network
nft insert rule inet docker forward \
    iifname "br-a1b2c3d4e5f6" tcp dport 3000 drop
Enter fullscreen mode Exit fullscreen mode

The insert keyword places your rule at the top of the chain, before Docker's blanket accept rules. This works because you're operating within Docker's priority level.

I use this method in production to enforce per-network policies. Each Docker Compose stack gets its own bridge network, and we insert drop rules for admin ports (like Jupyter on 8888, or MLflow on 5000) directly into the inet docker forward chain.

Making Rules Persistent

Docker recreates its nftables rules on every daemon restart. Your manual nft commands vanish. You need a script that runs after Docker starts.

Create /etc/systemd/system/docker-firewall.service:

[Unit]
Description=Docker nftables Firewall Rules
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/bin/docker-firewall-rules.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
Enter fullscreen mode Exit fullscreen mode

Then create /usr/local/bin/docker-firewall-rules.sh:

#!/bin/bash
set -e

# Wait for Docker's nftables table to exist
max_attempts=10
attempt=0
while ! nft list table inet docker >/dev/null 2>&1; do
    attempt=$((attempt + 1))
    if [ $attempt -ge $max_attempts ]; then
        echo "Docker nftables table not found after $max_attempts attempts"
        exit 1
    fi
    sleep 1
done

# Insert drop rules for blocked ports
nft insert rule inet docker forward tcp dport 3000 drop
nft insert rule inet docker forward tcp dport 8888 drop
nft insert rule inet docker forward tcp dport 5000 drop

echo "Docker firewall rules applied"
Enter fullscreen mode Exit fullscreen mode

Make it executable and enable:

chmod +x /usr/local/bin/docker-firewall-rules.sh
systemctl daemon-reload
systemctl enable docker-firewall.service
systemctl start docker-firewall.service
Enter fullscreen mode Exit fullscreen mode

The Table Family Trap

One gotcha: if Docker uses inet (which handles both IPv4 and IPv6), your rules must also use inet. A rule in an ip table won't see IPv6 traffic, and Docker's inet chains will still forward it.

Always match table families:

# Wrong - only catches IPv4
nft add table ip firewall
nft add chain ip firewall forward_early \
    '{ type filter hook forward priority -200; }'

# Right - catches both stacks
nft add table inet firewall
nft add chain inet firewall forward_early \
    '{ type filter hook forward priority -200; }'
Enter fullscreen mode Exit fullscreen mode

Debugging Chain Execution

When rules don't work, trace the packet path:

# Enable packet tracing for port 3000
nft add rule inet firewall forward_early \
    tcp dport 3000 meta nftrace set 1

# In another terminal, watch the trace
nft monitor trace
Enter fullscreen mode Exit fullscreen mode

Then trigger traffic to port 3000. You'll see exactly which chains and rules the packet hits, in order. This shows you where Docker's chains accept the packet before your drop rule fires.

For production debugging, I prefer logging over tracing:

nft insert rule inet docker forward \
    tcp dport 3000 log prefix "DOCKER-BLOCK-3000: " drop
Enter fullscreen mode Exit fullscreen mode

Then tail /var/log/syslog or /var/log/kern.log to see blocked connection attempts with full packet details.

What About iptables-nft?

If you're using iptables-nft (the nftables backend for iptables commands), Docker's rules still win. The iptables commands generate nftables rules in a compatibility table, but Docker's native inet docker table has its own priority scheme.

The solution is the same: create chains with appropriate priorities, or modify Docker's chains directly. Don't rely on legacy iptables commands to override nftables-native Docker rules.


This post is an excerpt from Practical AI Infrastructure Engineering — a production handbook covering Docker, GPU infrastructure, vector databases, and LLM APIs. Full book with 4 hands-on capstone projects available at https://activ8ted.gumroad.com/l/ssmfkx


Originally published at fivenineslab.com

Top comments (0)