I kept adding services to my homelab. Each one needed a port. It got ugly.

Grafana on :3000. NetAlertX on :20211. The stock dashboard on :6800. A Flask API for the AC on :8127. The blog on :8080. Every new project meant memorizing another port number, or digging through bookmarks, or just giving up and SSHing in to check docker ps.

The router's port forwarding table looked like a spreadsheet from hell. I had maybe 12 entries at one point. HTTP on 80 forwarded to one thing. HTTPS on 443 forwarded to something else — but only one thing, because you can't forward the same port to two different hosts. Every new HTTPS service needed a non-standard port, which meant remembering https://mydomain.com:8443 or https://mydomain.com:9443 or whatever I'd picked that week.

This doesn't scale. I knew it didn't scale. But I kept putting off fixing it because "setting up a reverse proxy properly" sounded like a weekend project I didn't have.

Phase 1: Nginx Proxy Manager

A friend recommended Nginx Proxy Manager. It's a Docker container that wraps nginx with a web UI — think of it as training wheels for nginx configuration. I was skeptical. I'd written raw nginx configs before. They're not hard. But the convenience of a GUI for something I touch maybe twice a year was appealing.

I spun up the container on what I now call the "services host" — a repurposed workstation that runs Docker for LAN-facing infrastructure. NPM listens on ports 80 and 443, plus its admin panel on :81.

The first proxy host took five minutes. Domain name, target IP, target port. Save. It worked. I pointed grafana.local.curci.cc at the Grafana container and suddenly I wasn't typing port numbers anymore.

Then SSL.

NPM integrates with Let's Encrypt directly. I generated a wildcard certificate for *.curci.cc using DNS challenge — Porkbun, my DNS provider, has an API that NPM can talk to. One certificate covers every subdomain I'll ever create. 90-day auto-renewal. I haven't touched it since.

The Part Where the Certificate Almost Didn't Happen

The wildcard cert was not a one-click operation.

When I first tried to generate it through NPM, the DNS challenge kept failing. NPM would create the TXT record on Porkbun, Let's Encrypt would check for it, and — nothing. Timeout. The record existed in Porkbun's API but hadn't propagated to their public nameservers yet.

I didn't know about propagation delays. I spent an evening deleting and regenerating the certificate, convinced I'd misconfigured the API credentials. Each attempt created new TXT records, which created new propagation delays, which created new failures. I was making the problem worse by trying to fix it.

The solution was embarrassingly simple: wait. Porkbun's DNS propagation takes 60-90 seconds. NPM's default challenge timeout was 30 seconds. I added a 120-second propagation delay in NPM's settings and the certificate issued on the next attempt.

This is the kind of thing that tutorials don't mention. "Generate a wildcard cert" is one bullet point. The reality is two hours of DNS debugging and a one-line config change you only discover after reading a GitHub issue from 2023.

Phase 2: DNS Makes You Feel Stupid

The proxy was working from inside my network. grafana.local.curci.cc resolved fine. From outside — nothing.

I spent two hours debugging nginx configs that were perfectly correct. The problem was DNS. My domain's public DNS pointed *.local.curci.cc to my WAN IP. The router received the request on port 443. But the router didn't know to forward it to the NPM host — because I'd only set up port forwarding for the main curci.cc domain, not the .local subdomain wildcard.

Even after fixing the port forward, there was a subtler problem: internal DNS resolution. Devices inside my LAN trying to reach grafana.local.curci.cc would resolve it to the WAN IP, hit the router, and loop back. This is hairpin NAT — and my router doesn't handle it well. Connections would hang, timeout, or serve the router's own admin page instead of Grafana.

The fix was split DNS. OPNsense, my router OS, runs Unbound DNS. I added host overrides so that *.local.curci.cc resolves to the NPM host's LAN address when queried from inside the network. External queries still resolve to the WAN IP. Two different answers for the same domain, depending on where you're asking from. It's a hack, but it's been running for over a year with zero issues.

Phase 3: The Double Reverse Proxy

Here's where it got weird.

Not everything goes through NPM. The blog — this blog, blog.curci.cc — runs on a separate LXC container with its own nginx reverse proxy. I call it "nginx-reverse2" because it's the second nginx instance in the stack. It handles WAN-facing services that need their own TLS termination or custom nginx configs that NPM's UI can't express.

So the traffic flow for a request to blog.curci.cc is: DNS resolves to my WAN IP → router forwards port 443 to nginx-reverse2 → TLS terminated with the wildcard cert → proxied to the blog LXC. For grafana.local.curci.cc: internal DNS resolves to NPM's LAN address → NPM terminates TLS → proxied to Grafana.

Two reverse proxies. One for WAN, one for LAN. They don't talk to each other. They don't need to.

Is this elegant? No. The "right" architecture would be a single reverse proxy that handles everything, with internal vs external access controlled by firewall rules or nginx allow/deny directives. But I built this incrementally. NPM handles the LAN services because it was easy. nginx-reverse2 handles the WAN services because it was already there when the blog needed a home. Merging them into one proxy would mean migrating a dozen proxy hosts and revalidating every SSL cert. The payoff isn't there.

I'm not running a SaaS platform. I'm running a homelab. "Works and I understand it" beats "architecturally pure."

Why Not Cloudflare Tunnels?

People ask me this. Cloudflare Tunnels would eliminate the port forwarding, the split DNS, the SSL certificates — all of it. One cloudflared container and every service is available on the public internet with Cloudflare's edge handling TLS.

I don't use them for the same reason I self-host at all: I don't want my traffic going through someone else's infrastructure if I can avoid it. Cloudflare decrypts your traffic at their edge, inspects it, and re-encrypts it to your origin. For a homelab running personal dashboards and hobby projects, the privacy trade-off isn't worth the convenience. I'd rather manage my own certs.

This is not a principled stance against Cloudflare. They provide genuine value for production workloads. But for a homelab where I control the full stack and the stakes are low, adding a man-in-the-middle — even a trusted one — feels wrong.

I realize this makes me sound like one of those "I compile my own kernel" people. I don't compile my own kernel. But I do terminate my own TLS.

What's Running Now

Behind the two proxies, I currently host:

  • This blog (publite.me on an LXC)
  • Grafana dashboards (homelab monitoring)
  • NetAlertX (network device detection)
  • Stock Analysis Hub (Flask dashboard)
  • Midea AC controller (Flask API)
  • A few experimental apps that come and go

All on one public IP. All on standard ports. All with valid TLS.

Adding a new service takes maybe three minutes: spin up the container, add a proxy host in NPM or nginx-reverse2, add a DNS override if it's LAN-only. The wildcard cert covers it automatically. No new port forwards on the router. No memorizing port numbers.

I should probably consolidate the two proxies someday. I should probably add monitoring for certificate expiry. I should probably document the DNS overrides somewhere other than the OPNsense UI. But for now, it works. My homelab has been running this setup since late 2025 without a single SSL expiry, without a single proxy misroute, and without me having to look up a port number.

For a homelab, that's good enough.