The server was new
and you did it properly.
ufw,
deny everything coming in,
allow SSH,
allow the web ports.
ufw status
said exactly that.
Then the app went in,
in containers,
with a compose file
copied from the readme.
Postgres,
with a line
that said 5432 colon 5432.
So you could reach it
from your laptop
while you were setting things up.
Six weeks later
a stranger's login
appeared in the database log.
From an address
on another continent.
The firewall
still said deny.
The firewall was right.
It just never saw the traffic.
When you publish a port,
Docker writes its own rules
into iptables.
A packet arriving
for that port
is redirected to the container
before it reaches
the chain where ufw keeps its rules.
It is forwarded,
not delivered,
so the deny you wrote
never gets asked.
Docker's own documentation
says it plainly.
Published ports
go around ufw.
And by default
a published port
listens on every address
the machine has,
including the public one.
Machines scan the internet
for open database ports
all day,
every day.
Six weeks
was a long time.
The fix is mostly
not to publish at all.
Containers on the same Docker network
can reach each other by name.
The app does not need
a port on the host
to talk to its database.
If you do need it from the host,
bind it to the loopback address.
127.0.0.1 colon 5432 colon 5432.
Now only the machine itself
can connect,
and you reach it
through an SSH tunnel.
If you really must
filter published ports,
the rules go in a chain
called DOCKER-USER,
which Docker checks first.
Not in ufw.
Keep a firewall
in front of the machine as well,
at the cloud provider,
where Docker cannot write.
Then test it the only way
that means anything.
From a different machine,
outside your network,
try every port
you believe is closed.
The firewall's own report
is not a test.
It is the firewall's opinion
of the traffic
it was shown.
– Serguey Asael Shinder