Skip to content
HomeTechOps

Backups & Storage

Home reverse proxy: NPM, Caddy, Traefik, or Pangolin

Once you run 4+ self-hosted services at home, manually managing TLS certs and remembering port numbers becomes the worst part of operating the stack. A reverse proxy fixes both: services live behind nice URLs like `jellyfin.example.com`, get automatic Let's Encrypt certs, and you stop port-forwarding individual services to the internet. The decision is which proxy — Nginx Proxy Manager (NPM, click-through GUI, beginner-friendly), Caddy (single binary, Caddyfile config-as-code, lightest), Traefik v3 (Docker-label-driven, container-auto-discovery), or Pangolin (newer, built-in identity layer, runs as Cloudflare Tunnel alternative). Most operators start with NPM and migrate when they outgrow GUI clicks.

Reverse proxy topology and the mixed-platform pattern

Reference images and diagrams. Click any image to view full resolution.

Three-tier home network diagram showing Trusted / IoT / Guest VLANs separated by router. Used here to illustrate where the reverse proxy sits in the topology: typically on the Trusted VLAN, with services on other LAN IPs reached internally by the proxy.
Original concept diagram (not vendor copyright). The reverse proxy sits on the Trusted VLAN (or its own internal network), with port 80/443 forwarded to it from the router. It reaches services on other LAN IPs (Synology, Unraid, Raspberry Pi) by their static internal IPs. Public traffic only enters via the proxy, never directly to the upstream services.

Who this is for

Operators running 4+ self-hosted services at home (Plex, Jellyfin, Home Assistant, Nextcloud, Immich, *arr stack, Vaultwarden, Frigate) who want them behind nice URLs with automatic TLS, without manually managing certs per service.

Outcome

A working reverse proxy serving all your home services under wildcard `*.example.com`, with the right tool for your operating style (NPM for GUI, Caddy for config-as-code, Traefik for Docker-label-driven, Pangolin for CGNAT). Public services have SSO in front; admin services stay on Tailscale.

Required inputs

  • A domain you control (Cloudflare-hosted DNS recommended for DNS-01 wildcard certs).
  • One always-on host on the LAN to run the reverse proxy (Unraid most common; dedicated Raspberry Pi 4/5 is the second-most-recommended).
  • Port 80 + 443 forwarded from the router to the proxy host (unless behind CGNAT — then Pangolin or Cloudflare Tunnel replaces port forwarding).
  • List of services to put behind the proxy, with their LAN IPs and ports.
GuideFollow in order

Step-by-step procedure

1

Decide which proxy fits your operating style

Do: Match the tool to your style (2026 versions: NPM v2.14, Caddy v2.11, Traefik v3.7): GUI + visual debugging → NPM; config-as-code with automatic HTTPS by default → Caddy; Docker-label auto-discovery → Traefik; CGNAT / no port-forward → Pangolin (self-hosted) or Cloudflare Tunnel. A tunnel solves reachability while a reverse proxy solves hostname routing + TLS on your own network — they compose. Most home operators start with NPM.

Expected result: Choice made; matches your comfort with text config vs UI clicking.

If not: If unsure, default to NPM — the migration path NPM → Caddy is straightforward; reverse migration is painful.

2

Set up DNS + wildcard Let's Encrypt cert (DNS-01)

Do: Move domain DNS to Cloudflare. Create an API token (dash.cloudflare.com > My Profile > API Tokens) scoped `Zone:Zone:Read + Zone:DNS:Edit`. Configure your proxy's DNS-01 provider with the token; request the `*.example.com` cert. DNS-01 is required for a wildcard AND for any internal-only service with no public port 80. Caddy needs an xcaddy build with the Cloudflare DNS module; Traefik uses the cloudflare provider + `CF_DNS_API_TOKEN`. Test issuance against the Let's Encrypt staging environment first.

Expected result: Cert issued for `*.example.com`, renewal scheduled. The proxy can serve any new subdomain without requesting a new cert.

If not: If cert request fails with 'No valid A/AAAA records', the API token lacks Zone scope. Don't iterate against production LE (you'll hit the 5-per-identifier-set/week limit) — use staging. And automate renewal: LE is trending to shorter-lived certs (the opt-in 6-day profile is GA; the 90-day default is heading toward 45).

