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:
- Set up UFW with just
allow OpenSSH - Deploy a Docker Compose stack with nginx
- Check
ufw statusand feel safe - 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 |
| SSH runs on the host |
Allow HTTP to container |
| Web server runs in Docker |
Allow HTTPS to container |
| Web server runs in Docker |
Block everything else | Already done by default | The |
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:
- New
write_filesentry for/etc/ufw/after.ruleswithappend: true. This adds the Docker firewall rules to the existing UFW config without overwriting it. ufw route allowinstead ofufw allowfor ports 80 and 443. Because your web server runs in Docker, not on the host.ufw allow OpenSSHstays 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:
- 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.
- 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.
- 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) |
|
Allow HTTP to Docker container |
|
Allow HTTPS to Docker container |
|
Allow specific IP to Docker container port |
|
Bind service to localhost only (no firewall needed) |
|
Check host firewall rules |
|
Check Docker firewall rules |
|
Check if a port is actually open |
|
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.