Linux & OS Advanced

Troubleshooting: Linux UFW Firewall Blocking Docker Container Port Exposures on Ubuntu 20.04 LTS

Resolve UFW blocking Docker container port access on Ubuntu 20.04 LTS. Understand iptables conflicts and restore container connectivity.

👨‍💻
Senior Systems Architect • Verified in Staging Labs

Resolve UFW blocking Docker container port access on Ubuntu 20.04 LTS. Understand iptables conflicts and restore container connectivity.

When deploying Docker containers on an Ubuntu 20.04 LTS server, you might encounter a frustrating scenario: despite correctly exposing container ports using docker run -p or docker-compose's ports directive, and even seeing the container running and listening internally, external access to these services fails. This typically manifests as connection timeouts or refused connections from clients trying to reach your containerized application. The culprit, more often than not, is an intricate interplay between UFW (Uncomplicated Firewall) and Docker's own iptables management. This guide will walk you through the technical details and provide robust solutions.

Symptom & Error Signature

The primary symptom is the inability to access services running inside a Docker container from an external network or even from the host machine's public IP address, while local access (e.g., curl localhost:PORT or curl 172.17.0.x:PORT from within the host) might work perfectly.

Typical Observations:

  • From an external client:

    $ curl http://YOUR_SERVER_IP:8080
    curl: (7) Failed to connect to YOUR_SERVER_IP port 8080: Connection refused
    

    or

    $ telnet YOUR_SERVER_IP 8080
    Trying YOUR_SERVER_IP...
    telnet: connect to address YOUR_SERVER_IP: Connection timed out
    
  • On the Docker host:

    • docker ps confirms the container is running and ports are mapped:
      $ sudo docker ps
      CONTAINER ID   IMAGE     COMMAND                  CREATED        STATUS        PORTS                                       NAMES
      abcdef123456   nginx     "/docker-entrypoint.…"   2 minutes ago   Up 2 minutes   0.0.0.0:8080->80/tcp, :::8080->80/tcp   webserver
      
    • Local access works:
      $ curl http://localhost:8080
      <!DOCTYPE html>
      <html>
      <head>
      <title>Welcome to nginx!</title>
      ...
      
    • UFW status might even show the port allowed (which is misleading for Docker's forwarding):
      $ sudo ufw status verbose
      Status: active
      Logging: on (low)
      Default: deny (incoming), allow (outgoing), deny (routed)
      New profiles: skip
      
      To                         Action      From
      --                         ------      ----
      8080/tcp                   ALLOW IN    Anywhere
      
  • iptables output might not immediately reveal the issue to the untrained eye, but the order of rules is critical.

Root Cause Analysis

The core of the problem lies in the interaction and rule order precedence between UFW and Docker's iptables management.

  1. Docker's iptables Management: When Docker exposes a port (e.g., -p 8080:80), it directly manipulates the iptables rules. It adds rules primarily to the NAT table (for port forwarding) and the FILTER table (specifically in the FORWARD chain and its custom DOCKER and DOCKER-USER chains) to allow traffic to and from the container. These rules are designed to punch holes in the firewall for container traffic.

  2. UFW's iptables Management: UFW, as a user-friendly frontend for iptables, also manages rules in the FILTER table. Crucially, UFW by default sets the DEFAULT_FORWARD_POLICY="DROP" in /etc/default/ufw. This means any traffic that is forwarded (i.e., traffic that isn't destined for the host itself but is passing through the host to another network, like a Docker container) will be dropped unless explicitly allowed by an earlier rule.

  3. The Conflict (Rule Order): When UFW is enabled or reloaded, it inserts its rules relatively early in the iptables chains, specifically within the filter table. Docker's rules, while present, often appear after UFW's default DROP rules for the FORWARD chain. This creates a "race condition" or, more accurately, a precedence issue:

    • An incoming connection for YOUR_SERVER_IP:8080 hits the PREROUTING chain in the NAT table, where Docker's DOCKER chain performs the destination NAT (DNAT) to 172.17.0.x:80.
    • The packet then moves to the FORWARD chain in the FILTER table.
    • Because UFW's rules are typically positioned earlier and often include a DEFAULT_FORWARD_POLICY="DROP", the packet gets dropped by UFW's rules before it reaches Docker's rules that would explicitly ACCEPT this forwarded traffic to the container.
    • Even if you use sudo ufw allow 8080/tcp, this rule typically applies to the INPUT chain (for traffic to the host itself), not the FORWARD chain (for traffic through the host to a container).

In essence, UFW's default behavior effectively nullifies Docker's iptables forwarding rules by dropping the forwarded packets first.

Step-by-Step Resolution

The most robust and recommended solution involves modifying UFW's before.rules file to explicitly allow forwarded traffic that Docker manages, before UFW's default DROP rules take effect.

1. Backup UFW Configuration Files

Always back up critical configuration files before making changes. This allows for easy rollback if anything goes wrong.

