Free From Cloud Logo
$freefrom_cloud

05 March 2026

I Was Wrong About the Firewall

UFW doesn’t protect your Docker containers. Here’s the fix, and the updated cloud-init config.


In the cloud-init post, I gave you a 42-line YAML file that provisions a hardened server. SSH locked down. Fail2ban enabled. UFW configured. Docker installed. Done.

I wrote that config for people like me. People who already know what UFW does, who understand that docker run -p 8080:80 exposes a port, and who would never accidentally publish a database to the internet.

I was wrong. Not about the config being useful. About the assumption that it was safe enough to share without a warning label.

UFW does not protect Docker containers. And if you followed my original cloud-init config, your firewall has a hole in it.

The Problem

Here’s what I told you to do:

runcmd:
- ufw allow OpenSSH
- ufw allow 80
- ufw allow 443
- ufw --force enable

Looks solid, right? SSH, HTTP, HTTPS. Everything else blocked. Run ufw status and it confirms: locked down.

Now do this:

docker run -d -p 80:80 nginx

Check ufw status again. Still says everything is blocked. But go ahead, try connecting to port 80 from another machine.

It works. Nginx is serving pages to the entire internet, and your firewall has no idea.

Why This Happens

UFW and Docker both control iptables, the actual firewall engine in the Linux kernel. But they don’t talk to each other. They don’t coordinate. They don’t even know the other one exists.

Here’s the packet flow when someone connects to a Docker-published port:

Internet
→ iptables NAT (Docker grabs the packet here)
→ FORWARD chain (Docker routes to container)
→ Container receives traffic

And here’s what UFW protects:

Internet
→ INPUT chain (UFW filters here)
→ Host process receives traffic

See the problem? Docker traffic goes through FORWARD. UFW protects INPUT. They never cross paths.

When you run docker run -p 80:80 nginx, Docker adds its own iptables rules that accept the traffic and route it to the container before UFW ever gets a chance to look at it. Your firewall says port 80 is blocked, but that only applies to services running on the host itself. The container lives in a different world.

This is not a bug. Docker does this on purpose. The assumption is: if you published a port, you meant to expose it.

The result? UFW gives you a false sense of security. You check ufw status, see a clean set of rules, and go to sleep thinking you’re protected. Meanwhile, Docker has punched a hole right through your firewall.

Who Gets Burned

If you’re experienced, you probably know this. You bind sensitive stuff to 127.0.0.1, you use Docker networks, you put a reverse proxy in front of everything. You know the limits.

But that’s not who I was writing for. The whole point of the cloud-init post was to make server provisioning accessible. “Copy this config and you’re good.” And for Docker, that’s simply not true.

A beginner following my guide would:

  1. Set up UFW with just allow OpenSSH
  2. Deploy a Docker Compose stack with nginx
  3. Check ufw status and feel safe
  4. Never realize their web server is publicly accessible on port 80

That’s on me. I knew about this gap and didn’t flag it. I assumed the reader would too. When you’re writing something that says “production-ready” on the tin, you don’t get to assume away the hard parts.

The Fix

Docker provides a chain called DOCKER-USER. It’s the one place where Docker guarantees your rules will run before its own. Docker will never overwrite or modify what you put there.

The fix is to hook UFW’s filtering into DOCKER-USER, so that every packet headed for a container has to pass through UFW first.

We do this by adding rules to /etc/ufw/after.rules. This file gets loaded when UFW starts, and it’s the right place for custom iptables rules that UFW should manage.

Here’s what we add:

# 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 -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 -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 172.16.0.0/12
-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

Let me break down what each part does:

-A DOCKER-USER -j ufw-user-forward — This is the key line. It sends all Docker-bound traffic through UFW’s forward chain. Now UFW gets to decide what passes and what gets blocked.

-j RETURN -s 10.0.0.0/8 (and the other private ranges) — Allows container-to-container traffic and local network communication. Without this, your Docker Compose services can’t talk to each other.

The ufw-docker-logging-deny rules — Block and log any new external connections to Docker containers that weren’t explicitly allowed. The log prefix [UFW DOCKER BLOCK] makes it easy to find these in your logs.

-j RETURN at the end — Anything that wasn’t caught falls through to Docker’s own rules.

After this patch, the packet flow becomes:

Internet
→ DOCKER-USER chain
→ UFW decides (allow or deny)
→ if allowed, Docker routes to container
→ Container receives traffic

Now UFW is actually in charge.

The Critical Difference: ufw allow vs ufw route allow

This is the part that trips people up, and it’s the most important thing in this entire post.

After applying the Docker fix, there are two different types of UFW rules and they protect two completely different things:

ufw allow — Protects the host

ufw allow OpenSSH

This allows traffic to services running directly on the host machine. It controls the INPUT chain. SSH runs on the host, not in a container, so this is correct.

ufw route allow — Protects Docker containers

ufw route allow proto tcp from any to any port 80
ufw route allow proto tcp from any to any port 443

This allows traffic to services running inside Docker containers. It controls the FORWARD chain, which is where Docker traffic lives.

Here’s the mental model:

What you’re doing

Command

Why

Allow SSH to host

ufw allow OpenSSH

SSH runs on the host

Allow HTTP to container

ufw route allow proto tcp from any to any port 80

Web server runs in Docker

Allow HTTPS to container

ufw route allow proto tcp from any to any port 443

Web server runs in Docker

Block everything else

Already done by default

The after.rules patch handles it

If you use ufw allow 80 instead of ufw route allow, your Docker container will still be blocked. ufw allow only opens the door for host traffic, not forwarded traffic.

