Docker and UFW: The Silent Security Hole and How to Fix It

If you are running a Linux server, you probably rely on UFW (Uncomplicated Firewall) to secure it. You run ufw default deny incoming, open SSH, and feel safe.

Then, you install Docker.

You spin up a container with -p 8080:80 and suddenly, port 8080 is open to the entire world even if UFW says it should be blocked.

This isn’t a bug; it’s a feature. Docker interacts directly with iptables to manage Network Address Translation (NAT). To make port forwarding work, Docker inserts its rules before your standard UFW rules. In effect, Docker traffic bypasses your firewall entirely.

Here is how to fix it, how to shift your mental model, and why the “System-Level Gateway” pattern is the ultimate secure setup.

1. The DOCKER UFW Patch: Reclaiming Control

To fix this, we don’t need to disable Docker’s network management (which breaks things). Instead, we need to tell Docker: “Before you forward traffic to a container, ask UFW for permission.”

We do this by injecting rules into the DOCKER-USER chain, a specific hook provided by Docker for administrators.

The Fix

Add the following block to the end of /etc/ufw/after.rules. This configuration forces Docker traffic to pass through UFW’s filters.

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

# 1. The Hook: Send all Docker traffic to UFW's user chain
-A DOCKER-USER -j ufw-user-forward

# 2. Connection Tracking: Allow return traffic for established connections
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN

# 3. Internal Traffic: Allow containers to talk to each other
-A DOCKER-USER -i docker0 -o docker0 -j ACCEPT
# (Optional) Allow Docker Compose networks (adjust subnet as needed)
-A DOCKER-USER -s 172.16.0.0/12 -j RETURN

# 4. Default Deny: Block new external connections by default
-A DOCKER-USER -m conntrack --ctstate NEW -j ufw-docker-logging-deny

# 5. Logging: Log blocked packets so you can see them in dmesg
-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

After adding this, run sudo ufw reload. Now, all published Docker ports are blocked by default.

2. The New Mental Model: INPUT vs. FORWARD

Once you apply this patch, you must change how you think about firewall rules. UFW manages two distinct “directions” for traffic.

The “House” vs. The “Tunnel”

  1. Traffic for the Host (INPUT)
    • Scenario: You want to access SSH, or a system-level Nginx/Caddy installed directly on the OS.
    • The Path: Traffic hits the server’s network card and stays there.
    • The Command: ufw allow <port>
    • Example: ufw allow 22 opens SSH.
  2. Traffic for Docker (FORWARD)
    • Scenario: You want to access a container running a web app.
    • The Path: Traffic hits the server, gets grabbed by Docker, and is forwarded through a virtual tunnel (NAT) to the container’s internal IP (e.g., 172.17.0.2).
    • The Command: ufw route allow <port>
    • Example: ufw route allow proto tcp from any to 172.17.0.2 port 80

Key Takeaway: If the app is in a container, ufw allow does nothing. You must use ufw route allow.

3. The “Gateway Pattern”: System-Level Caddy

While the ufw route method works, managing firewall rules for ephemeral container IPs is tedious. A more robust architecture is the Gateway Pattern.

The Setup

  1. Install Caddy (or Nginx) on the Host OS: This acts as your single entry point (Reverse Proxy).
  2. Run Containers on Localhost: Bind your Docker ports strictly to 127.0.0.1.

Why its Superior

A. Zero Firewall Complexity for Apps

Because Caddy runs on the host, you only need standard UFW rules:

sudo ufw allow 80
sudo ufw allow 443

You mostly would never have to touch ufw route or worry about container IPs changing.

B. Physical Isolation

When you run a container like this:

docker run -p 127.0.0.1:8080:80 my-app

The application is listening only on the loopback interface. It is physically impossible for an external packet to reach port 8080, regardless of your firewall state. The connection must come from the host (i.e., from Caddy).

C. The “Safety Net”

So why did we bother patching UFW in step 1? Defense in Depth.

If you (or a junior dev) accidentally type -p 8080:80 and forget the 127.0.0.1 binding, a standard Docker setup would instantly expose that port to the internet.

With the UFW Patch installed, that accidental port exposure is blocked by default. The firewall acts as a safety net, catching human error before it becomes a security incident.

Summary Architecture
ComponentInstallationNetwork BindingFirewall Rule
CaddySystem (apt/rpm)Public IP (0.0.0.0)ufw allow 80,443
App ADocker127.0.0.1:3000None needed (Internal)
App BDocker127.0.0.1:4000None needed (Internal)
SafetyUFW PatchN/ABlocks accidental -p 80:80
This gives you the convenience of Docker, the simplicity of UFW, and the security of a hardened server.

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.