You Opened Port 32400 Two Years Ago and Forgot
It’s 2 AM. Your auth log shows 400 failed SSH logins from six countries. You didn’t know SSH was reachable from outside. You also forgot you opened Plex for a friend in 2024, that your router has a UPnP table you never looked at, and that Docker doesn’t ask your firewall for permission.
Most home lab security advice starts with tools: install a WAF, deploy an IDS, buy a firewall appliance. That’s backwards. Before you defend anything, you need a list of what the internet can actually reach. That list is almost always longer than you think, and the surprises are boring ones.
This article does two things. First, an outside-in audit: look at your network the way a scanner does, with a working command for each exposure source. Second, a small threat model: who really attacks a home lab, what they want, and what to do about each asset. The verdict is short. Expose a reverse proxy on 443 or nothing at all, and reach everything else over a mesh VPN.
Who Actually Attacks a Home Lab
Not a nation state. Not the person who hates your Minecraft server. Three groups account for nearly everything:
- Mass scanners. Bots sweep the whole IPv4 space on common ports, constantly. Services like Shodan and Censys do it for search indexes. Others do it for resale or for the next group. Your IP gets probed within hours of going online, and nobody picked you.
- Credential stuffing and brute-force bots. They hit SSH, RDP, web logins, and anything with a password field. They try leaked passwords and defaults. Your goal is to not have a login page they can reach.
- Botnet operators hunting known CVEs. They scan for a specific unpatched version of a specific product (a NAS, a router, a media server, a dashboard) and exploit it automatically. The prize is your bandwidth and CPU, plus a foothold to attack the next target from your IP.
What they want is boring: compute for mining, bandwidth for DDoS and proxying, a place to host malware, stored credentials, or ransomware leverage against your backups. They don’t care that it’s your lab. That’s good news. Automated attackers only try what’s reachable, so shrinking the reachable surface beats nearly every clever defense.
Step 1: Scan Yourself From the Outside
Scanning your own IP from inside your LAN tells you nothing about what the internet sees. NAT hairpinning, router firewall rules, and IPv6 all behave differently from outside. Use a VPS, or tether your laptop to your phone’s hotspot (a different network entirely).
First find your public address from inside the lab:
curl -4 ifconfig.me# 203.0.113.10 (documentation address, yours will differ)Then, from the outside machine, scan every TCP port:
nmap -Pn -p- --min-rate 1000 203.0.113.10-Pn skips host discovery, since many routers drop ping and nmap would otherwise report the host as down. -p- covers all 65535 ports, because the interesting listeners are rarely on port 80. A full scan takes a few minutes. Add a quick UDP pass, which is slow, so limit it:
sudo nmap -Pn -sU --top-ports 50 203.0.113.10Only scan addresses you own. Your own IP is fine. Your neighbor’s is not.
Next, see what the public indexes already know. Shodan has a CLI:
pip install shodanshodan init YOUR_API_KEYshodan host 203.0.113.10shodan host <ip> prints what Shodan has recorded for that address: open ports, banners, and the organization that owns the range. If you’re unsure of the exact output on your version, shodan host --help shows it. Censys has a similar search on its website. If either shows a port your own nmap scan missed, that port was open earlier, and someone captured it. Treat old banners as a hint about what was exposed, then go find out why it stopped.
Whatever this scan finds is your real baseline. Everything below explains why that list contains ports you never opened on purpose.
Step 2: UPnP and NAT-PMP Open Ports Without Asking
UPnP lets any device on your LAN ask the router to forward a port to itself. Game consoles do it. Torrent clients do it. Plex does it if you let it. A compromised IoT camera can do it too. No password, no prompt, no notification.
List what’s currently mapped, from a machine inside your LAN, with miniupnpc:
sudo apt install miniupnpc # or your distro's equivalentupnpc -l-l lists the port redirections on the router’s Internet Gateway Device. Look at the external ports, protocols, and the internal address each one points to. If you see a mapping for a device you don’t recognize, or an old Plex mapping for a server you rebuilt, that’s an open door. -d <external port> <protocol> deletes a mapping, but the real fix is in the router settings: turn UPnP off. Then forward the few ports you need by hand, so every exposed port is a decision you made. Also, upnpc speaks UPnP only. Mappings made through NAT-PMP or PCP may not show up in its list, so check the router’s own port-mapping page as well.
Your outside nmap scan should match your manual forwards exactly. If it shows more, UPnP did it.
Step 3: Docker Ports Walk Around Your Firewall
This one catches experienced people. You run UFW. You set ufw default deny incoming. You start a container with -p 8080:80. Port 8080 is now reachable from the internet, and ufw status says it isn’t.
The mechanism is routing, not a bug. Docker’s published ports use DNAT in the nat table. A packet to port 8080 gets its destination rewritten to the container’s address in PREROUTING, then it gets routed to the container instead of to the host. It never reaches the host’s INPUT chain, where UFW’s rules live. Docker’s own documentation says it plainly: because Docker routes container traffic in the nat table, packets are diverted before they reach the firewall rules ufw manages.
Published ports also bind to every address by default, IPv4 and IPv6. The Docker docs state that the daemon publishes ports to all host addresses, so -p 8080:80 is reachable from outside the host.
Audit it:
docker ps --format '{{.Names}}\t{{.Ports}}' | grep -E '0\.0\.0\.0|\[::\]|:::'Every line in that output is listening on all interfaces. Now fix it, in order of preference.
Fix 1: bind to loopback. If a reverse proxy on the same host fronts the container, the container needs no public port at all:
services: app: image: ghcr.io/example/app:latest ports: - "127.0.0.1:8080:80" - "[::1]:8080:80"The docs show the same pattern: docker run -p 127.0.0.1:8080:80 -p '[::1]:8080:80' nginx. Only the host can reach the port. If your reverse proxy is another container on the same Docker network, drop ports: entirely and let it talk to the container by name.
Fix 2: filter in the DOCKER-USER chain. Docker documents DOCKER-USER as a placeholder for your rules, processed before Docker’s own DOCKER-FORWARD and DOCKER chains. Docker’s own example drops everything not from one source address:
sudo iptables -I DOCKER-USER -i eth0 ! -s 192.0.2.2 -j DROPsudo iptables -I DOCKER-USER -m state --state RELATED,ESTABLISHED -j ACCEPTReplace eth0 with your external interface. Insert the ESTABLISHED rule second so it lands on top. Without it, replies to your containers’ own outbound connections get dropped too. Three more details matter. Packets reaching DOCKER-USER have already been through DNAT, so you see the container’s internal address and port, not the original ones. To match the original destination you need the conntrack extension with --ctorigdst, and Docker warns it can cost performance. Also, rules you add with iptables don’t persist across reboots on their own, so save them with your distro’s mechanism. And these rules cover IPv4 only: repeat them with ip6tables for the [::] side of every published port.
A note on Docker 28. Docker 28.0 changed the defaults: remote hosts can no longer reach a container’s internal IP directly. Before that, a machine on the same LAN segment could reach a container IP directly, even for ports you never published. The docs note that releases older than 28.0.0 let hosts on the same L2 segment reach ports published to localhost, and that unpublished ports are now blocked by default. Upgrade, but remember this hardening only blocks direct access to container IPs and loopback-published ports. A published port on 0.0.0.0 is still public by design.
Docker also supports an nftables backend via the firewall-backend daemon option. The docs still label it experimental, so check the current page before you switch a production host.
Step 4: IPv6 Skips Your Port Forwarding Mindset
With IPv4, NAT accidentally acts like a firewall. Devices sit on private addresses, and nothing reaches them unless you forward a port. IPv6 has no such crutch. Every device can get a globally routable address, and the only thing between it and the internet is your router’s IPv6 firewall.
Check what your machines have:
ip -6 addr show scope global# inet6 2001:db8:1234:5678::10/64 scope globalIf you see a global address, that host is addressable from anywhere. Whether it’s reachable depends on the router. Most consumer routers default to blocking unsolicited inbound IPv6, but not all, and some firmware has had bugs. Don’t assume. From your outside machine, scan the v6 address:
nmap -6 -Pn -p- 2001:db8:1234:5678::10Two common surprises. A Docker port published on 0.0.0.0 is also published on [::], so your IPv4 audit has an IPv6 twin. And a server you forgot about, with SSH listening on all interfaces, is now reachable by IPv6 if your router permits it, regardless of your IPv4 forwards. For the full dual-stack picture, see IPv6 for the home lab.
Step 5: Certificate Transparency Publishes Your Subdomains
You got a Let’s Encrypt certificate for jellyfin.example.com. That certificate went into public Certificate Transparency logs, which anyone can search. Let’s Encrypt’s FAQ says certificates are submitted to CT logs after validation. Attackers watch those logs for new hostnames, and a fresh vault.example.com entry gets probed within minutes.
See what you’ve published:
curl -s "https://crt.sh/?q=%25.example.com&output=json" | jq -r '.[].name_value' | sort -u%25 is a URL-encoded %, the wildcard for crt.sh. The name_value field can hold several names separated by newlines, which sort -u handles. crt.sh is a free service and returns a 502 now and then under load, so retry before concluding anything.
The mitigation is a wildcard certificate. A certificate for *.example.com appears in the logs as that single wildcard name, not as every host behind it. Let’s Encrypt issues wildcards, and they require the DNS-01 challenge. The tradeoff is real: a DNS-01 setup needs an API token for your DNS provider, and that token can change your records, so scope it tightly.
A wildcard hides names. The services behind them stay reachable. Someone guessing grafana.example.com still finds it if it’s reachable. Treat hidden names as a speed bump. Obscurity isn’t a control.
Step 6: “No Open Ports” Doesn’t Mean “Not Exposed”
Cloudflare Tunnel and Tailscale Funnel both let you publish a service without forwarding a port. cloudflared opens an outbound connection to Cloudflare, so you can block all inbound traffic at the router. Cloudflare’s docs describe this as connecting resources without a publicly routable IP address. That’s a real improvement. Scanners still sweep your home IP, but nothing answers, and your router has no forwarded port to attack.
But the application is still on the internet. Anyone who knows or guesses the hostname reaches your login page, with the same CVEs and the same weak password. The tunnel removes the forwarded port. The app’s own login is still the only lock.
Tailscale draws the distinction clearly. tailscale serve shares a service with devices in your tailnet. tailscale funnel routes traffic from the broader internet to the service, for anyone, no Tailscale account required. Funnel listens only on ports 443, 8443, and 10000. Check what you’ve turned on:
tailscale funnel statustailscale serve statusIf you tunnel a service, put authentication in front of it that doesn’t depend on the app: Cloudflare Access, an OIDC proxy, or client certificates. Without that layer, the exposure moved from your router to Cloudflare’s edge, and it is still there.
Step 7: Outbound Counts Too
Exposure isn’t only inbound. Your services phone out to update servers, telemetry endpoints, license checks, and webhook URLs. A compromised container with unrestricted egress can reach a command-and-control server, send your data out, or join a botnet without any inbound port at all.
Check what a host is talking to right now:
sudo ss -tnp state established | grep -v '127.0.0.1'You don’t need a full egress firewall on day one. Start with DNS logging at your resolver (Pi-hole or AdGuard Home both work) and look for domains you don’t recognize. Isolated networks for IoT devices and untrusted containers are the next step up.
The Threat Model, in One Table
Fill this out for your own lab. The point is to name each exposure, the likely attacker, and a specific mitigation. The rows below cover the usual suspects.
| Asset | Exposure | Likely attacker | Mitigation |
|---|---|---|---|
| SSH on the router or a server | Port 22 forwarded or IPv6 reachable | Brute-force bots | Keys only, no password auth, or remove the forward and use a VPN |
| Plex, Jellyfin, media server | Forwarded port or UPnP mapping | CVE-hunting botnets, scanners | Reverse proxy with TLS, keep updated, or VPN only |
| Admin UIs (Portainer, Proxmox, router) | Published Docker port or open to LAN-wide access | Credential stuffing, CVE exploits | Never publish. Reach it over WireGuard or Tailscale |
| Docker containers | -p on all interfaces, bypassing UFW | Any scanner | Bind to 127.0.0.1, filter in DOCKER-USER |
| Subdomain list | CT logs, passive DNS | Recon bots | Wildcard cert, don’t rely on hidden names |
| IPv6 hosts | Global addresses, router firewall default | Scanners with IPv6 hitlists | Verify router blocks inbound, scan from outside |
| Tunnelled apps | Public hostname via Cloudflare or Funnel | Credential stuffing, CVE hunters | Auth in front of the app (Access, OIDC) |
| Backups and NAS | SMB or NFS on a forwarded or v6 address | Ransomware operators | Never expose. Offline or immutable copies |
Notice the pattern. Every mitigation is either “close it”, “put a VPN in front”, or “put authentication in front”. None requires a commercial product.
The SumGuy Take
Most home labs should expose one of two things: nothing, or a single reverse proxy on 443. Everything else, including SSH, dashboards, databases, and admin tools, belongs behind a mesh VPN. WireGuard or Tailscale gives you remote access to everything behind it. Your phone becomes a node on the network, and nothing else needs a public address.
If you do need to share a service with other people, run it behind a reverse proxy with TLS, put real authentication in front, keep it patched, and watch the logs. One front door gets monitored. Twelve doors get forgotten.
Run the audit this week. Scan from a phone hotspot, check upnpc -l, grep your docker ps output, and look at your crt.sh results. The first scan usually finds at least one thing you forgot, and fixing it takes five minutes.
Common Questions
Is it safe to port forward Plex?
Plex port forwarding is acceptable if you keep Plex updated and accept the risk. A forwarded port on 32400 is visible to scanners within hours. A safer setup puts Plex behind a reverse proxy on 443 with TLS, or restricts remote access to a VPN. Forward nothing else with it.
Does Cloudflare Tunnel hide my home IP?
Cloudflare Tunnel keeps your home IP out of the DNS record, because traffic reaches Cloudflare’s network first and your origin connects outward. Your IP can still leak through other paths: direct connections, email headers, or other services on the same IP. The tunnel also leaves the application itself publicly reachable.
Do I need a firewall if I’m behind NAT?
Yes, you still need one. NAT blocks unsolicited inbound IPv4 traffic only by accident, and it doesn’t help with UPnP mappings, forwarded ports, or IPv6, where devices get global addresses. Run a host firewall on every server and verify the router’s IPv6 inbound policy with a scan from outside.
Why does Docker ignore my UFW rules?
Docker publishes ports with DNAT rules in the nat table, so packets are rewritten and routed to the container before UFW’s INPUT rules run. UFW never sees them. Bind published ports to 127.0.0.1 or filter traffic in the DOCKER-USER chain instead.