8 hours ago8 hr A lot of the small offices we pick up have a firewall with a pile of port forwards on it. 8443 goes to one server, 8080 to another, 9000 to a third, and nobody remembers which is which. Each of those services faces the internet with its own TLS settings, or none. A reverse proxy cleans that up. You get one public IP, only ports 80 and 443 open, routing by hostname, and certificates managed in one place. We use Nginx Proxy Manager (NPM) for a lot of this because it puts a usable GUI on top of plain nginx, which helps when the next tech to touch it isn't an nginx person. Below is how we set it up, plus the client IP and redirect loop problems that catch everyone. What needs to be in place A small Linux VM or container host with Docker, on a network segment that can reach your internal apps. One public IP with ports 80 and 443 forwarded (or 1:1 NATed) to the proxy, and nothing else. DNS records for each hostname (for example app.example.com) pointing at the public IP. For DNS-01 certificates, an API token from your DNS provider, scoped to the zones you need. Getting NPM running services: npm: image: jc21/nginx-proxy-manager:latest restart: unless-stopped ports: - "80:80" - "443:443" - "81:81" # admin UI - do NOT expose this publicly volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt Once it's in production, pin a specific image version so an update doesn't surprise you. Port 81 is the admin UI, so restrict it with the host firewall to your management network or VPN. Log in, change the default credentials right away, and back up the data and letsencrypt folders. One proxy host per app For each app, create a proxy host with the public hostname and the internal scheme, IP and port (for example http://10.10.50.20:8080), and turn on "Block common exploits." Turn on WebSockets support for anything with live updates, like dashboards, remote consoles or chat. NPM routes on the Host header, so dozens of apps can share the same IP and port 443. Lock the backends down as well. The app servers should only accept connections from the proxy's IP. Otherwise anyone on the internal network can walk right around whatever the proxy enforces. Certificates, HTTP-01 or DNS-01 With HTTP-01, Let's Encrypt connects to http://hostname/.well-known/acme-challenge/. Port 80 has to be reachable from the internet and the DNS record has to point at the proxy. It's simple, but you get no wildcards, and it won't work for hostnames that are internal-only. With DNS-01, NPM creates a TXT record through your DNS provider's API. That works for wildcards (*.example.com), for internal-only hosts, and when port 80 is blocked. The cost is an API token sitting on the proxy, so scope it to DNS edit on the specific zone. Then turn on "Force SSL," and once you're sure everything works over HTTPS, turn on HSTS too. Getting the real client IP to the backend Skip this and every request in the app's logs comes from the proxy's IP. That wrecks your auditing and rate limiting, and tools like fail2ban will end up banning the proxy, which means banning everyone behind it. NPM sends X-Forwarded-For, X-Real-IP and X-Forwarded-Proto by default. The backend has to be told to trust those headers only from the proxy. If the backend runs nginx, it looks like this. # On the backend server, in the http or server block set_real_ip_from 10.10.50.5; # the NPM host's IP real_ip_header X-Forwarded-For; real_ip_recursive on; If something else sits in front of NPM, such as a CDN or cloud proxy, NPM also needs set_real_ip_from entries for that provider's published ranges and the provider's client IP header. Add them in the proxy host's Advanced tab or a custom include. Never trust X-Forwarded-For from just anywhere, because clients can set it to whatever they like. Apps that aren't behind nginx have their own setting, usually called "trusted proxies" (Laravel, Nextcloud, and many Node and Python frameworks). Set it to the proxy IP, not to "everything." The "too many redirects" loop The browser talks HTTPS to NPM, and NPM usually talks plain HTTP to the backend. An app that enforces HTTPS sees a plain HTTP request and redirects to HTTPS. The browser comes back through the proxy, the app sees HTTP again, and around it goes until the browser gives up with "too many redirects." You fix it by making the app honor X-Forwarded-Proto: https from the trusted proxy. A few examples: In Nextcloud, set overwriteprotocol to https and fill in trusted_proxies in config.php. In Django, set SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https"). In Laravel, configure the TrustProxies middleware with the proxy IP. For anything else, set the app's "base URL" or "external URL" to https://app.example.com. Testing it from outside # Certificate and redirect chain from outside your network curl -sI http://app.example.com | head -n 5 curl -svI https://app.example.com 2>&1 | grep -Ei "subject|expire|HTTP/" # Confirm only 80/443 are open on the public IP (run from outside) nmap -Pn -p 1-10000 192.0.2.10 Run these from outside your network. Then load the app, sign in, and check that its access log shows your real public IP and not the proxy's. Where this has bitten us A CDN in "flexible" mode (HTTPS to the browser, HTTP to the origin) in front of an NPM host set to Force SSL gives you a redirect loop before the app is even involved. Use full or strict mode at the CDN. If the backend is HTTPS with a self-signed cert, set the scheme to https in NPM. Some apps also insist on a matching Host header or reject the proxy. When uploads fail, it's usually nginx's default body size limit, which is small. Add client_max_body_size in the Advanced tab for file-heavy apps. Repeatedly failing HTTP-01 validations while you're debugging DNS can hit Let's Encrypt rate limits. Fix DNS first, then request the cert. Putting an admin login page behind a proxy doesn't make it safe. Add NPM access lists, an SSO layer or VPN-only access for admin interfaces. Once it's set up right, the proxy is about the most boring box we run, and I mean that as a compliment.
Create an account or sign in to comment