sudo cp /etc/ufw/before.rules /etc/ufw/before.rules.bak
sudo cp /etc/ufw/before6.rules /etc/ufw/before6.rules.bak # For IPv6 if in use

2. Identify Docker Bridge Network

You need to know the IP range of your Docker bridge network (usually docker0). This is crucial for crafting specific iptables rules.

ip a show docker0

Example Output:

3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:0a:92:49:15 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0
       valid_lft forever preferred_lft forever
    inet6 fe80::42:aff:fe92:4915/64 scope link
       valid_lft forever preferred_lft forever

From this output, we see the Docker network is 172.17.0.0/16. You will use 172.17.0.0/16 for IPv4 rules.

3. Modify UFW's before.rules for Docker Forwarding

Edit /etc/ufw/before.rules using your preferred text editor (e.g., nano or vim).

sudo nano /etc/ufw/before.rules

Scroll down to the section that defines rules for the FORWARD chain. Look for comments like # Don't delete these required lines, otherwise there will be no TCP/IP forwarding or similar.

The exact placement within before.rules is important. You want these rules to be evaluated before any DROP rules that might block Docker's forwarded traffic. A good place is often after the *filter table declaration and before the standard UFW rules begin.

Add the following rules. Replace 172.17.0.0/16 with your Docker bridge network range if it's different.

# START DOCKER CUSTOM RULES
# Allow all traffic from Docker containers to the outside world
-A ufw-before-forward -i docker0 -j ACCEPT
# Allow all traffic from the outside world to Docker containers through the Docker bridge (if forwarded by Docker's NAT)
-A ufw-before-forward -o docker0 -j ACCEPT
# Allow traffic from specific external interface to Docker bridge (e.g., eth0 to docker0)
# This rule is crucial for incoming connections
-A ufw-before-forward -i <YOUR_MAIN_NETWORK_INTERFACE> -o docker0 -j ACCEPT

# ACCEPT all connections coming from the Docker network and going out
# This ensures containers can talk to the internet
-A FORWARD -s 172.17.0.0/16 -j ACCEPT

# ACCEPT all connections going into the Docker network from the outside
# This ensures external access to exposed container ports
-A FORWARD -d 172.17.0.0/16 -j ACCEPT

# If you specifically want to control the DOCKER-USER chain, you can ensure it's jumped to early:
# -A FORWARD -j DOCKER-USER
# END DOCKER CUSTOM RULES

Replace <YOUR_MAIN_NETWORK_INTERFACE> with your actual main network interface (e.g., eth0, ens3, enp0s3). You can find this using ip a. Example: eth0.

The rules -A FORWARD -s 172.17.0.0/16 -j ACCEPT and -A FORWARD -d 172.17.0.0/16 -j ACCEPT are broad. They allow all forwarded traffic to/from your Docker network. While generally necessary for Docker to function correctly with UFW, ensure your Docker containers are secure. Docker's internal DOCKER chain will still apply specific port filtering.

Revised and more targeted approach within before.rules (recommended):

Find the section that starts with *filter and contains rules for the FORWARD chain. Add these rules above the line -A ufw-before-forward -j ufw-user-forward and also above any general DROP rules for the FORWARD chain.

#
# rules.before
#
# Rules that should be run before the ufw command line added rules.
#

*filter
:ufw-before-input - [0:0]
:ufw-before-output - [0:0]
:ufw-before-forward - [0:0]
:ufw-not-local - [0:0]
# Don't delete these required lines, otherwise there will be no TCP/IP forwarding
-A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -i docker0 -j ACCEPT # Docker to outside
-A FORWARD -o docker0 -j ACCEPT # Outside to Docker

# Insert these BEFORE the "default deny" rules for the FORWARD chain
# See https://docs.docker.com/network/iptables/#forwarding-to-containers-from-the-outside-world
# Allowing established and related connections
-A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT

# Explicitly allow traffic from the Docker bridge to anywhere
# This allows containers to reach the internet or other hosts
-A FORWARD -i docker0 -j ACCEPT

# Explicitly allow traffic from anywhere to the Docker bridge
# This allows external access to services exposed by Docker (Docker will handle specific ports via DNAT)
-A FORWARD -o docker0 -j ACCEPT

# Ensure DOCKER-USER chain is traversed for more granular control if you use it
-A FORWARD -j DOCKER-USER

# Continue with UFW's standard rules
# -A ufw-before-forward -j ufw-not-local
...

For IPv6, perform the same modification to /etc/ufw/before6.rules, adapting the IP range (e.g., fe80::/64 or your Docker IPv6 subnet if configured).

sudo nano /etc/ufw/before6.rules

Add similar rules using ip6tables. For example:

# Rules for IPv6
*filter
# ... existing ufw-before-forward rules ...

# DOCKER IPv6 FORWARD RULES
-A FORWARD -i docker0 -j ACCEPT
-A FORWARD -o docker0 -j ACCEPT
-A FORWARD -j DOCKER-USER
# END DOCKER IPv6 FORWARD RULES

# ... rest of the ufw-before-forward rules ...