3

Forward router port 80 + 443 to the proxy host

Do: Router admin > Port Forwarding > add 80 → proxy_host:80 and 443 → proxy_host:443. Reserve the proxy host's IP via DHCP reservation so it doesn't change.

Expected result: From outside cellular, `https://test.example.com` reaches the proxy (404 or similar response — meaning the request landed).

If not: If port 80 from outside is unreachable, you may be CGNAT'd or your ISP blocks inbound 80/443 — see /fix/cgnat-home-access-no-public-ip.

4

Add services to the proxy one at a time, simplest first

Do: Start with a stateless service (Uptime Kuma, Homepage, Grafana). Add it as a proxy host: `service.example.com` → `192.168.1.X:port`. Test from cellular before moving to the next.

Expected result: Service reachable at `https://service.example.com` with the wildcard cert valid (no browser warnings).

If not: If browser shows cert warning, the wildcard cert isn't being served — recheck the proxy's TLS config; verify cert is for `*.example.com` not just `example.com`.

5

Add tricky services with their specific quirks

Do: Plex: enable WebSocket pass-through; set Plex's Custom Server Access URLs. Home Assistant: configure `http.trusted_proxies` in configuration.yaml; WebSocket read_timeout 0. Nextcloud: client_max_body_size 16G + timeouts 3600s. Jellyfin: WebSocket + disable buffering. Immich: max body 50GB.

Expected result: Each service works through the proxy without errors. WebSocket-dependent features (Plex sync, HA real-time, Jellyfin SyncPlay) function.

If not: Service-specific failures usually trace to WebSocket pass-through, body-size limits, or X-Forwarded headers. See the per-service section.

6

Set up split-horizon DNS for internal-LAN access

Do: On your self-hosted DNS server (Pi-hole, AdGuard Home, Technitium), add wildcard A record `*.example.com → proxy_LAN_IP`. Internal clients hitting `jellyfin.example.com` go directly to the proxy on the LAN, never traversing WAN.

Expected result: Phone on home Wi-Fi loads `https://jellyfin.example.com` and DNS resolves to the proxy LAN IP, not the WAN IP.

If not: If clients still resolve to WAN IP, the local DNS server isn't being used — check DHCP issued DNS, or set static DNS on each client.

7

Add SSO in front of sensitive services (Authentik or Authelia)

Do: Install Authentik (new builds) or Authelia (minimal). Create one provider + one application for a test service. Wire the proxy to use ForwardAuth pointing at the SSO endpoint. Scope the trusted-proxy CIDR so the auth server only trusts X-Forwarded-* from the proxy's IP (otherwise clients spoof their identity). Add CrowdSec — it reads the proxy access logs, enforces via a bouncer, and fails open (if it goes down, the proxy still routes).

Expected result: Hitting the protected subdomain redirects to the SSO login; after login, the proxied service is reachable; CrowdSec shows decisions in its metrics.

If not: If a redirect loop happens, check the ForwardAuth header pass-through — the common bug is the proxy not forwarding the auth cookie back, or the auth server not trusting the proxy's X-Forwarded-For.

8

Keep admin services on Tailscale only — don't proxy them

Do: Sonarr, Radarr, qBittorrent, Proxmox UI, Container Manager admin UIs: leave LAN-only and reach them via Tailscale, not the public proxy. Even with SSO in front, admin UIs are too high-value to be reachable from a public hostname.

Expected result: These services have no public hostname; you reach them by Tailscale IP (or LAN IP from inside).

If not: If you've already exposed admin UIs publicly, set up Tailscale access and remove the public hostname.

Commands and settings paths

Verify wildcard cert installed correctly

openssl s_client -connect service.example.com:443 -servername service.example.com 2>/dev/null | openssl x509 -noout -subject -dates

Where: From any internet-reachable workstation.

Expected: Subject shows `CN = *.example.com`; notAfter date is 60+ days in the future after a renewal.

Failure means: Cert is per-host (CN = service.example.com) instead of wildcard, OR renewal failed and cert is expiring.

Safe next step: Re-request wildcard via the proxy's DNS-01 config; check API token scope; check cert renewal job in proxy logs.

