Bitdoze Logo

How to Fix Docker Bypassing Firewall: A Complete Guide

Docker bypasses UFW firewall rules. Secure your containers with localhost binding, DOCKER-USER iptables chain rules, the ufw-docker tool, and a Traefik reverse proxy.

DragosDragos21 min read
How to Fix Docker Bypassing Firewall: A Complete Guide

If you’re managing your own servers and running Docker containers, there’s a security issue you need to know about: Docker bypasses your system’s firewall by default. It’s not a bug, it’s how Docker’s networking works. But it means the UFW or iptables rules you set up are not protecting your containers the way you’d expect.

This guide covers every practical fix, from the two-minute localhost binding to architectural changes like rootless Docker. Everything here is tested with Docker 28.x and 29.x.

Tested with Docker 28.x and 29.x

Docker’s networking changed substantially in 28.0.0 (Feb 2025) and 29.0.0 (Nov 2025). The iptables chain structure was reworked, and nftables support arrived as experimental. If you’re on 27.x or older, the iptables commands below will still work, but the internal chain layout differs.

Understanding the problem: why Docker bypasses the firewall

Docker creates its own bridge network (usually docker0) and manages container networking through iptables (or nftables on newer setups). When you run a container with a port mapping like this:

services:
  myapp:
    image: nginx
    ports:
      - "8080:80"

You might think you’re opening port 8080 on localhost only. What actually happens: Docker binds to 0.0.0.0:8080 and inserts DNAT rules into your iptables, ahead of your regular firewall rules. So your UFW rules saying “block everything except 22, 80, 443” don’t apply to Docker’s forwarded traffic.

If you’re setting up containers for your home server or a VPS, this can expose services you thought were internal.

You can see Docker’s rules in action:

sudo iptables -L -n -v | grep DOCKER

You’ll see rules Docker added automatically. These are what bypass your firewall.

This is by design. Docker needs network control for inter-container communication and published ports. But “by design” doesn’t mean “acceptable for your setup.” Here’s how to fix it.

CVE-2025-54388 (fixed in Docker 28.3.3)

After a firewalld reload, published container ports could be accessed directly from the local network, even when bound to loopback. If you’re running Docker 28.0 through 28.3.2, update immediately. This was fixed in 28.3.3.

If you are interested to see some free cool open source self hosted apps you can check toolhunt.net self hosted section.

Solutions: configuring the firewall to control Docker traffic

These solutions range from quick fixes to architectural changes. Start with the first one and work down as needed.

1. Using localhost binding (the simplest fix)

The fastest mitigation: bind containers to 127.0.0.1 so they’re only accessible from the host itself.

services:
  myapp:
    image: nginx
    ports:
      - "127.0.0.1:8080:80"

This works because Docker skips its DNAT chain for loopback-bound ports. The container is reachable from localhost but invisible to the outside world.

Limitations: Other hosts on the same network can’t reach the container directly. For external access, you need a reverse proxy (Traefik, Caddy, Nginx) in front. That’s the recommended pattern anyway.

2. Using a Cloud firewall

Cloud-level firewalls operate at the network layer, before traffic ever hits your VPS. Docker can’t touch them because they run outside the host.

Providers with network-level firewalls:

These can’t be bypassed by Docker’s iptables manipulation. My typical setup:

  1. Allow ports 80, 443 (web traffic) and 22 (SSH)
  2. Block all other incoming traffic
  3. Add specific rules for any additional services

This is your outermost defense layer. It doesn’t replace fixing Docker’s behavior on the host, but it catches anything that slips through.

3. Using Traefik as a reverse proxy

Using Traefik as a reverse proxy is one of the best long-term solutions. Only Traefik’s ports are exposed. Everything else stays hidden.

Why Traefik over direct port exposure?

Only Traefik’s ports are exposed. All other containers remain hidden behind it. Built-in Let’s Encrypt, rate limiting, and middleware. No need to manage TLS certs per container.

Here’s a working setup:

services:
  traefik:
    image: traefik:v3
    command:
      - "--providers.docker=true"
      - "--api.dashboard=true"
      - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true"
      - "--certificatesresolvers.letsencrypt.acme.email=your@email.com"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--entrypoints.web.http.redirections.entrypoint.to=websecure"
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - letsencrypt:/letsencrypt
    labels:
      - "traefik.enable=true"

  myapp:
    image: nginx
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.myapp.rule=Host(`your-domain.com`)"
      - "traefik.http.routers.myapp.entrypoints=websecure"
      - "traefik.http.routers.myapp.tls.certresolver=letsencrypt"
    # Notice: no ports exposed directly
volumes:
  letsencrypt:

For the Traefik dashboard, secure it with basic authentication. The placeholder hash $$apr1$$xyz123$$ won’t work. Generate a real one:

echo $(htpasswd -nbB admin 'your-password') | sed -e 's/\$/\$\$/g'

Add the result to your Traefik labels or a file provider. For a full walkthrough on securing the proxy itself with basic authentication, see that dedicated guide.

4. Using Caddy + caddy-docker-proxy (lightweight alternative)

If Traefik feels like overkill, Caddy with the lucaslorentz/caddy-docker-proxy plugin gives you automatic HTTPS with simpler config:

services:
  caddy:
    image: lucaslorentz/caddy-docker-proxy:ci-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - caddy_data:/data
    environment:
      - CADDY_INGRESS_NETWORK=caddy

  myapp:
    image: nginx
    labels:
      caddy: your-domain.com
      caddy.reverse_proxy: "{{upstreams 80}}"
    # No ports exposed directly

networks:
  default:
    name: caddy
    external: true

volumes:
  caddy_data:

Create the external network first: docker network create caddy.

Caddy vs Traefik: Caddy has a simpler config model and automatic HTTPS by default (no explicit certresolver config). Traefik has more middleware options, a richer dashboard, and better support for complex routing. For solo operators running a handful of services, Caddy is often the better fit.

5. Using ufw-docker: the simplest UFW + Docker fix

If you’re using UFW, this is the tool you probably didn’t know existed. chaifeng/ufw-docker (6.6k+ stars on GitHub) automates the fix for Docker bypassing UFW.

Recommended for UFW users

ufw-docker is the de facto community solution for UFW + Docker. It replaces manual after.rules editing with simple per-container commands.

Install:

sudo wget -O /usr/local/bin/ufw-docker \
  https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker
sudo chmod +x /usr/local/bin/ufw-docker
sudo ufw-docker install

The install command modifies /etc/ufw/after.rules (and after6.rules for IPv6) to add Docker-aware filtering rules. It backs up the existing file first.

Allow a container’s port through the firewall:

sudo ufw-docker allow myapp 80
sudo ufw-docker allow myapp 443/tcp

List rules for a container:

sudo ufw-docker list myapp

Remove a rule:

sudo ufw-docker delete allow myapp 80

Key advantage over manual rules: ufw-docker keeps DEFAULT_FORWARD_POLICY="DROP" (the safe default) and only opens what you explicitly allow. The manual approach of setting it to ACCEPT opens all forwarded traffic. That’s a broad hammer.

I documented my full experience securing a Docker server after a BSI security report if you want the complete story.

Advanced firewall configurations: working with iptables and UFW

If you need finer-grained control or you’re not using UFW, these approaches work at the iptables level.

Working with iptables and the DOCKER-USER chain

The DOCKER-USER chain is special: Docker intentionally does not modify rules you put there. It’s processed before Docker’s own forwarding rules, so it’s the right place for admin-defined filtering.

Docker 28.2.2 changed DOCKER-USER behavior

In Docker 28.2.2 (May 2025), Docker stopped adding an explicit RETURN rule to DOCKER-USER. This means you can now both append (-A) and insert (-I) rules. If you previously relied on the implicit RETURN, verify your rule order still works after upgrading.

Here’s the standard pattern:

# Allow established connections (don't break existing traffic)
sudo iptables -A DOCKER-USER -i eth0 -j ACCEPT -m conntrack --ctstate ESTABLISHED,RELATED

# Allow traffic from a specific IP
sudo iptables -A DOCKER-USER -i eth0 -s 203.0.113.1 -j ACCEPT

# Allow traffic from a trusted subnet
sudo iptables -A DOCKER-USER -i eth0 -s 10.0.0.0/8 -j ACCEPT

# Drop everything else coming from eth0 to containers
sudo iptables -A DOCKER-USER -i eth0 -j DROP

Replace eth0 with your actual public interface (check with ip a).

Make rules persistent across reboots:

sudo apt install iptables-persistent
sudo netfilter-persistent save
sudo netfilter-persistent reload

Without iptables-persistent, your rules vanish on reboot.

Verify your rules:

sudo iptables -L DOCKER-USER -n -v --line-numbers

You should see your rules listed with packet counters. If counters stay at zero, the rules aren’t matching. Check the interface name.

Working with firewalld

firewalld uses zones to represent different network environments. You can create a dedicated zone for Docker interfaces:

1. Create a zone for Docker:

sudo firewall-cmd --permanent --new-zone=docker

2. Bind Docker interfaces to the zone:

# Default docker0 bridge
sudo firewall-cmd --permanent --zone=docker --add-interface=docker0

# User-defined networks (replace br-xxx with actual interface)
# Find the interface name:
docker network ls
docker network inspect <network_name> | grep "Interface"

sudo firewall-cmd --permanent --zone=docker --add-interface=br-xxxxx