The exact docker0 IPv6 range depends on Docker's IPv6 network configuration. If you haven't explicitly enabled IPv6 for Docker, you might not need the IPv6 rules here, but it's good practice if you plan to.

4. Configure UFW to Allow the Specific Port (Optional, but Good Practice)

While the before.rules modification handles the forwarding aspect, it's still good practice to explicitly allow the port in UFW for clarity and for any non-Docker services that might use it. This rule applies to the INPUT chain, allowing traffic to the host itself, which then gets forwarded by Docker's NAT.

sudo ufw allow 8080/tcp comment 'Allow Nginx container on port 8080'
# If you need UDP:
# sudo ufw allow 5000/udp comment 'Allow application UDP on port 5000'

5. Reload UFW and Restart Docker

To apply the before.rules changes, you must reload UFW. The most reliable way to do this is to disable and then enable it. It's also a good idea to restart Docker to ensure its iptables rules are re-applied correctly after UFW.

sudo ufw disable
sudo ufw enable
sudo systemctl restart docker

Disabling UFW temporarily exposes your server to the internet without a firewall. Do this only if you understand the risks and for a brief period. If you're on a production server, consider doing this during a maintenance window or ensuring other network-level firewalls are in place.

6. Verification

After performing the steps, verify that your container is now accessible.

  • Check UFW status:

    sudo ufw status verbose
    

    Ensure it's active and your new port rules (if any in step 4) are listed.

  • Check iptables rules (optional, for advanced users):

    sudo iptables -nvL FORWARD
    

    You should see your ufw-before-forward rules (and potentially the DOCKER rules) appearing in the FORWARD chain, with ACCEPT actions for traffic to/from the Docker bridge network. The order is key: your ACCEPT rules should precede any general DROP rules.

  • Test external access:

    curl http://YOUR_SERVER_IP:8080
    

    This should now return the expected output from your containerized application.

7. Alternative 1: Modify UFW's Default Forward Policy (Less Secure)

Another approach, often found online, is to change UFW's default forward policy.

sudo nano /etc/default/ufw

Change DEFAULT_FORWARD_POLICY="DROP" to DEFAULT_FORWARD_POLICY="ACCEPT".

# DEFAULT_FORWARD_POLICY="DROP"
DEFAULT_FORWARD_POLICY="ACCEPT"

Then reload UFW:

sudo ufw disable
sudo ufw enable

This method is generally NOT recommended for production environments. Setting DEFAULT_FORWARD_POLICY="ACCEPT" significantly weakens your firewall's security posture by allowing all forwarded traffic by default, unless explicitly blocked by other rules. This could expose other internal networks or services if your server has multiple interfaces or complex routing. The before.rules modification is more targeted and secure.

8. Alternative 2: Use Docker's DOCKER-USER chain (Advanced, for Fine-Grained Control)

Docker provides a special DOCKER-USER chain for users to add their own rules, which are applied before Docker's automatic rules in the FORWARD chain. This allows you to explicitly permit or deny traffic to/from containers based on source IP, interface, etc., without altering UFW's core configuration files.

Rules added to DOCKER-USER are volatile. They will be reset if iptables is flushed or Docker is restarted without iptables-persistent or similar. To make them persistent, you'll need to save them.

  1. Add iptables rules directly (temporary):

    # Allow traffic from eth0 destined for port 80 (exposed by container) to be forwarded to docker0
    sudo iptables -I DOCKER-USER -i eth0 -o docker0 -p tcp --dport 80 -j ACCEPT
    # Allow traffic from eth0 destined for port 443 (exposed by container) to be forwarded to docker0
    sudo iptables -I DOCKER-USER -i eth0 -o docker0 -p tcp --dport 443 -j ACCEPT
    

    Replace eth0 with your main network interface and 80/443 with your exposed container ports.

  2. Make iptables rules persistent: Install iptables-persistent:

    sudo apt install iptables-persistent
    

    Save your current iptables rules:

    sudo netfilter-persistent save
    

    This will save the rules to /etc/iptables/rules.v4 and /etc/iptables/rules.v6, ensuring they are reloaded on boot.

This approach provides very granular control but requires managing iptables directly and ensuring persistence. It's often used when specific source IPs or interfaces need different access policies to containers. For most standard deployments, modifying before.rules is simpler and equally effective.

👨‍💻

Johnathon Wheeler

Senior Systems Architect & DevOps Engineer • Austin, TX

Connect on LinkedIn

Johnathon has over 16 years of hands-on experience designing, debugging, and scaling Linux web hosting stacks, container clusters, and high-availability database architectures. Every guide on ButItWorkedLocal is independently tested against Debian 12, Ubuntu 24.04/22.04 LTS, Rocky Linux, and Docker environments to guarantee reproducibility in production.

🛡️

Our Production Verification Guarantee

Encountering a bug not covered here or running a non-standard kernel configuration? Our solutions are continually refined against real production incidents. Submit an environment trace for our editorial team to replicate.