The Firewall That Was Never On: Clearing My Homelab Security List With an AI Pair
A month ago I ran a security scan across everything I host at home: my blog, my portfolio, a few work tools, the automation server that runs half my life. Nothing was on fire. No "High" findings. But there were a dozen "Medium" ones, a handful of ports listening on my whole network that had no business doing so, and every single site was missing the same basic header.
So I did what any responsible engineer does with a list like that. I turned it into ten tidy tasks, put them in my task app, gave each one a due date... and then let every one of those due dates pass.
Today I finally sat down and cleared them. Not alone, though. I worked through them with Claude Code, my AI pair, and the way we split the work turned out to be the most interesting part.
The split: what the AI can do, and what only I can do
The first thing I asked was simple: "check what is pending and what we can execute." Instead of just reading back my task list, it went and checked the live server. That mattered, because a task list only tells you what you planned. The server tells you what's true. None of the ten had been done. Every port was still open, and no site had the header.
Then it sorted the list into two piles:
- Things it could do itself (with my go-ahead): change how two internal services expose their ports, fix the blog, and run a deeper scan of the apps that sit behind a login.
- Things only I could do: anything needing my admin password on the server, and anything in my Cloudflare dashboard.
That second pile is a deliberate line, not a limitation I'm annoyed by. An assistant that can type sudo on my production box, or flip settings on my DNS provider, is a much scarier assistant. So it handed me exact commands and click-paths, and I ran them myself.
"Status: inactive"
My first job was supposed to be the easiest: block the database port with the firewall. One command. I pasted it in, and the output said:
Status: inactive
The firewall wasn't on. It had never been on. Earlier the system had reported the firewall service as "active", which only means the service is loaded, not that it's filtering anything. My rule was saved, and it did absolutely nothing.
Twenty years in broadcast teaches you a version of this lesson early: the light on the panel says "ON", but you still put a meter on the line. A status indicator is a claim, not a measurement.
The obvious next move would be "fine, turn the firewall on." Claude stopped me there, and rightly. Switching on a firewall that's been off for its whole life blocks everything you haven't explicitly allowed, and that can include your own remote login. There's a second catch too: Docker publishes container ports by writing its own network rules, which go around the usual Ubuntu firewall. So it wouldn't even have protected the containers.
The better fix was at the source. The database was set to listen on every network interface, and nothing actually needed that. One line in its config changed it to listen only on the machine itself, followed by a restart. I ran it, and it now answers only locally.
Checking before closing
For the two container services, the AI was careful in a way I appreciated. Before changing anything, it checked:
- that recreating the container would keep the exact same image, so no surprise upgrade sneaks in with a "small" change;
- that the data volume would carry over;
- which other containers talk to the service, and over which address;
- and one trap the original task notes had warned about: one of the containers had been attached to two extra networks by hand at some point. Recreating it silently drops those, and the apps depending on them break. So it reattached them straight away and tested from each container that uses the service.
For the document service, it didn't just ping a health URL. It sent a real HTML file through and checked that a real PDF came back. A health check tells you the service is up. A real conversion tells you it works.
Then a funny moment: it tested the closed ports from my Mac and declared them closed... but SSH also showed "closed", which can't be right, because I was logged in over SSH. Turns out my Mac only reaches the server over my private VPN, never the local network, so that test proved nothing. It said so plainly and re-ran the check from the server itself. I'd much rather have an assistant that catches its own bad test than one that reports a green tick.
The blog had 156 insecure images
This blog has been around since the Blogger days, and old posts point at images on websites that no longer exist, over plain http://. Browsers complain about that on an https page.
The first count said 7 posts. That felt low, and it was: Ghost's API quietly caps results at 100 posts per request, even when you ask for "all". Once it went page by page through all 645 posts, the real number was 156 insecure image links across 68 posts.
Rather than blindly swapping every http for https, it tested each one first. 45 images still worked over https, and only those were changed, editing just the URL text and leaving the rest of each post untouched. The other 111 point at sites that are simply gone: old photo hosts, a long-dead CDN, blogs that shut down a decade ago. Changing those would only swap one broken link for another, so they stayed as they were. Every edited post was backed up first.
The Cloudflare part was mine
The last two tasks were mine: turn on HSTS (which tells browsers "never talk to me over plain http") and add a rule that sets a few security headers on four of my sites.
I immediately got stuck. "I can't find Transform Rules." Cloudflare had reorganised its dashboard since the task was written. We found it under Rules → Overview → Create rule → Response Header Transform Rule. Small thing, but it's a good reminder that step-by-step notes go stale. The underlying steps don't change, but where they live in a dashboard does.
One detail I'm glad was in the plan: the header rule deliberately leaves out my work app, which already sends its own, stricter frame protection. A blanket "apply to everything" rule at Cloudflare would have overwritten that strict setting with a weaker one. Sometimes the secure move is to leave something alone.
The before and after
The final re-scan, compared against last month's:
- HSTS: missing on every site → present on every site.
- Clickjacking, content-type sniffing and "X-Powered-By" findings: gone from the four public sites.
- The three internal ports that were open to my whole network: closed.
- Still zero High findings. A few Mediums remain, mostly "add a Content Security Policy", which needs a careful, report-only rollout of its own.
Those leftovers went straight back into my task app as dated follow-ups. And I made it a standing rule: at the end of every session, anything still waiting on me becomes a task, so this list never quietly rots for a month again.
What I'm taking away
- Check the live system, not the plan. My task list said "pending". The server agreed, but it could just as easily have disagreed.
- A status light is not a measurement. "Active" didn't mean "on".
- Fix at the source. Making the service stop listening beat bolting on a firewall that Docker would have walked around anyway.
- Keep the dangerous keys with the human. The AI did the careful, tedious checking. I typed the passwords and clicked the dashboard switches. That split felt exactly right.
Ten tasks, one afternoon, a month late. I'll take it.