Check proxy can reach upstream service

From proxy host: `curl -k https://192.168.1.X:port/`

Where: SSH to the proxy host.

Expected: Service responds (200 / 302 / login page). Confirms the upstream is reachable from the proxy.

Failure means: Service is down, wrong IP/port, or firewall blocking proxy-to-service traffic.

Safe next step: Verify service is running; confirm IP + port; check any LAN firewall rules.

Verify DNS-01 TXT record propagation

dig TXT _acme-challenge.example.com @1.1.1.1 (or nslookup -type=TXT)

Where: Any machine; check against a public resolver.

Expected: The ACME challenge TXT record is present and matches what the proxy set.

Failure means: If the TXT isn't visible publicly, DNS-01 issuance fails — token lacks scope, propagation lag, or the internal resolver can't see the public record.

Safe next step: Set public resolvers (1.1.1.1 / 8.8.8.8) in the proxy's ACME config; widen the Cloudflare token scope; wait for propagation.

Debug a 502 by curling the backend from inside the proxy container

docker exec <proxy> wget -qO- --timeout=5 http://backend:8080 (or curl)

Where: On the Docker host.

Expected: The backend responds — proving the proxy can reach the upstream over the shared Docker network.

Failure means: No response = wrong container name, wrong INTERNAL port (not the published host port), or proxy + backend not on the same Docker network.

Safe next step: `docker network inspect <net>` to confirm shared membership; `docker network connect <net> <container>`; fix the upstream name/port.

Check for a redirect loop / wrong SNI cert

curl -vI https://service.example.com (watch for repeated 301/308); openssl s_client -connect host:443 -servername host

Where: Any internet-reachable workstation.

Expected: A single final 200/302 and the correct wildcard cert for the SNI host.

Failure means: Repeated 301/308 = redirect loop (often Cloudflare 'Flexible' SSL + an origin HTTPS redirect). Wrong/expired cert = missing -servername / SNI misconfig.

Safe next step: Set Cloudflare SSL to Full (Strict); ensure the origin honors X-Forwarded-Proto; always pass -servername when testing.