3. Define rules in the Docker zone:

sudo firewall-cmd --permanent --zone=docker --add-port=8080/tcp
sudo firewall-cmd --permanent --zone=docker --add-service=http

4. Apply and verify:

sudo firewall-cmd --reload
sudo firewall-cmd --zone=docker --list-all

Using UFW (Uncomplicated Firewall): manual approach

If you prefer manual control over ufw-docker, here’s the traditional approach.

1. Set the forward policy in /etc/default/ufw:

DEFAULT_FORWARD_POLICY="ACCEPT"

This is a broad setting

Setting DEFAULT_FORWARD_POLICY to ACCEPT opens all forwarded traffic. The ufw-docker tool (covered above) keeps this at DROP and only opens what you explicitly allow. Use ufw-docker unless you have a specific reason to manage the rules manually.

2. Add Docker-specific rules to /etc/ufw/after.rules (append before the final COMMIT):

# NAT table rules
*nat
:POSTROUTING ACCEPT [0:0]

# Forward traffic through eth0
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE

COMMIT

# Don't delete these required lines
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]

# Allow Docker internal traffic
-A DOCKER-USER -j RETURN -s 10.0.0.0/8
-A DOCKER-USER -j RETURN -s 172.16.0.0/12
-A DOCKER-USER -j RETURN -s 192.168.0.0/16

-A DOCKER-USER -j ufw-user-forward

-A DOCKER-USER -j DROP

COMMIT

3. Apply:

sudo ufw reload

Rootless Docker: the aggressive option

Rootless Docker runs the daemon as a non-root user. Because it can’t manipulate iptables directly, published ports are forwarded via userland networking (slirp4netns or gvisor-tap-vsock), and host firewall rules are respected.

This effectively eliminates the bypass problem. Trade-offs:

  • No --net=host mode
  • Some features unavailable (AppArmor, certain storage drivers)
  • Slightly different networking behavior
  • Requires per-user setup with dockerd-rootless-setuptool.sh

Rootless Docker trade-offs

Rootless Docker solves the firewall bypass but introduces operational complexity. Not all images work cleanly under rootless mode, and debugging networking issues is harder. Consider this for single-user VPS setups, not shared servers.

If you’re evaluating container runtimes, Podman handles firewall rules differently and may be worth considering as an alternative.

Docker network isolation and best practices

Firewall rules are one layer. Network isolation is another. Use both.

Using Docker Compose networks

Custom networks let you control which containers can talk to each other:

  • Internal networks (internal: true): completely isolated. Containers can’t reach the internet or external services. Perfect for databases.
  • External networks (internal: false): containers can reach the internet and be reached from outside. Needs firewall rules.

Here’s how I organize containers with network isolation:

networks:
  frontend:
    internal: false  # Allows external access
  backend:
    internal: true   # Completely isolated from external access

services:
  web:
    image: nginx
    networks:
      - frontend
    ports:
      - "127.0.0.1:8080:80"
    security_opt:
      - no-new-privileges:true

  api:
    image: node
    networks:
      - frontend
      - backend
    depends_on:
      - database

  database:
    image: mysql
    networks:
      - backend    # Only connected to internal network
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
    secrets:
      - db_root_password
    security_opt:
      - no-new-privileges:true

secrets:
  db_root_password:
    file: ./secrets/db_password.txt

This ensures the database is completely isolated from external access, the API bridges both networks, and all external entry goes through localhost. For more on Docker Compose secrets and what actually works for secret management, see that dedicated guide.

You can also define explicit subnets for tighter control:

networks:
  frontend:
    internal: false
    ipam:
      config:
        - subnet: 172.20.0.0/24
  backend:
    internal: true
    ipam:
      config:
        - subnet: 172.20.1.0/24

Audit your networks regularly:

docker network ls
docker network inspect frontend
docker stats --format "table {{.Name}}\t{{.NetIO}}"
docker network prune   # Remove unused networks

Container resource limits

deploy.resources is Swarm-only

The deploy.resources.limits syntax in docker-compose.yml is only honored by Docker Swarm (docker stack deploy). For standalone docker compose, use the top-level service keys shown below.

For standalone docker compose:

services:
  web:
    image: nginx
    cpu_count: 1
    cpu_percent: 50
    mem_limit: 512m
    memswap_limit: 512m  # Prevent swap abuse

For Docker Swarm (docker stack deploy):

services:
  web:
    image: nginx
    deploy:
      resources:
        limits:
          cpus: '0.50'
          memory: 512M
        reservations:
          cpus: '0.25'
          memory: 256M

Docker 28+ gateway modes