One more thing: when you use ufw route allow, you specify the container port, not the host port. If your Docker Compose says 8080:80, you allow port 80, not 8080. The container is listening on port 80. Docker’s NAT handles the translation from 8080 to 80, but the firewall rule needs to match what the container actually sees.

The Updated Cloud-Init Config

Here’s the original 42-line config, now fixed. The changes are in write_files (the new after.rules block) and runcmd (using ufw route allow for container ports):

#cloud-config
packages:
- apache-utils
- fail2ban
- ufw
- docker.io
- curl
- git
package_update: true
package_upgrade: true
users:
- name: app
groups: users, admin, docker
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
ssh_authorized_keys:
- ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMZe78S7iGyDQJW8Dx3gYl5Iloa/CKFeFIyzu3p9uGH6 zde@hasek
write_files:
- path: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
MaxAuthTries 2
AllowTcpForwarding no
X11Forwarding no
AllowAgentForwarding no
AllowUsers app
- path: /etc/fail2ban/jail.local
content: |
[sshd]
enabled = true
banaction = iptables-multiport
- path: /etc/ufw/after.rules
append: true
content: |
# 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 -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 -p udp -m udp --sport 53 --dport 1024:65535 -j RETURN
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -p tcp -m tcp --tcp-flags FIN,SYN,RST,ACK SYN -d 172.16.0.0/12
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 192.168.0.0/16
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 10.0.0.0/8
-A DOCKER-USER -j ufw-docker-logging-deny -p udp -m udp --dport 0:32767 -d 172.16.0.0/12
-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
runcmd:
- systemctl enable fail2ban
- ufw allow OpenSSH
- ufw allow 80
- ufw allow 443
- ufw --force enable
- systemctl restart sshd
- reboot

Important: Replace the SSH key with your own.

The key changes from the original:

  1. New write_files entry for /etc/ufw/after.rules with append: true. This adds the Docker firewall rules to the existing UFW config without overwriting it.
  2. ufw route allow instead of ufw allow for ports 80 and 443. Because your web server runs in Docker, not on the host.
  3. ufw allow OpenSSH stays the same. SSH runs on the host, so the original command is correct.

The config is longer now. It’s no longer 42 lines. But it’s honest.

Why Not Use ufw-docker?

You might come across a tool called ufw-docker (6k+ stars on GitHub). It’s a shell script that automates exactly what we just did: modifying after.rules and providing convenience commands like ufw-docker allow httpd 80.

It’s a fine tool. But I’m not using it here, and you shouldn’t either in a cloud-init config. Here’s why:

  1. It downloads a script from GitHub during provisioning. If GitHub is down when your server boots, your firewall is misconfigured. We’re writing the rules directly into the config. No downloads, no moving parts.
  2. The rules it writes are the same ones we wrote above. There’s no magic. The tool is a wrapper around what you already have.
  3. The whole point of this series is to understand what you’re running. Downloading a script you haven’t read defeats that purpose.

Use ufw-docker on servers you manage manually. For automated provisioning, write the rules yourself.

Verify It Works

After your server boots, SSH in and check:

# Check UFW status for host rules
sudo ufw status

# Check that DOCKER-USER chain has our rules
sudo iptables -L DOCKER-USER -n --line-numbers

The DOCKER-USER chain should show your rules, with ufw-user-forward at the top and DROP at the bottom.

Now test it:

# Run nginx — this should be blocked (no route allow yet)
docker run -d -p 80:80 nginx

# From another machine, try connecting
curl http://your-server-ip
# Connection refused. Good. The firewall is working.

# Now allow port 80 through the firewall for Docker containers
sudo ufw route allow proto tcp from any to any port 80

# From another machine, try again
curl http://your-server-ip
# Nginx welcome page. Good. The route allow works.

If you can reach nginx before adding the route allow rule, something went wrong. Check that /etc/ufw/after.rules contains the BEGIN UFW AND DOCKER block and run sudo ufw reload.

The Cheat Sheet

Keep this somewhere. It will save you one day.

Scenario

Command

Allow SSH (host service)

ufw allow OpenSSH

Allow HTTP to Docker container

ufw route allow proto tcp from any to any port 80

Allow HTTPS to Docker container

ufw route allow proto tcp from any to any port 443

Allow specific IP to Docker container port

ufw route allow proto tcp from 1.2.3.4 to any port 5432

Bind service to localhost only (no firewall needed)

docker run -p 127.0.0.1:3306:3306 mysql

Check host firewall rules

ufw status

Check Docker firewall rules

iptables -L DOCKER-USER -n

Check if a port is actually open

nmap -p PORT your-server-ip (from another machine)

Bonus tip: If you have a service that only needs to be accessed from the server itself (databases, Redis, admin panels), skip the firewall entirely and bind to localhost:

docker run -p 127.0.0.1:3306:3306 mysql

This is the safest option. No firewall rule needed. The port is physically unreachable from outside the machine.

What I Learned

I built the original cloud-init config for people like me. Developers who are comfortable with Linux, who know Docker networking, who understand the gaps. The config was meant to be a starting point, and I assumed the reader would fill in the blanks.

That assumption was the mistake. When you publish a guide that says “production-ready,” people trust it. And they should be able to. If there’s a known gap between UFW and Docker that could expose their database to the internet, it needs to be in the guide.

The updated config fixes that. It’s longer, it’s more complex, but it does what the original promised: a server that boots up hardened and actually stays that way, even when Docker gets involved.


This is a follow-up to Cloud-Init: The 42-Line Config That Replaces Your Cloud Setup. The original post has been updated with a link to this one. Subscribe to get notified when new posts drop.

STAY UPDATED

Don't Miss New Articles

No spam, no sales pitches. Just practical content.

NEED HELP?

Don't Want to Do It Yourself?

Let me handle the migration for you.