Dev.to Security 🔐 Cybersecurity 👁 0 📖 5 min read

ufw Said My Pi Was Locked Down. nmap Found Postgres Open to the Internet.

My Raspberry Pi 4 sits behind my router, runs ufw, and holds the database for my AI agent. For eight months I described it to myself as "firewalled." I had the receipts: ufw status verbose said active, default deny incom

My Raspberry Pi 4 sits behind my router, runs ufw, and holds the database for my AI agent. For eight months I described it to myself as "firewalled." I had the receipts: ufw status verbose said active, default deny incoming, one rule allowing SSH from my LAN only.

Then I wrote a post telling readers to check what their machines actually expose. It felt dishonest to publish it without checking mine, so I rented a $3 VPS for an hour and scanned myself from the outside.

nmap -Pn -p- --min-rate 2000 <my-public-ip>

PORT     STATE SERVICE
22/tcp   open  ssh          # expected...ish
5432/tcp open  postgresql   # what
6379/tcp open  redis        # no. no no no

Two database ports, wide open to the internet, on a box where ufw was enabled and denying everything. Both of those ufw facts were true. That is the whole problem.

Why ufw was telling the truth and still lying

ufw is a friendly frontend to iptables' filter table, specifically the INPUT chain. Traffic arriving from the internet to a port that a host process is listening on hits INPUT. Blocking it there works.

Docker does not send published-port traffic through INPUT. When you write -p 5432:5432, Docker inserts a DNAT rule in the nat table that rewrites the destination address, and the packet is then routed through the FORWARD chain to the container's bridge network. ufw never gets a vote. It has no rules in FORWARD, so the default policy there — set by Docker to ACCEPT — is what decides.

You can watch it happen:

sudo iptables -t nat -L DOCKER -n --line-numbers
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:5432 to:172.18.0.4:5432

That rule was six months older than my confidence in my firewall. And ufw status will never mention it, because from ufw's perspective there is nothing to mention.

I had added -p 5432:5432 to docker-compose.yml because I wanted to poke at the schema from my laptop on a Saturday afternoon. I told myself I would remove it after lunch. I did not remove it after lunch.

What was actually in the logs

Once I knew where to look, the evidence was not subtle. Nine days of Postgres logs, about 11,000 failed authentication attempts, source addresses spread across a dozen countries. Automated scanners find an open 5432 in hours. It is not personal; it is arithmetic.

The Redis container was worse, because Redis on 6379 with no password is not a brute-force target. It is a write target. In the keyspace I found four keys named backup1 through backup4, and a CONFIG SET attempt pointing the dump directory at /root/.ssh with dbfilename authorized_keys. That is the standard playbook: use Redis as a file-write primitive, drop an SSH key, come back later.

It did not work. Not because of anything I did on purpose with security in mind, but because I had set read_only: true on that container six months earlier for an unrelated reason — I was chasing a bug where a job rewrote a config file mid-run and I wanted it to fail loudly. The attacker's write bounced off a read-only filesystem, and the container logged an error I never read.

I got lucky, and the luck came from a decision I made while thinking about something else. That is not a security posture. That is a coin flip that happened to land well.

The five-minute audit I now run monthly

Two commands, run from outside and inside. The gap between them is your real attack surface.

# From a machine that is NOT this host (a VPS, a phone on cellular)
nmap -Pn -p- --min-rate 2000 <public-ip>

# On the host
sudo ss -tulpn | grep -v '127.0.0.1\|::1'

The first list is what strangers see. The second is what is bound to all interfaces. Anything in the first list that you cannot justify in one sentence is a bug, and "I'll remove it later" is not a sentence, it is a wish.

On the Pi, the fix took four edits:

services:
  postgres:
    ports:
      - "127.0.0.1:5432:5432"   # was: "5432:5432"
  redis:
    # no ports: block at all. Nothing outside needs it.

Binding to 127.0.0.1 means Docker's DNAT rule targets the loopback interface, which is not reachable from off-host. If I need ad-hoc access from my laptop, I use a tunnel instead of a published port:

ssh -N -L 5432:127.0.0.1:5432 pi@<host>

The tunnel exists only while the command runs, which is exactly the property my "temporary" port lacked. If you genuinely need published ports and host-firewall semantics at the same time, ufw-docker exists and does the FORWARD-chain work that stock ufw skips. I installed it, then removed it again, because binding to loopback solved the actual problem and one less moving part is one less thing to misconfigure.

The honest part

I had been writing about security for weeks. I had published a post about auditing what an API key can reach and another about cleaning leaked environment variables out of a repo. Meanwhile, my own database was answering strangers, and the only reason I found out was that I wanted a screenshot for an article.

What I got wrong was not the docker-compose.yml line. It was the belief that enabling a tool is the same as being protected by it. ufw status: active reads like a verdict. It is not a verdict, it is a description of one layer, and I had stacked three layers on top of it that it could not see — Docker's FORWARD policy, my router's port forwarding, my provider's firewall. Each of those has its own opinion, and none of them is reading ufw's notes.

The second thing I got wrong: I had no expiry mechanism for temporary changes. So I added one that does not depend on my memory. There is now a # REVIEW-BY: 2026-12-01 comment above every published port, and a cron job on the Pi that greps for REVIEW-BY dates in the past and pings me. The job is twelve lines long and it is the most useful thing I have added to that machine all year.

The checklist, if you run containers on a Pi

  • Never -p 5432:5432 on a host with a public IP. Prefix with 127.0.0.1: or omit the port entirely.
  • Treat ufw status as one input, never as an answer. Check iptables -t nat -L DOCKER -n too.
  • Scan yourself from outside, monthly, from a network you do not control.
  • Every published port gets a review date in a comment, and something automated nags you about it.
  • Assume Redis, Mongo, and Elasticsearch with no auth are already compromised if they were ever reachable. Rotate the keys, don't just close the port.
  • Read the container logs you never read. The only reason I know the attack failed is that I went looking for an error message I had been ignoring for months.

If you are about to put something on the internet this week, the drilling order matters more than the tooling: scan from outside first, then read what is bound, then fix the compose file, then re-scan and diff. The full checklist I run through on launch day — plus the scripts that do the outside scan, the port diff, and the REVIEW-BY nag — is in Ship Safe — The Launch-Day Security Kit — code LAUNCH90 at checkout makes it $1.50.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.