LXC 104 — IP: 192.168.5.109 — admin panel at :81 (Tailscale only)
This page covers how all public traffic reaches the homelab — and how each domain name gets routed to the right service. The mechanism is a reverse proxy. Every concept is explained from scratch.
The problem: one port, many services#
The homelab has multiple services running on different internal ports:
- Nextcloud on
192.168.5.134:80 - Plex on
192.168.5.127:32400 - n8n on
192.168.5.x:5678
The internet only knows one address: the home public IP (82.4.94.229). And only one service can own port 443 (HTTPS) at a time.
Without a solution, you’d either:
- Forward a different port for each service (ugly URLs, security exposure, port management nightmare)
- Or only have one public service
The solution is a reverse proxy.
What a reverse proxy does#
A reverse proxy is a server that sits in front of all your services, listening on port 443. It reads the domain name of each incoming request and routes it to the right backend:
nextcloud.imadinc.com → 192.168.5.134:80
plex.imadinc.com → 192.168.5.127:32400
n8n.imadinc.com → 192.168.5.x:5678The client (browser, app, WebDAV sync) only ever talks to the proxy on port 443. It never sees internal IPs or ports. Clean URLs, single entry point.
The “reverse” in reverse proxy: a regular (forward) proxy sits in front of clients — it makes outgoing requests on their behalf (e.g. a corporate proxy that all employee traffic goes through). A reverse proxy sits in front of servers — it receives incoming requests on their behalf and routes them internally. Same mechanism, opposite direction.
SSL termination#
The reverse proxy handles HTTPS for every service. This is called SSL termination — the proxy terminates the encrypted connection, decrypts the traffic, and forwards plain HTTP to the backend:
Browser → Cloudflare (HTTPS) → NPM (HTTPS, Let's Encrypt cert) → Service (HTTP)Two separate TLS connections happen:
- Browser ↔ Cloudflare: Cloudflare’s edge certificate
- Cloudflare ↔ NPM: Let’s Encrypt certificate that NPM manages
NPM → backend services is plain HTTP on the trusted internal LAN. Backend services don’t need HTTPS configured at all — the proxy handles it.
Cloudflare SSL mode: Full. This means Cloudflare verifies NPM has a certificate (but doesn’t require it to be CA-signed). End-to-end encryption. If set to “Flexible”, Cloudflare would accept HTTP from NPM — that would mean unencrypted traffic on the path from Cloudflare to home.
Let’s Encrypt: why DNS challenge is required#
Let’s Encrypt issues free SSL certificates but must verify you control the domain.
Standard HTTP challenge: Let’s Encrypt visits http://nextcloud.imadinc.com/.well-known/acme-challenge/<token>. NPM creates the challenge file, Let’s Encrypt reads it, ownership confirmed.
Why this fails here: Cloudflare is proxying traffic for imadinc.com. When Let’s Encrypt visits the HTTP challenge URL, Cloudflare intercepts the request before it reaches NPM. Let’s Encrypt is talking to Cloudflare’s edge, not to NPM. The challenge file never gets read from NPM.
DNS challenge: completely different mechanism. Let’s Encrypt asks you to prove ownership by creating a specific TXT record in your DNS zone:
_acme-challenge.nextcloud.imadinc.com → <random-token>Let’s Encrypt queries DNS directly (this bypasses the Cloudflare proxy entirely — it’s a DNS lookup, not an HTTP request). It sees the TXT record, confirms ownership, issues the certificate.
NPM automates this using the Cloudflare API. When a certificate needs to be issued or renewed, NPM uses the API token to create the TXT record, waits for Let’s Encrypt to verify, then deletes the TXT record. All automatic. Certificates renew every 90 days without any manual action.
Cloudflare API token#
The token needs exactly these permissions:
- Zone → DNS → Edit — to create and delete TXT records
- Scoped to
imadinc.comonly — if the token is ever compromised, the attacker can only modify DNS records for this one domain, not touch the Cloudflare account
Create it at: Cloudflare dashboard → My Profile → API Tokens → Create Token → “Edit zone DNS” template.
Nginx Proxy Manager#
NPM is a web UI built on Nginx. Nginx is a high-performance web server and reverse proxy. NPM makes its configuration manageable through a browser UI — no hand-editing of Nginx config files.
The admin panel runs on port 81 (Tailscale-only — never publicly exposed).
Adding a new service#
For every new service deployed on the homelab:
- NPM → Proxy Hosts → Add: enter the domain (
servicename.imadinc.com), forward to internal IP and port - SSL tab: select the wildcard Let’s Encrypt certificate (already issued for
*.imadinc.com) or request a new one - No DNS changes needed — the wildcard A record
*.imadinc.com → 82.4.94.229already covers any new subdomain - No router changes needed — ports 80 and 443 are already forwarded to NPM
Three steps, new service is live with HTTPS.
The Nextcloud redirect loop problem#
This section documents a real debugging problem that took time to trace and is a good example of how reverse proxy assumptions can break things.
NextcloudPi (the preconfigured Nextcloud distribution) was designed to be the public-facing server handling its own HTTPS. Behind NPM, its Apache configuration makes broken assumptions.
What happens: NPM sends plain HTTP to Nextcloud (http://192.168.5.134). Apache inside Nextcloud sees HTTP — not HTTPS — and fires its HTTPS redirect:
Location: https://{SERVER_NAME}/where {SERVER_NAME} is the LXC’s IP (192.168.5.134), not the public domain. NPM receives Location: https://192.168.5.134/, tries to follow it, gets redirected again — ERR_TOO_MANY_REDIRECTS.
Two Apache fixes:
Fix 1 — make the redirect use the real domain, not the server IP:
pct exec 103 -- sed -i \
's|https://%{SERVER_NAME}/$1|https://nextcloud.imadinc.com/$1|' \
/etc/apache2/sites-enabled/000-default.confFix 2 — don’t redirect at all if the request came from a proxy claiming HTTPS via X-Forwarded-Proto header:
pct exec 103 -- sed -i \
's|RewriteCond %{HTTPS} !=on|RewriteCond %{HTTPS} !=on\n RewriteCond %{HTTP:X-Forwarded-Proto} !https|' \
/etc/apache2/sites-enabled/000-default.confNextcloud also needs to know: it’s behind a proxy. Without this, Nextcloud generates internal URLs using the LXC IP instead of the public domain:
pct exec 103 -- sudo -u www-data php /var/www/nextcloud/occ \
config:system:set trusted_proxies 0 --value="192.168.5.109"
pct exec 103 -- sudo -u www-data php /var/www/nextcloud/occ \
config:system:set overwriteprotocol --value="https"
pct exec 103 -- sudo -u www-data php /var/www/nextcloud/occ \
config:system:set overwrite.cli.url --value="https://nextcloud.imadinc.com"occ is Nextcloud’s admin CLI. Use it instead of editing config.php directly — it validates input and runs as the correct user.
Troubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
| Cloudflare 522 | NPM unreachable | Check port forwarding on both routers; check NPM is running |
| SSL handshake failed | Certificate not issued or wrong Cloudflare mode | Check Cloudflare SSL mode is Full; check NPM cert |
ERR_TOO_MANY_REDIRECTS | Backend forcing HTTPS + NPM also forcing | Apply the two Apache fixes above |
| Certificate issuance fails | API token wrong scope or expired | Recreate token: Zone → DNS → Edit, scoped to imadinc.com |
| NPM admin login broken | Stale SQLite database | sqlite3 /data/database.sqlite "DELETE FROM user; DELETE FROM auth;" then stop+start (not restart) |
| 504 gateway timeout | Backend service unreachable | Check the backend LXC/VM is running; check the IP and port in NPM config |