Docker 28.0.0 introduced bridge network gateway modes for finer-grained control over how container ports are exposed on the host:

  • nat-unprotected: NAT is applied but no per-port iptables rules are created. Useful for when you want NAT but manage port access through DOCKER-USER.
  • isolated: No bridge IP on the host. The host can’t reach containers directly.
  • routed: Containers are accessible from other bridge networks via routing.

Create a network with a specific mode:

docker network create --opt com.docker.network.bridge.gateway_mode=nat-unprotected mynet

This is for advanced operators. If the default behavior plus a reverse proxy solves your problem, you don’t need to touch gateway modes.

nftables backend: the future (Docker 29+)

Experimental in Docker 29+

nftables support is experimental. Use for testing and evaluation. The iptables backend remains the default and is fully supported. There is no DOCKER-USER chain equivalent with nftables yet. Filtering is done via nftables sets and rules.

Docker 29.0.0 (Nov 2025) introduced experimental nftables support. Instead of iptables, Docker manages firewall rules through nftables:

// /etc/docker/daemon.json
{
  "firewall-backend": "nftables"
}

With the nftables backend, you must enable IP forwarding manually (Docker doesn’t do it automatically as with iptables):

echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-docker.conf
sudo sysctl --system
sudo systemctl restart docker

Key differences from the iptables backend:

  • No DOCKER-USER chain. Use nftables rules directly.
  • Not yet supported in Swarm mode.
  • Requires kernel with nftables support (all modern distros have this).

This will eventually become the default. For now, stick with iptables unless you’re testing.

Verifying your firewall configuration

After applying any of the fixes above, verify that it actually works. Don’t assume.

  • Check what’s actually exposed: docker ps –format “table {{ .Names }}\t{{ .Ports }}”, verify no unexpected 0.0.0.0 bindings
  • Test from outside: nc -zv <public-ip> <port> from a different host. Confirm a port is blocked when it should be
  • Inspect iptables rules: iptables -L DOCKER-USER -n -v –line-numbers. Verify your rules are in the right order
  • Trace NAT rules: iptables -t nat -L -n -v. See Docker’s DNAT rules and where they sit relative to your firewall
  • Check UFW status: ufw status verbose. Confirm rules are active and correct
  • Audit all networks: docker network ls and docker network inspect <name>

Here are the concrete commands:

# 1. See exactly what ports are exposed and where
docker ps --format "table {{.Names}}\t{{.Ports}}"

# 2. Test a port from an external host (run on a different machine)
nc -zv YOUR_PUBLIC_IP 8080

# 3. Check DOCKER-USER chain rules and packet counts
sudo iptables -L DOCKER-USER -n -v --line-numbers

# 4. Inspect all NAT rules (look for DNAT entries from Docker)
sudo iptables -t nat -L -n -v

# 5. UFW status
sudo ufw status verbose

# 6. List and inspect networks
docker network ls
docker network inspect bridge

If nc succeeds on a port you expected to be blocked, your fix didn’t apply. Recheck the interface name in your iptables rules (eth0 vs ens3 vs enp0s3, it varies by distro and cloud provider).

Conclusion: keeping your Docker containers secure

Docker bypasses host firewalls by default. That’s the reality. But it’s manageable with a layered approach:

  1. Localhost binding + reverse proxy as the foundation. Bind containers to 127.0.0.1, expose only Traefik or Caddy to the public. This single pattern eliminates most of the risk.
  2. Host firewall with ufw-docker (for UFW users) or DOCKER-USER iptables rules (for iptables users). Control exactly which container ports are reachable and from where.
  3. Cloud firewall as the outer layer. Network-level filtering that Docker can’t touch.
  4. Docker network isolation for defense in depth. Internal networks for databases and backend services.

Keep Docker updated. CVE-2025-54388 showed that even loopback-bound ports could be exposed after a firewalld reload on affected versions. If you’re running security-critical containers, staying current isn’t optional.

For layering security beyond Docker itself, consider CrowdSec for automated intrusion detection and response.

  • Bind all containers to 127.0.0.1 unless they must be publicly accessible
  • Deploy a reverse proxy (Traefik or Caddy) for all public services
  • Install ufw-docker or configure DOCKER-USER iptables rules
  • Set up a cloud firewall allowing only ports 22, 80, 443
  • Use internal networks for databases and backend services
  • Verify with nc -zv from an external host after every change
  • Keep Docker updated. Check release notes before upgrading
  • Regularly clean up unused Docker resources to reduce attack surface

Security isn’t about implementing every possible measure. It’s about the right combination for your situation. For most solo operators running Docker on a VPS: localhost binding + reverse proxy + ufw-docker + cloud firewall covers the vast majority of exposure. The rest is network isolation and keeping things updated.

If you’re getting started with Docker, check out the essential Docker commands and how to copy multiple files efficiently in Dockerfiles.