Evidence to record

  • Proxy config (Caddyfile, Traefik labels, NPM exported JSON) committed to Git.
  • Cloudflare API token + zone scope screenshotted (mask the token itself).
  • Test results: each service reachable via public URL with wildcard cert; SSO works on protected ones; split-horizon DNS resolves internally.
  • Cert renewal date noted (Let's Encrypt 90-day cycle; renew at 60 days).

Common mistakes

  • Exposing admin UIs (Sonarr, Radarr, Proxmox, NAS admin) through the public proxy — even with SSO in front, the attack surface is too high.
  • Exposing the proxy's OWN admin surface publicly — Traefik dashboard with `api.insecure=true`, or NPM's admin on port 81. Keep those LAN/VPN-only.
  • Trusting X-Forwarded-For from everywhere — backends and auth (Authelia/Authentik) must only trust proxy headers from the proxy's IP/CIDR, or clients spoof their IP and bypass IP rules / poison fail2ban.
  • Mounting the Docker socket read-write into the proxy — use a read-only socket-proxy (e.g. tecnativa/docker-socket-proxy) + `no-new-privileges` so a proxy compromise isn't root over Docker.
  • Per-service certs instead of a wildcard — hammering issuance hits Let's Encrypt's 5-per-exact-identifier-set/week limit; test against the LE staging environment and use a wildcard.
  • Cloudflare 'Flexible' SSL + an origin that redirects HTTP→HTTPS — an infinite redirect loop. Use Full (Strict) and give the origin a real cert.
  • Forgetting WebSocket upgrade headers on raw nginx — real-time features (HA, Jellyfin SyncPlay) break; Caddy/Traefik handle it automatically.
  • Forgetting the `trusted_proxies` config in Home Assistant — HA refuses external requests with HTTP 400 until you set `use_x_forwarded_for: true` + the proxy IP.
  • Enabling HSTS with `includeSubDomains`/`preload` before HTTPS is rock-solid — it locks browsers out of any HTTP subdomain for the max-age (often a year), and preload is hard to reverse.
  • Routing Nextcloud large-file sync through Cloudflare Tunnel (100 MB cap) instead of through the home reverse proxy directly.
  • Running the proxy on the same box as Plex with `network_mode: host` on both — they fight over ports 80/443.
  • Not rebuilding Caddy with the DNS plugin (the stock image has no Cloudflare module) — wildcard/internal DNS-01 certs are then silently impossible.

Stop points

  • Stop before exposing any service publicly without an SSO/auth layer if the service has sensitive controls (NAS admin, smart-home control of locks/cameras, file deletion).
  • Stop before counting on Cloudflare Tunnel for personal video streaming (Plex/Jellyfin) — CDN terms restrict it; multiple operators report account warnings.
  • Stop before exposing the proxy's own dashboard/admin (Traefik dashboard, NPM port 81) to the internet — keep it LAN/VPN-only behind auth.
  • Stop before hammering Let's Encrypt during setup — test against the staging environment first so you don't burn the 5-per-identifier-set/week production limit.
  • Stop before migrating production traffic from NPM → Caddy without keeping NPM running on an alternate port for a week as fallback.

Last reviewed

2026-05-18

Source-backed checks

HomeTechOps turns official docs and conservative safety rules into a shorter runbook. These links are the source trail for the page direction.

Tailscale: Connect to network attached storageUsed for VPN-style NAS remote-access planning instead of exposing admin or file-sharing ports.Caddy: Automatic HTTPSUsed for Caddy v2.11's automatic-HTTPS-by-default, the Caddyfile reverse_proxy directive, and the .ts.net special-case; DNS plugins need an xcaddy custom build.Traefik Proxy documentationUsed for Traefik v3.x label-driven Docker routing (routers/services/middleware), exposedbydefault=false, and the api@internal dashboard handling.Nginx Proxy ManagerUsed for NPM (GUI on nginx + certbot): beginner-friendly but config lives in a DB (no git), admin runs on port 81 (keep LAN/VPN-only).Let's Encrypt: Rate limitsUsed for cert-issuance rate-limit math: 5 duplicate certs per identical SAN set per 7 days, 50 certs per registered domain per 7 days, 300 new orders per account per 3 hours, 5 failed validations per identifier per hour. Wildcard avoids the duplicate limit during iteration.Let's Encrypt: Challenge types (HTTP-01 vs DNS-01)Used for why DNS-01 is required for wildcard certs and for internal-only services with no public port 80; HTTP-01 needs port 80 reachable.Let's Encrypt: 6-day and IP certificates general availability (Jan 2026)Used for the 2026 cert landscape: opt-in ~6-day short-lived certs via the 'shortlived' profile, IP-address certs, and the 90-to-45-day default-lifetime trajectory — making automated renewal essential.CrowdSec: behavioral intrusion preventionUsed for the modern fail2ban alternative that reads proxy access logs and enforces via a bouncer; fails open (if CrowdSec is down, the proxy still routes).Authelia: security measures (forwardAuth + X-Forwarded-For)Used for the forwardAuth SSO pattern and the critical X-Forwarded-For trust trap — backends/auth must only trust proxy headers from the proxy's IP, or clients spoof their IP.Cloudflare: Cloudflare TunnelUsed for cloudflared install, Public Hostnames + Access policies, account limits (1000 tunnels, 100 MB upload cap on free/Pro), and SMB-over-Tunnel limitations.Cloudflare: Zero Trust / Access plansUsed for the free Cloudflare Access tier (up to 50 users) and the $7/user pay-as-you-go above it.Cloudflare: Updated self-serve Terms of ServiceUsed for the media-streaming restriction — the old section 2.8 HTML-only clause moved to the CDN Service-Specific Terms, so Plex/Jellyfin streaming through the CDN/Tunnel is still disallowed off Cloudflare-hosted products.Pangolin: self-hosted tunneled reverse proxyUsed as the 2025-2026 self-hosted Cloudflare-Tunnel alternative (identity-aware WireGuard reverse proxy on your own VPS).

Get the deal & firmware alerts

Home Stack Field Notes: NAS deals, firmware changes worth acting on, restore-test reminders, and new decision guides — plus the capacity & backup sizing cheatsheets from our calculators. Unsubscribe anytime.