Fix Docker Bypassing UFW: 4 Tested Solutions for Your VPS

11 min read·Matthieu·DockerUFWiptablesFirewallSecurityUbuntuDebian|

Docker manipulates iptables directly and ignores UFW rules. Your container ports are exposed to the internet even with ufw deny active. Here are four solutions with tradeoffs, each verified by scanning from an external host.

Your UFW firewall is lying to you. If you run Docker on a VPS with UFW enabled, every published container port is wide open to the internet. Running ufw deny 8080 does nothing. Docker bypasses UFW completely.

You will see the problem in action, then walk through four fixes with different tradeoffs. Each solution includes a verification step: scanning the port from an external host to confirm it is actually blocked. Not just checking ufw status.

Tested on Ubuntu 24.04 and Debian 12. Both use the iptables compatibility layer by default, so the same fixes apply. If you run Debian 12 with nftables, see the compatibility note in the troubleshooting section.

Prerequisites: A VPS with Docker installed, UFW enabled, and SSH access. A second machine (your laptop or another server) for external port scanning. Docker in Production on a VPS: What Breaks and How to Fix It

Why does Docker bypass UFW firewall rules?

Docker writes iptables rules in the nat and filter tables to route traffic to containers. UFW manages only the INPUT chain. When a packet arrives for a published container port, Docker's NAT rules redirect it through the FORWARD chain before UFW's INPUT rules ever see it. The packet never reaches UFW. This means ufw deny has zero effect on Docker-published ports.

Here is the packet flow for a request hitting port 8080 on your VPS, where a container is published with -p 8080:80:

  1. Packet arrives at the network interface
  2. It enters the nat table's PREROUTING chain
  3. Docker's DNAT rule rewrites the destination to the container IP (e.g., 172.17.0.2:80)
  4. The packet moves to the filter table's FORWARD chain (not INPUT)
  5. Docker's DOCKER chain accepts the forwarded packet
  6. The packet reaches the container

UFW never sees it because UFW only watches the INPUT chain. The packet takes the FORWARD path instead.

This is not a bug. Docker needs iptables control to make container networking work. But it creates a serious security gap if you assumed UFW was protecting your server. How to Set Up a Linux VPS Firewall with UFW and nftables

How do I verify that Docker is bypassing my UFW firewall?

Before applying any fix, see the problem yourself. Start a test container that serves HTTP on port 8080:

docker run -d --name ufw-test -p 8080:80 nginx:alpine

Check that UFW is active and denies incoming traffic by default:

sudo ufw status verbose

You should see Default: deny (incoming) in the output. Now explicitly deny port 8080:

sudo ufw deny 8080

Check UFW says the port is blocked:

sudo ufw status | grep 8080
8080                       DENY        Anywhere
8080 (v6)                  DENY        Anywhere (v6)

UFW reports the port as denied. Now test from your local machine (not the server):

curl -s -o /dev/null -w "%{http_code}" http://YOUR_SERVER_IP:8080
200

The response is 200. The container is fully accessible from the internet despite UFW denying the port. If you have nmap installed on your local machine:

nmap -p 8080 YOUR_SERVER_IP
PORT     STATE SERVICE
8080/tcp open  http-proxy

The port is open. UFW is not protecting it.

Clean up the deny rule (you will apply a real fix next):

sudo ufw delete deny 8080

Keep the test container running. You will use it to verify each solution.

Which Docker UFW fix should I use?

Four solutions exist. Each has different tradeoffs. Pick the one that matches your setup.

Solution Networking impact Survives reboot Compose compatible Complexity Best for
DOCKER-USER chain None Yes Yes Medium Full control, multiple public ports
ufw-docker tool None Yes Yes Low Automated management, dynamic containers
iptables=false Breaks inter-container networking, no internet from containers Yes Yes Low Isolated single containers (rare)
Bind to 127.0.0.1 None Yes Yes Low Services behind a reverse proxy

Quick decision:

  1. Are all your containers behind a reverse proxy (Nginx, Traefik, Caddy)? Use 127.0.0.1 binding. It is the simplest.
  2. Do you need some containers publicly accessible on specific ports? Use the DOCKER-USER chain for manual control or ufw-docker for automated management.
  3. Do you run containers that must never access the network? Use iptables=false, but understand the tradeoffs.

How do I fix Docker bypassing UFW with the DOCKER-USER chain?

The DOCKER-USER chain is a placeholder that Docker leaves empty for administrators. Rules you add here are evaluated before Docker's own forwarding rules. By inserting UFW integration rules into this chain via /etc/ufw/after.rules, you make Docker traffic pass through UFW.

Open /etc/ufw/after.rules:

sudo cp /etc/ufw/after.rules /etc/ufw/after.rules.bak
sudo nano /etc/ufw/after.rules

Add the following block at the end of the file, after the existing COMMIT line:

# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward

-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP
-A DOCKER-USER -i docker0 -o docker0 -j ACCEPT

