← posts / infrastructure

Docker publishes ports straight past UFW: hardening a fresh VPS without the firewall rule that does nothing

ufw deny 8080 does not stop a Docker container published on 8080. Docker's own docs explain why. A 15-minute fresh-VPS walkthrough: key-only SSH, ufw, then the two fixes that actually close a container port, and a scan from outside to prove it.

if.codesOct 11, 2026 · 6 min read#docker#security#vps#firewallAI-assisted

A one-command VPS hardening script went round X this week, and the replies were the usual mix: people who had never thought about it, and people who had already been bitten. The bite is almost always the same one. You enable ufw, you allow only SSH and HTTPS, you run docker run -p 8080:80 something, and port 8080 is open to the whole internet anyway.

That is not a ufw bug and not a misconfiguration on your side. Docker's documentation says it plainly: Docker and ufw use firewall rules in ways that make them incompatible, because Docker routes container traffic in the nat table, so packets are diverted before they reach the INPUT and OUTPUT chains that ufw uses. Your ufw deny rule is real; the packets just never pass through it.

This guide takes a fresh Ubuntu/Debian VPS to a state where the only open ports are the ones you meant, in about 15 minutes. Everything runs as a sudo user, not root.

1. SSH: keys only

From your laptop, copy your key first (and test that you can log in with it before you change anything):

Securing server access with SSH keys is the first line of defense.
Securing server access with SSH keys is the first line of defense.
ssh-copy-id deploy@203.0.113.10
ssh deploy@203.0.113.10

On the server, drop a config snippet instead of editing the main file, so package upgrades do not fight you:

sudo tee /etc/ssh/sshd_config.d/10-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
EOF
sudo sshd -t && sudo systemctl reload ssh

sshd -t checks the config before the reload. Keep your current session open and confirm a second login works before you close it. On some distributions the service is called sshd rather than ssh.

2. fail2ban for the SSH noise

sudo apt-get update
sudo apt-get install -y fail2ban
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

With password logins off, fail2ban is mostly about keeping logs readable; the key-only setting is what actually stops guessing.

3. ufw for the host

sudo apt-get install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

This protects services running directly on the host. It does not protect container ports that Docker publishes. That is the part most scripts skip.

4. See the problem for yourself

Start a throwaway container with a published port, and deny that port in ufw:

Docker traffic often bypasses standard UFW rules due to how it handles network routing.
Docker traffic often bypasses standard UFW rules due to how it handles network routing.
docker run -d --name leaktest -p 8080:80 nginx:alpine
sudo ufw deny 8080/tcp

Now, from a different machine (your laptop, not the server):

curl -sI http://203.0.113.10:8080 | head -n 1
HTTP/1.1 200 OK

The ufw rule is there and the page still loads. Docker's port-publishing docs explain the default: when a port is mapped without a host address, the daemon publishes it on all host addresses, 0.0.0.0 and [::]. The same page calls publishing container ports "insecure by default".

Need the backend underneath done properly?I build and harden Go and Python backends. The estimate is free.

5. Fix A: bind published ports to localhost

For most small setups this is the right fix. Your app containers do not need to be reachable from the internet; your reverse proxy (Caddy, Traefik, nginx) does. Publish the app on the loopback address only and let the proxy on 80/443 forward to it:

Binding ports to localhost ensures services are only reachable via a controlled proxy.
Binding ports to localhost ensures services are only reachable via a controlled proxy.
docker rm -f leaktest
docker run -d --name leaktest -p 127.0.0.1:8080:80 nginx:alpine

In Compose, the same thing goes in the ports list:

services:
  app:
    image: nginx:alpine
    ports:
      - "127.0.0.1:8080:80"

To make loopback the default for the default bridge network, so a forgotten address does not open a port, set the ip key in /etc/docker/daemon.json. Do not confuse it with bip: bip sets the address of the docker0 bridge itself, while ip is the default host address that published ports bind to (the daemon's --ip flag, default 0.0.0.0). Docker's port-publishing page documents exactly this use:

{
  "ip": "127.0.0.1"
}
sudo systemctl restart docker

Note the scope: Docker documents this key for the default bridge network. Containers on user-defined networks (which Compose creates) still need the explicit 127.0.0.1: prefix, so keep writing it.

6. Fix B: rules in the DOCKER-USER chain

Sometimes a container port really must be public, but only to some addresses (an office IP, a monitoring service). Do not edit Docker's own chains: the docs warn against modifying the rules Docker creates. Docker gives you a chain for this instead, DOCKER-USER, which is processed before its own DOCKER-FORWARD and DOCKER chains.

Find your external interface name first:

ip -o route get 1.1.1.1 | awk '{print $5}'

Then, with eth0 as an example, drop everything arriving on that interface that is not from your allowed range. This is the docs' own example pattern:

sudo iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.0/24 -j DROP

Two things to know. First, that rule drops all external traffic to all published containers except from the range, so if you also serve a public site from a container, allow it explicitly. Docker's docs show matching on the original destination port with conntrack, because by the time packets reach this chain the destination has already been rewritten to the container's address:

sudo iptables -I DOCKER-USER -p tcp -m conntrack --ctorigdstport 443 -j ACCEPT

The docs note the conntrack extension may degrade performance. Also let reply traffic back in, or containers lose their own outbound connections (package downloads, API calls), since the replies arrive on the external interface too:

sudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPT

Second, order matters. iptables -I inserts at the top of the chain, so the command you run last is evaluated first. Run the commands in the order shown here (DROP, then the port ACCEPT, then the ESTABLISHED ACCEPT) and the chain reads ACCEPT, ACCEPT, DROP from the top. Check it before you log out:

sudo iptables -L DOCKER-USER -n --line-numbers

The DROP rule must be below both ACCEPT rules. These rules are not persistent across reboots on their own; save them with whatever your distro uses (for example iptables-persistent) and re-check after a Docker upgrade.

If you only remember one of the two fixes, make it Fix A. It needs no firewall knowledge and it fails closed.

7. Prove it from outside

The only test that counts is one run from another network. From your laptop:

nmap -Pn -p 22,80,443,8080 203.0.113.10
PORT     STATE    SERVICE
22/tcp   open     ssh
80/tcp   open     http
443/tcp  open     https
8080/tcp filtered http-proxy

And on the server, list what is actually listening on all addresses:

sudo ss -tlnp | grep -v '127.0.0.1'

Anything bound to 0.0.0.0 or [::] that you did not expect is a port to explain. Run the scan again after every new docker compose up that adds a ports: entry; this is where leaks come back.

Checklist

  • SSH: keys only, root login off, config tested with sshd -t.
  • ufw: default deny incoming, allow 22/80/443. Understand it does not cover published container ports.
  • Every ports: entry starts with 127.0.0.1: unless it must be public.
  • Public-but-restricted container ports: rules in DOCKER-USER, saved persistently.
  • An nmap from outside after every deploy that changes ports.

Sources

if.codesI build RAG, AI integrations and agent pipelines on Go and Python backends — and write about it here.
// keep reading

More posts on AI and backends.

// free quote

Read something you need? I’ll quote it for free.

RAG, AI integrations, agents or the backend underneath — tell me what you have and what should change. I read every request myself.

Free · no commitment

Tell me what you have. I’ll tell you what it takes.

1Describe the projectA few sentences is enough — about two minutes.
2I review itI read it myself and may ask a follow-up question.
3You get a free quoteScope, approach and estimate — yours to keep, no strings.
What kind of project is it?
Free and without obligation. Your details are used only to reply — see the privacy policy.