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.

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:
- AWS Security Groups: instance-level inbound/outbound rules
- Hetzner Cloud Firewalls: network-level filtering, free, easy to configure
- DigitalOcean Cloud Firewalls: similar to security groups
- Google Cloud Firewall Rules: tag-based network filtering
These can’t be bypassed by Docker’s iptables manipulation. My typical setup:
- Allow ports 80, 443 (web traffic) and 22 (SSH)
- Block all other incoming traffic
- 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=hostmode - 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 unexpected0.0.0.0bindings - 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 lsanddocker 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:
- 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. - 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. - Cloud firewall as the outer layer. Network-level filtering that Docker can’t touch.
- 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.1unless they must be publicly accessible - Deploy a reverse proxy (Traefik or Caddy) for all public services
- Install
ufw-dockeror 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 -zvfrom 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.