-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-docker-logging-deny -m conntrack --ctstate NEW -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 172.16.0.0/12
-A DOCKER-USER -j ufw-docker-logging-deny -m conntrack --ctstate NEW -d 192.168.0.0/16

-A DOCKER-USER -j RETURN

-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP

COMMIT
# END UFW AND DOCKER

What this does:

  • -A DOCKER-USER -j ufw-user-forward: sends Docker traffic through UFW's forwarding rules first
  • conntrack --ctstate RELATED,ESTABLISHED: allows return traffic for established connections (keeps existing connections working after rule changes)
  • conntrack --ctstate INVALID: drops malformed packets
  • -i docker0 -o docker0: allows container-to-container communication on the same bridge network
  • The -j RETURN -s lines allow traffic originating from private subnets
  • New connections (ctstate NEW) to Docker subnets from outside are logged and dropped
  • The logging rule rate-limits to 3 messages per minute to avoid flooding your logs

Restart UFW to load the new rules:

sudo ufw reload

List the active rules:

sudo iptables -L DOCKER-USER -n -v

You should see your new rules in the chain output. If the chain shows only a default RETURN rule, the after.rules block was not loaded correctly. Check the file syntax.

Allow a specific container port through UFW

With the DOCKER-USER chain active, all container ports are blocked by default from external access. To allow a specific port, you need to reference the container IP and container port, not the host port. This is because Docker's DNAT rule in the PREROUTING chain rewrites the destination before the packet reaches the FORWARD chain. By the time your rule is evaluated, the destination is the container's internal address.

First, find the container IP:

docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ufw-test
172.17.0.2

Then allow traffic to that container:

sudo ufw route allow proto tcp from any to 172.17.0.2 port 80

Verify UFW shows the route rule:

sudo ufw status | grep "ALLOW FWD"
172.17.0.2 80/tcp          ALLOW FWD   Anywhere

Note: since the rule targets a container IP that may change on restart, use the ufw-docker tool (next section) for automatic IP tracking. The manual DOCKER-USER approach is best when you manage the container IPs yourself (static IPs in Docker Compose or when you script rule updates).

Check from an external host

From your local machine, test the allowed port:

curl -s -o /dev/null -w "%{http_code}" http://YOUR_SERVER_IP:8080
200

The port is accessible because you explicitly allowed forwarding to the container. Now test a port you did not allow (for example, if you had a container on port 9090):

nmap -p 9090 YOUR_SERVER_IP
PORT     STATE    SERVICE
9090/tcp filtered unknown

filtered means the firewall is silently dropping packets. The DOCKER-USER chain is working.

How do I use the ufw-docker tool to manage container firewall rules?

The chaifeng/ufw-docker tool automates the DOCKER-USER chain setup and provides commands to allow or deny container ports. It modifies /etc/ufw/after.rules for you and adds a CLI for managing per-container rules.

Install it:

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

Review the script before running it. It is a shell script you can read with cat /usr/local/bin/ufw-docker.

Run the installer to patch /etc/ufw/after.rules:

sudo ufw-docker install

This appends DOCKER-USER chain rules similar to the previous section, including conntrack-based filtering and logging. It also patches after6.rules for IPv6. Restart UFW:

sudo ufw reload

Check the installed version:

sudo ufw-docker check

Managing container ports

Allow external access to port 80 of a container named web:

sudo ufw-docker allow web 80/tcp

List rules for a container:

sudo ufw-docker list web

Remove a rule:

sudo ufw-docker delete allow web 80/tcp

Show all Docker firewall rules:

sudo ufw-docker status

Docker Compose example with ufw-docker

services:
  web:
    image: nginx:alpine
    container_name: web
    ports:
      - "8080:80"
    restart: unless-stopped

Start the stack, then allow the port:

docker compose up -d
sudo ufw-docker allow web 80/tcp

Note: the ufw-docker command references the container port (80), not the host port (8080). The tool resolves the mapping automatically.

Alternative: ufw-docker-automated

The shinebayar-g/ufw-docker-automated project takes a different approach. It runs as a Docker container itself, listens to Docker API events, and automatically creates UFW rules when containers start or stop. This solves a limitation of the ufw-docker tool: when a container restarts and gets a new IP, the old rule becomes invalid.

With ufw-docker-automated, you add labels to your containers:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    labels:
      - "UFW_MANAGED=TRUE"
    restart: unless-stopped

The tool watches for containers with the UFW_MANAGED=TRUE label and creates matching UFW rules automatically. It also supports UFW_ALLOW_FROM to restrict access to specific IPs or CIDR ranges.

Check from an external host

After allowing a port with ufw-docker, confirm from your local machine:

curl -s -o /dev/null -w "%{http_code}" http://YOUR_SERVER_IP:8080
200

Test an unlisted port to confirm it is blocked:

nmap -p 9090 YOUR_SERVER_IP
PORT     STATE    SERVICE
9090/tcp filtered unknown

