← All posts

Four green ticks that do not cover the broken layer

ufw active, scanner clean, backup job exited 0, container up — four green ticks, and the three layers that stay broken underneath. What each tick does not cover, and the commands that show it.

Most self-hosted servers do not fall over loudly. They fail quietly at the one place where a dashboard is green and the machine is not. You turned the firewall on, you run a scanner, last night's backup job finished with no error. None of those three ticks is evidence that the port is closed, that the advisory was ever read, or that the backup comes back.

Four ticks, and three of them are bookkeeping:

  • ufw is active.
  • The scanner ran and reported nothing.
  • The backup job's last run is green.
  • The container health check answers.

The last one only says the process is up. The first three look like security and mostly record that a job was configured. Here is what each one leaves uncovered.

1. ufw status says active; Docker publishes anyway

ufw writes its rules into the INPUT chain — packets addressed to the host itself. A published Docker port is not addressed to the host. Docker inserts its own rules in the nat table's PREROUTING chain, the packet is rewritten to the container's address, and from there it travels the FORWARD path. It never meets your INPUT rules. Your "deny 8080" rule can sit there, active and green, while docker run -p 8080:80 answers the whole internet. The write-up on this also flags a log rotation setting worth making before the disk fills (ufw rules and Docker ports).

Look at which rules actually exist, and in which chain:

bash
sudo iptables -t nat -S DOCKER          # what Docker published
sudo iptables -S DOCKER-USER            # your own hook, before Docker's FORWARD rules
sudo ss -tlnp | grep -E ':(80|443|8080|5432)'   # what the host really listens on

Then stop asking the host and ask from outside — a second machine, or a phone on mobile data:

bash
nmap -Pn -p 1-65535 <server-ip>

A pass is not "ufw is active". A pass is: nothing is reachable that you did not intend to expose. That includes the address a port is bound to. -p 8080:80 binds every interface; -p 127.0.0.1:8080:80 binds loopback only and puts the port back behind whatever sits in front — a reverse proxy, a VPN mesh, a tunnel. If a port must stay reachable but filtered, the rule belongs in DOCKER-USER, not INPUT. You can also tell Docker to stop managing iptables and write the rules yourself — and then you own every rule Docker used to write for you, container-to-container traffic included.

2. The scanner is clean; the advisory never named a package

A scanner matches what it can see: image layers, installed versions, OS packages. It cannot match an advisory that never names the module it is about. A pre-registered result reported that around half of self-hosted apps' own security advisories do not name the package they concern (the pre-registered result), and the follow-up looks at what predicts whether those advisories reach a scanner at all (the follow-up). That is the gap: a scanner can be correct and blind in the same run.

So keep a second list that no scanner fills in for you. Pin down what you actually run:

bash
docker image ls --digests --format '{{.Repository}}\t{{.Tag}}\t{{.Digest}}'
docker inspect -f '{{index .Config.Labels "org.opencontainers.image.version"}}' <container>

Then open the app's own advisory page or its GitHub security advisories, read the fixed version, and compare it with yours. One line per advisory is enough — app, version you run, advisory, fixed version, date you checked. The point is not to automate it; it is that manual review catches exactly the class of advisory a version scanner cannot.

3. "The backup ran" is not "the backup restores"

A green backup job means the job exited zero. It does not mean the dump is complete, that it holds the table you care about, or that the credentials in the job still work on a fresh host. The podcast episode that names this pattern — "the green check lies" — makes the point across backup, networking and self-hosting generally (the episode).

The only test that counts is a restore you actually ran, into something disposable:

bash
docker run --rm -d --name restore-test -p 127.0.0.1:55432:5432 postgres:16
pg_restore -h 127.0.0.1 -p 55432 -U postgres -d restored /backups/db.dump
psql -h 127.0.0.1 -p 55432 -U postgres -d restored -c 'select count(*) from orders;'

Write down the minutes it took. That is your restore time, and it is the only backup number worth putting on a dashboard. Put the drill in the calendar once a month: a restore you have never run is a hope with a cron entry.

What to check instead of the tick

Run these four on one host this week and keep the output:

  1. sudo iptables -t nat -S DOCKER and ss -tlnp — do the published ports match what you meant to publish?
  2. nmap -Pn -p 1-65535 <server-ip> from another machine — is anything reachable that should not be?
  3. One restore into a throwaway container, then a row count — does the data come back, and how many minutes does it take?
  4. One advisory page of an app you run, against your version — is there a fixed version you are not on?

Four ticks can be green while all four answers are uncomfortable. The tick tells you a job finished. Only the check tells you what happened.