Back to Blog

Locking Down 19 Wide-Open Container Ports Across the Homelab

5 min read

While chasing down an unrelated bug, I stumbled onto something that should have been caught a long time ago: every container's published Docker port on every one of my four hosts, docker01, docker02, docker03, and Tower, was bound to 0.0.0.0 with no firewall in front of it at all. No ufw, no iptables-persistent, nothing. Any device on the LAN could reach any published container port directly, completely bypassing Traefik and Authentik SSO.

How I Found It#

I was digging into a trustHost configuration fix on one of the websites when I noticed a container port responding to a direct request from my laptop that had no business being reachable outside of Traefik. A quick sweep across all four hosts confirmed it wasn't isolated. 19 ports total, spread across three Docker hosts and one Unraid box, all wide open to anyone on the network.

Why a Plain ufw Rule Doesn't Work#

The obvious first instinct is to drop in a ufw deny rule and move on. That doesn't work with Docker, because Docker manages its own iptables NAT rules that run ahead of the standard INPUT chain a tool like ufw writes to. Docker's DNAT happens first, so by the time a packet would hit a plain INPUT rule, it's already been forwarded to the container.

The actual place to intervene is the DOCKER-USER chain, which Docker creates specifically so operators can insert their own rules ahead of its NAT logic:

iptables -I DOCKER-USER -p tcp --dport <container_port> -m conntrack --ctorigdstport <published_port> -j DROP
iptables -I DOCKER-USER -p tcp --dport <container_port> -m conntrack --ctorigdstport <published_port> -s <allowed_source> -j ACCEPT

Order matters here. Insert the DROP rule first, then the ACCEPT rule, since iptables -I prepends and the most recently inserted rule ends up evaluated first. Using --ctorigdstport to match the pre-NAT published port (rather than the container's internal port) means the rule keeps working even if the container's internal IP changes on redeploy.

What Got Restricted, and to What#

Most ports only needed to accept traffic from one place: Traefik, at its dedicated internal address. That covered the public-facing sites, Authentik, Komodo's core API, the log viewer, Grafana, and Open WebUI.

A smaller set of ports were genuinely host-to-host traffic, not user-facing at all, and those got scoped to the specific peer host rather than to Traefik:

  • The log viewer's remote agent and Komodo's periphery agent on docker02 and docker03 only ever need to hear from docker01, confirmed by checking the actual environment variables driving that connection and by testing a raw connection from docker01 directly rather than trusting the config alone.
  • Qdrant on docker02 turned out to have a genuinely surprising wrinkle: Open WebUI, running on the very same host, connects to it over docker02's regular LAN address rather than a Docker-internal network alias, even though both containers live on the same machine. Scoping that port to anything other than docker02's own address would have broken RAG entirely.

Verifying It Actually Worked#

For every single port, I tested both directions live, immediately after each change rather than batching them all up and checking at the end: first that the legitimate path (from Traefik, or from the authorized peer host) still worked normally, and second that a direct connection attempt from an unauthorized source, my own laptop, timed out instead of connecting. I did this one at a time for the highest-stakes services, Authentik and Komodo, specifically because a mistake there would have taken down SSO for everything sitting behind it.

Persistence Isn't the Same on Every Host#

On the three Debian-based Docker hosts, iptables-persistent covers this cleanly: install it, save the rules with netfilter-persistent save, enable the service, done.

Unraid needed a different approach entirely. It isn't Debian, it doesn't have iptables-persistent, and its only real persistent storage is the boot USB stick, since the OS itself runs from a RAM-resident image rebuilt fresh at every boot. My first instinct was to drop the rule into /boot/config/go, the traditional rc.local equivalent, but that script runs before Docker itself initializes. DOCKER-USER is a chain Docker creates, so a rule referencing it in go would fail silently every single reboot with "no chain by that name," and I'd have had zero indication anything was wrong until the next time I actually checked.

The correct hook turned out to be the User Scripts plugin's "Array Started" trigger, which fires after Docker is already up. I found existing precedent for this exact pattern already in use on Tower for an unrelated disk setting, which confirmed it was the right mechanism before committing to it. One more Unraid-specific wrinkle worth remembering: User Scripts entries aren't executable by default, and the plugin invokes them by explicitly running bash script rather than relying on the execute bit, so there's no need to chmod anything.

What Else Turned Up Along the Way#

Not every fix that came out of this pass was about firewalls. I found that a homelab documentation file had been deliberately gitignored in an earlier commit for no security reason I could identify, in a repo that's already private. Re-tracked it so the firewall rules themselves would actually be version controlled going forward, which they hadn't been before this.

Where It Landed#

All four hosts are now locked down, 19 previously wide-open ports restricted to only the traffic that legitimately needs to reach them. Nothing changed from a user's perspective, since everything still routes through Traefik exactly as before, but the LAN itself is no longer a flat trust zone where any device can reach any container directly.

This work also fed directly into the ongoing VLAN segmentation project, which tackles the same flat-network problem at the network layer instead of the host layer.

Related Posts