Skip to main content
Nginx Proxy Manager — Public Access

Nginx Proxy Manager — Public Access

Table of Contents

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:5678

The 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:

  1. Browser ↔ Cloudflare: Cloudflare’s edge certificate
  2. 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.com only — 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:

  1. NPM → Proxy Hosts → Add: enter the domain (servicename.imadinc.com), forward to internal IP and port
  2. SSL tab: select the wildcard Let’s Encrypt certificate (already issued for *.imadinc.com) or request a new one
  3. No DNS changes needed — the wildcard A record *.imadinc.com → 82.4.94.229 already covers any new subdomain
  4. 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.conf

Fix 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.conf

Nextcloud 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
#

SymptomCauseFix
Cloudflare 522NPM unreachableCheck port forwarding on both routers; check NPM is running
SSL handshake failedCertificate not issued or wrong Cloudflare modeCheck Cloudflare SSL mode is Full; check NPM cert
ERR_TOO_MANY_REDIRECTSBackend forcing HTTPS + NPM also forcingApply the two Apache fixes above
Certificate issuance failsAPI token wrong scope or expiredRecreate token: Zone → DNS → Edit, scoped to imadinc.com
NPM admin login brokenStale SQLite databasesqlite3 /data/database.sqlite "DELETE FROM user; DELETE FROM auth;" then stop+start (not restart)
504 gateway timeoutBackend service unreachableCheck the backend LXC/VM is running; check the IP and port in NPM config