DEV Community

augustine Egbuna
augustine Egbuna

Posted on • Originally published at fivenineslab.com

How to Block Docker Ports with nftables Without Getting Bypassed

You add an nftables rule to drop traffic on port 8080. You check the ruleset — it's active. You curl localhost:8080 from outside the host, and the Dockerized API responds anyway. Your firewall just got ignored.

This isn't a configuration mistake. Docker deliberately writes its own iptables rules that execute before nftables ever sees the packet. If you're running GPU inference services, internal LLM APIs, or any container that shouldn't be internet-facing, this behavior is a production security gap.

Why Docker Bypasses Your Firewall

Docker manipulates iptables-legacy directly, inserting DNAT rules in the nat table and ACCEPT rules in the filter table. These rules redirect incoming traffic to container IPs before your nftables ruleset runs.

Check what Docker created:

sudo iptables-legacy -t nat -L DOCKER -n -v
sudo iptables-legacy -t filter -L DOCKER -n -v
Enter fullscreen mode Exit fullscreen mode

You'll see entries like:

DNAT  tcp  --  *  *  0.0.0.0/0  0.0.0.0/0  tcp dpt:8080 to:172.17.0.2:8080
Enter fullscreen mode Exit fullscreen mode

The packet gets rewritten and forwarded before your nftables input chain ever evaluates it. Even if you block port 8080 in nftables, Docker's NAT rule already sent the traffic to the container.

On modern Debian and Ubuntu systems, nftables is the default firewall backend. But Docker still uses iptables-legacy for compatibility. This creates two parallel firewall systems — and Docker's rules win.

The Fix: Disable Docker's iptables Manipulation

Stop Docker from writing iptables rules. Edit /etc/docker/daemon.json:

{
  "iptables": false
}
Enter fullscreen mode Exit fullscreen mode

Restart Docker:

sudo systemctl restart docker
Enter fullscreen mode Exit fullscreen mode

Now Docker won't touch your firewall. But you've also disabled container NAT and port publishing. If you run docker run -p 8080:8080 myapp, the port mapping silently fails. The container starts, but nothing listens on the host.

You now manage all forwarding and NAT yourself in nftables.

Build Your Own Docker NAT in nftables

You need three components:

  1. DNAT for inbound traffic (external → container)
  2. SNAT for outbound traffic (container → internet)
  3. Forwarding rules between host and Docker bridge

Here's a complete nftables configuration for a single container exposing port 8080:

#!/usr/sbin/nft -f

flush ruleset

table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;
    ct state established,related accept
    iif "lo" accept
    # Allow SSH
    tcp dport 22 accept
    # Block direct access to 8080 from outside
    # Traffic will arrive via DNAT as forwarded packets
  }

  chain forward {
    type filter hook forward priority 0; policy drop;
    ct state established,related accept
    # Allow forwarding to Docker containers
    iif "eth0" oif "docker0" ip daddr 172.17.0.2 tcp dport 8080 accept
    # Allow container responses
    iif "docker0" oif "eth0" accept
  }

  chain output {
    type filter hook output priority 0; policy accept;
  }
}

table ip nat {
  chain prerouting {
    type nat hook prerouting priority -100; policy accept;
    # DNAT: external traffic on 8080 → container
    iif "eth0" tcp dport 8080 dnat to 172.17.0.2:8080
  }

  chain postrouting {
    type nat hook postrouting priority 100; policy accept;
    # SNAT: container outbound traffic → host IP
    oif "eth0" ip saddr 172.17.0.0/16 masquerade
  }
}
Enter fullscreen mode Exit fullscreen mode

Save this as /etc/nftables.conf and apply:

sudo nft -f /etc/nftables.conf
Enter fullscreen mode Exit fullscreen mode

Replace 172.17.0.2 with your container's IP. Find it with:

docker inspect -f '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}' <container_name>
Enter fullscreen mode Exit fullscreen mode

Selective Exposure: Allow Only Internal Networks

If you want the container reachable only from your private network (not the internet), add a source filter in the DNAT rule:

iif "eth0" ip saddr 10.0.0.0/8 tcp dport 8080 dnat to 172.17.0.2:8080
Enter fullscreen mode Exit fullscreen mode

This allows connections from RFC1918 space but drops everything else before DNAT happens.

For GPU inference APIs or internal vector search endpoints, this prevents accidental internet exposure while keeping the service available to your application tier.

Handling Multiple Containers

For multiple published ports, add one DNAT rule and one forward rule per container:

# Container 1: LLM API on 8080
iif "eth0" tcp dport 8080 dnat to 172.17.0.2:8080
iif "eth0" oif "docker0" ip daddr 172.17.0.2 tcp dport 8080 accept

# Container 2: Vector DB on 9200
iif "eth0" tcp dport 9200 dnat to 172.17.0.3:9200
iif "eth0" oif "docker0" ip daddr 172.17.0.3 tcp dport 9200 accept
Enter fullscreen mode Exit fullscreen mode

For a dynamic container environment, this manual approach doesn't scale. Use Docker networks with explicit binds (--publish 127.0.0.1:8080:8080) so the service listens only on localhost, then manage external access through an nginx reverse proxy protected by nftables.

Enable nftables on Boot

Make the ruleset persistent:

sudo systemctl enable nftables
sudo systemctl start nftables
Enter fullscreen mode Exit fullscreen mode

On Debian/Ubuntu, nftables reads /etc/nftables.conf at boot. Verify the service is active:

sudo systemctl status nftables
Enter fullscreen mode Exit fullscreen mode

What You Lose

With "iptables": false, Docker Compose port mappings (ports: - "8080:8080") stop working unless you manually configure nftables NAT. Docker networks still function for inter-container communication, but host publishing requires your explicit forwarding rules.

For production GPU clusters running inference APIs, this tradeoff is worth it. You control exactly which ports are exposed and to whom. A single nftables ruleset governs all traffic — no hidden Docker rules bypassing your firewall.

Verification

Test the block:

# From outside the host
curl http://<host-ip>:8080
# Should fail if no DNAT rule exists
Enter fullscreen mode Exit fullscreen mode

Add the DNAT rule, reload nftables, and retry. The request should reach the container.

Check your ruleset matches what you expect:

sudo nft list ruleset
Enter fullscreen mode Exit fullscreen mode

Verify Docker didn't sneak in iptables rules:

sudo iptables-legacy -t nat -L DOCKER
# Should be empty or show "Chain DOCKER (0 references)"
Enter fullscreen mode Exit fullscreen mode

If Docker re-created rules, it means daemon.json wasn't applied. Restart the daemon and double-check the JSON syntax.

Use Cases for Manual Firewall Control

This pattern matters when:

  • Running inference APIs on GPU instances where accidental exposure costs money and leaks proprietary models
  • Operating multi-tenant platforms where container isolation must be firewall-enforced, not just network-namespace-enforced
  • Deploying internal RAG pipelines with vector databases that should never touch the public internet
  • Meeting compliance requirements that demand explicit, auditable firewall rules for all published services

Docker's automatic iptables manipulation is convenient for development. In production infrastructure, convenience is a security liability. You need deterministic control over which packets reach which containers.


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)