Hosting a hardened website from home with zero open ports
The site you’re reading is served from a server in my home, but my router has no port forward for it and the server’s firewall has no rule for it. Here’s how.
Let the server dial out instead of letting the internet dial in
A Cloudflare Tunnel (cloudflared) on the server opens an outbound connection to Cloudflare. Visitors connect to Cloudflare over HTTPS, and Cloudflare passes requests down that tunnel to a web container listening only on 127.0.0.1. There’s no inbound port to scan, no port forward on the router, and no firewall rule to get wrong.
Visitor ──HTTPS──▶ Cloudflare ◀──outbound tunnel── cloudflared ──▶ 127.0.0.1:8088 ──▶ nginx container
A web server with as few privileges as possible
nginxinc/nginx-unprivilegedrunning as UID 101, never rootread_only: trueroot filesystem, with smallnoexectmpfs mounts for cache and temp filescap_drop: ALLandno-new-privileges- Memory, CPU and process limits, so a bug can’t take down the rest of the homelab
- The site files are mounted read-only, the server only answers GET/HEAD, and hidden files like
.gitor.envreturn 404
A strict Content Security Policy
Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: DENY, a Referrer-Policy, a Permissions-Policy and Cross-Origin-Opener-Policy. The CSP starts from default-src 'none' and allows only what the site needs:
default-src 'none'; script-src 'self'; style-src 'unsafe-inline';
img-src 'self' data:; font-src 'self'; base-uri 'none';
form-action 'none'; frame-ancestors 'none'
Because inline scripts are blocked, even this site’s scroll animations live in a small same-origin reveal.js. And it’s progressive enhancement: with JavaScript off, every word on the page is still visible.
Checking from the outside
| Check | Result |
|---|---|
| IPv4, 1,007 TCP ports (incl. 22, 80, 443, 8080, 8443) | 0 open |
| IPv6, both server addresses, 16 common ports each | All blocked (timed out) |
I used public outside-in checkers, each validated against a host with known open ports, because my scanning machine’s proxied connection gave misleading nmap results. To be precise: I ran this check earlier the same day, before the site went live. The tunnel only makes outbound connections, so it adds no inbound port. It’s also not a full 65,535-port or UDP scan.
The sanitized compose file, nginx config and an example tunnel config are in github.com/oemarino/homelab.
What I learned
- The best open port is no open port. An outbound tunnel removes a whole class of exposure instead of trying to defend it.
- Least privilege applies to containers too. Non-root, read-only, no capabilities and resource limits cost nothing for a static site.
- Security constraints shape design. A strict CSP pushed me toward cleaner code with no inline scripts and graceful no-JS fallbacks.
- Measure what you claim, and state the limits. “Zero open ports” is backed by a validated scan, with its caveats written down.