$ BLR//CODE

Home / Notes

26 July 2026 · Technical

Your cloud firewall rules probably don't do what you think

A hardened VPS with correct iptables rules can still expose a port to the internet. Two layers of firewall, plus Docker rewriting one of them behind your back.

Here’s a failure mode worth internalising, because it produces a server that looks locked down in every check you’d normally run, and isn’t.

Cloud VMs have two firewalls. There’s the one inside the guest OS — iptables, nftables, ufw, whatever you configured — and there’s the one in the provider’s virtual network, outside the VM entirely. AWS calls it a security group. OCI calls it a security list or NSG. GCP calls them VPC firewall rules.

Traffic has to pass both. That sounds simple, and it’s exactly where the confusion starts, because the two layers fail in opposite directions:

  • Provider allows, host denies → connection refused. Annoying, but safe, and you’ll find it in ten minutes because your service doesn’t work.
  • Host allows, provider denies → also broken, also safe, also quickly found.
  • Both allow, and you only remember configuring one → open to the internet, working perfectly, and nothing tells you.

The last case is the problem. Nothing is broken, so nothing prompts you to look.

Then Docker joins in

This is the part that catches people who are being careful.

When you publish a container port — ports: ["8080:8080"] in a Compose file — Docker doesn’t put that rule in the INPUT chain where your host firewall rules live. It inserts its own rules into the DOCKER chain, which hangs off FORWARD, and it does so ahead of your policy.

The practical consequence: your carefully written INPUT drop rules do not apply to published container ports. You can have a default-deny INPUT policy, verify it with iptables -L, and still be serving that container to the entire internet.

Two ways to deal with it, and you want the first:

# Bind to a specific interface, not all of them.
ports:
  - "127.0.0.1:8080:8080"     # local only — reverse proxy reaches it
  - "10.0.40.5:8080:8080"     # or one internal IP

Never - "8080:8080" on a public host. The short form means 0.0.0.0, which means every interface, which means the public one.

The second: on a cloud VM, treat the provider’s firewall as the real perimeter, because it sits outside the VM and Docker cannot reach around it. Host rules become defence in depth rather than the primary control. That’s the opposite of the instinct most Linux admins have, and it’s correct here.

A short checklist that catches this

Run these on any public VM you own:

# What is actually listening, on which addresses?
ss -tulpn | grep -v '127.0.0.1\|::1'

# What did Docker publish, and to which interface?
docker ps --format 'table {{.Names}}\t{{.Ports}}'

# What does the host firewall believe?
sudo iptables -S | grep -E 'INPUT|DOCKER'

Then compare that against the provider’s security list in the console. The two views disagree more often than you’d expect, and the disagreement is the finding.

One more trap: don’t mix firewall tools

On distributions where the cloud image ships pre-seeded iptables rules — Oracle Linux and Oracle’s Ubuntu images are the common ones — installing ufw on top does not replace those rules cleanly. You end up with two tools writing to the same tables with different mental models, and the result is neither one’s intended policy. Pick whichever the image already uses, and stay with it.

None of this is exotic. It’s just that “I hardened the server” and “the port is closed” are two different claims, and only one of them can be verified with iptables -L.

cloudnetworkingsecuritydocker

CTA/ — Follow up

Want this done properly at your place?

If this describes a problem you're currently living with, describe your situation and you'll get a straight answer about what fixing it involves.

Get in touch →