What happens if I disable Docker iptables management?

Setting "iptables": false in Docker's daemon configuration stops Docker from creating any iptables rules. This is the simplest fix but has the most severe side effects.

What breaks:

  • Containers cannot access the internet (no masquerading rules)
  • Container-to-container communication across different networks stops working
  • Port publishing (-p) stops working entirely. You must manage all forwarding rules yourself.

This approach is only appropriate if you run isolated containers that do not need internet access and you handle all networking manually.

Edit or create the Docker daemon config:

sudo nano /etc/docker/daemon.json
{
  "iptables": false
}

If the file already has content, add the "iptables": false key to the existing JSON object. Do not create a second JSON object.

Restart Docker:

sudo systemctl restart docker

Confirm Docker is not creating iptables rules:

sudo iptables -L DOCKER -n 2>/dev/null

With Docker 28.x, the DOCKER chain may still exist but should contain only a DROP rule instead of the usual forwarding rules. No ACCEPT rules means Docker is not routing traffic to containers.

Check from an external host

Start the test container again (it was stopped when Docker restarted):

docker start ufw-test

From your local machine:

nmap -p 8080 YOUR_SERVER_IP
PORT     STATE  SERVICE
8080/tcp closed http-proxy

closed (not filtered) because Docker is no longer creating the NAT rules to forward traffic. The port is not listening from the external perspective.

To revert, remove "iptables": false from daemon.json and restart Docker:

sudo systemctl restart docker

How do I bind Docker Compose ports to localhost only?

For containers behind a reverse proxy (Nginx, Traefik, Caddy), the simplest fix is to never publish ports to 0.0.0.0 in the first place. Bind them to 127.0.0.1 instead. The reverse proxy connects to the container over localhost. External traffic hits the reverse proxy on ports 80/443, which UFW controls normally.

No iptables changes, no extra tools. Works on every OS.

In your docker-compose.yml, change the port binding:

services:
  app:
    image: your-app:latest
    ports:
      - "127.0.0.1:8080:80"
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
    restart: unless-stopped

The app service binds to 127.0.0.1:8080 only. It is not accessible from outside the server. The nginx service binds to 0.0.0.0:80 and 0.0.0.0:443 (the default when no IP is specified), which you control with UFW.

For docker run, the equivalent syntax:

docker run -d --name app -p 127.0.0.1:8080:80 your-app:latest

Check the binding

On the server, confirm the port is bound to localhost only:

ss -tlnp | grep 8080
LISTEN 0      4096      127.0.0.1:8080      0.0.0.0:*    users:(("docker-proxy",pid=12345,fd=4))

The listen address is 127.0.0.1:8080, not 0.0.0.0:8080. Only local connections are accepted.

Check from an external host

From your local machine:

nmap -p 8080 YOUR_SERVER_IP
PORT     STATE  SERVICE
8080/tcp closed http-proxy

The port is closed to external traffic. Your reverse proxy handles public access on ports 80 and 443, which UFW protects normally.

This is the recommended approach for most VPS setups where you run web applications behind a reverse proxy. Docker in Production on a VPS: What Breaks and How to Fix It

Troubleshooting

Rules disappear after Docker restart

If you used the DOCKER-USER chain approach and rules vanish when Docker restarts, check that your rules are in /etc/ufw/after.rules and not added manually with iptables commands. Manual iptables rules do not survive service restarts. The after.rules file is loaded every time UFW starts.

sudo ufw reload
sudo iptables -L DOCKER-USER -n -v

Container cannot resolve DNS after applying DOCKER-USER rules

The after.rules block includes a conntrack --ctstate RELATED,ESTABLISHED rule that allows DNS response traffic back to containers. If DNS resolution fails inside containers, verify the conntrack rule is loaded:

sudo iptables -L DOCKER-USER -n -v | grep "RELATED,ESTABLISHED"

If missing, check the syntax of your after.rules block and reload UFW.

ufw-docker commands fail with "container not found"

The ufw-docker allow command requires the container to be running. It resolves the container name to an IP address. If the container is stopped, the command fails. Start the container first, then add the rule.

Debian 12 nftables compatibility

Debian 12 uses nftables as its firewall backend, but UFW and Docker both use the iptables compatibility layer (iptables-nft). The solutions in this guide work identically on Debian 12 and Ubuntu 24.04. Verify you are using the nft backend:

sudo iptables --version
iptables v1.8.10 (nf_tables)

The (nf_tables) suffix confirms the iptables-nft compatibility layer is active. All DOCKER-USER chain rules work through this layer.

Check logs for blocked traffic

If a connection is unexpectedly blocked after applying the DOCKER-USER chain fix, check the UFW Docker logs:

sudo journalctl -k | grep "UFW DOCKER BLOCK"

The log entries show the source IP and destination port of blocked packets.

Clean up test resources

Remove the test container when you are done:

docker rm -f ufw-test

If you added a UFW deny rule during testing, remove it:

sudo ufw delete deny 8080