So you’ve got a handful of services running on your home server — maybe Jellyfin, Home Assistant, a wiki — and you’re tired of remembering port numbers or hoping your roommates won’t fat-finger :8096 into the address bar. A reverse proxy solves that, but raw nginx configs feel like a part-time job you never applied for. Enter Nginx Proxy Manager: a Docker‑friendly GUI that wraps nginx, handles Let’s Encrypt certificates, and keeps your homelab accessible over clean https:// URLs without a single server_name directive written by hand. Here’s how to set it up, get free SSL, and avoid the common traps that turn a Saturday project into a Sunday troubleshooting session.
Why Nginx Proxy Manager Beats Raw Config Files
A reverse proxy is the right tool for exposing multiple internal services on a single IP address. It terminates TLS, routes requests by hostname, and keeps your internal services isolated from the internet’s direct gaze. The problem is that hand‑crafting nginx server blocks requires you to understand directive inheritance, SSL stapling, and the subtle difference between proxy_pass with and without a trailing slash. For a home user who just wants to reach jellyfin.internal from the living room, that’s overkill.
Nginx Proxy Manager (NPM) gives you a web UI that generates those configs for you. It bundles a Let’s Encrypt client with support for HTTP‑01 and DNS‑01 challenges, so you get free certificates in a few clicks. Compared to Caddy, which does automatic HTTPS beautifully but forces you to love its Caddyfile syntax, NPM offers a visual dashboard that non‑terminal‑dwellers actually enjoy. Traefik is more powerful, but its label‑driven configuration and YAML sprawl can make a simple home setup feel like deploying a microservices mesh. NPM sits in the sweet spot: a GUI, sensible defaults, and no YAML‑induced rage. The trade‑off is that you lose some advanced flexibility — no custom map blocks, no fine‑grained Lua scripting — but for 90% of homelab use cases it’s more than enough. If you ever outgrow it, the underlying nginx configs it writes are human‑readable and can be exported.
What You Need Before You Start
The non‑negotiable prerequisites are a machine that runs Docker (or Docker Compose), a domain name you control, and a basic understanding of DNS and port forwarding. I’ll assume you’re running a Linux host, but the steps work on any Docker‑capable system, including a Raspberry Pi or a Synology NAS.
- A Docker host with a static internal IP. Your services and NPM will live on the same network. The host’s IP should be fixed — either via DHCP reservation or static assignment — so your router can forward ports reliably.
- A domain name (or a free dynamic DNS hostname). Let’s Encrypt requires a publicly resolvable FQDN. You can buy a cheap domain or use a free dynamic DNS service like DuckDNS. For internal‑only services, you can still get certificates using DNS‑01 challenges without exposing ports; I’ll cover that later.
- Port forwarding on your router. For HTTP‑01 challenges and public access, forward ports 80 (HTTP) and 443 (HTTPS) from your router’s WAN interface to the Docker host’s IP. If your ISP blocks port 80, you’ll need to use DNS‑01 challenges exclusively. Leave port 81 for the NPM admin UI — but don’t forward that to the internet unless you enjoy reading unauthorized access logs.
- Docker and Docker Compose installed. NPM runs as a container. If you haven’t installed Docker yet, the official convenience script (
get.docker.com) works, but I recommend adding your user to thedockergroup to avoid constantsudo.
One more thing: decide whether you want NPM to manage certificates for services that are only accessible inside your LAN. If so, you’ll need a DNS provider that supports API access for DNS‑01 challenges (Cloudflare, DigitalOcean, Route53, etc.). NPM’s GUI integrates with several providers out of the box.
Installing Nginx Proxy Manager with Docker Compose
I prefer a docker-compose.yml file because it keeps the container’s configuration explicit and reproducible. Create a directory for NPM — say /opt/npm — and drop this file inside:
version: '3.8'
services:
app:
image: 'jc21/nginx-proxy-manager:latest'
restart: unless-stopped
ports:
- '80:80' # HTTP
- '443:443' # HTTPS
- '81:81' # Admin UI
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
environment:
- DISABLE_IPV6=true # optional, disable if you don't have IPv6
The volume mounts are critical: ./data holds the SQLite database and nginx configs, ./etc/letsencrypt stores certificates. Keep these on persistent storage. If you’re running on a NAS, map them to a folder outside the container’s ephemeral layer.
Start it with docker-compose up -d. After a few seconds, point a browser at http://<host-ip>:81. The default login is [email protected] with password changeme. You’ll be forced to change them immediately — do that before you forget.
Your First Proxy Host: Exposing a Service
Before we touch SSL, let’s get a basic HTTP proxy working to understand the flow. I’ll use Home Assistant as an example, running on the same Docker host at http://192.168.1.50:8123.
In the NPM UI, go to Hosts > Proxy Hosts and click Add Proxy Host.
- Domain Names: Enter the FQDN you want, like
homeassistant.mydomain.com. You can add multiple names that point to the same backend. - Scheme:
http(the backend service is not HTTPS — NPM will handle TLS termination). - Forward Hostname / IP:
192.168.1.50(the Docker host’s IP, or the container’s name if NPM is on the same Docker network and you use the container name). - Forward Port:
8123. - Block Common Exploits: Enable. This adds a few basic nginx rules that block SQL injection and path traversal attempts. It’s not a WAF, but it’s better than nothing.
- Websockets Support: If the service uses websockets (Home Assistant does), toggle it on. This adds the necessary
UpgradeandConnectionheaders.
Save it. Now, if you have port 80 forwarded and DNS pointing homeassistant.mydomain.com to your public IP, you should be able to reach the service via HTTP. But we’re not done — HTTP is unacceptable.
Free SSL with Let’s Encrypt
NPM’s real value is its one‑click SSL integration. On the same proxy host entry, switch to the SSL tab.
- SSL Certificate: Choose Request a new SSL Certificate. This triggers the built‑in Let’s Encrypt client.
- Force SSL: Enable. This redirects all HTTP requests to HTTPS and sets the HSTS header.
- HTTP/2 Support: Enable. Modern browsers benefit, and nginx handles it without extra effort.
- Email Address for Let’s Encrypt: Enter a valid email. Let’s Encrypt will send expiry notices here, and it’s required for the ACME account.
- I Agree to the Let’s Encrypt Terms of Service: Check it.
Click Save, and NPM will attempt the HTTP‑01 challenge. It spins up a temporary webroot on port 80, Let’s Encrypt’s servers verify domain control, and a certificate is issued. This takes about 15–30 seconds. If it fails — usually because port 80 isn’t reachable from the internet — check your port forwarding and ISP restrictions.
Once the certificate is issued, your service is now accessible over https://homeassistant.mydomain.com. NPM will automatically renew the certificate 30 days before expiry. You can watch the renewal logs under SSL Certificates.
When HTTP‑01 Fails: DNS‑01 Challenge
If your ISP blocks port 80, or you’re issuing a certificate for a domain that points to an internal IP (like *.home.arpa), you need DNS‑01 validation. NPM supports this via API credentials for several DNS providers.
Before you can use it, you need to add DNS challenge credentials. Go to SSL Certificates > Add Let’s Encrypt Certificate. Instead of a proxy host, you’ll create a standalone certificate. Enter the domain(s), select Use a DNS Challenge, pick your provider from the dropdown, and provide the API token. Cloudflare tokens need Zone:Read and DNS:Edit permissions. Save, and NPM will create TXT records to prove ownership, then clean them up.
You can then assign this certificate to any proxy host by selecting it from the SSL Certificate dropdown instead of requesting a new one. This is also the method to use for wildcard certificates (*.mydomain.com), which cover all your subdomains with a single cert.
Accessing Internal Services Securely Without Exposing Ports
Here’s a pattern I use for services that never need to be reached from the internet — like a local wiki or a Proxmox VE interface. I still want HTTPS and a proper hostname, but I don’t want to forward any ports. The trick: use a DNS‑01 wildcard certificate, and run a local DNS resolver (like Pi‑hole or Unbound) that resolves wiki.mydomain.com to NPM’s internal IP.
The flow: your browser on the LAN queries the local DNS, gets NPM’s internal IP, connects to NPM on port 443, and NPM proxies to the backend. Since the certificate was issued via DNS‑01, Let’s Encrypt never needed to reach your network. No port forwards, no public exposure. The only requirement is that NPM’s HTTPS port is accessible on the LAN.
Common Pitfalls and Troubleshooting
Even with a GUI, things can go wrong. Here’s what I’ve tripped over and how to fix it.
Proxy Host returns 502 Bad Gateway. This means NPM can reach the backend, but the backend rejected the connection or didn’t respond correctly. Check that the Forward Hostname and Port are correct. If NPM and the service are on the same Docker host but not on the same Docker network, localhost or 127.0.0.1 won’t work — use the host’s LAN IP or the container’s name on a shared network. If you’re using a container name, ensure both containers are on the same user‑defined bridge network; the default bridge network doesn’t support automatic DNS resolution.
SSL certificate keeps failing. For HTTP‑01, verify that http://<your-domain>/.well-known/acme-challenge/ is reachable from the internet. NPM creates a temporary file there during validation. If you see a 404, your port forwarding might be misdirected or a firewall is blocking the request. For DNS‑01, check the API credentials and that the DNS zone is correct. Some providers have propagation delays; NPM waits a sensible amount of time, but if your TTL is set to something absurd like an hour, you might need to lower it before requesting.
Websockets not working. The toggle in NPM adds proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade";. If the service still fails, check that the backend isn’t doing its own SSL termination (double encryption), and that your firewall isn’t stripping headers. A quick curl -I against the backend can reveal mismatches.
Admin UI exposed to the internet. By default, NPM listens on port 81 for its management interface. If you forwarded that port, stop immediately. Instead, access the UI via a proxy host that you protect with an access list (see below) or simply don’t expose it at all — use a VPN or SSH tunnel to reach the internal IP.
Going Further: Access Lists and Custom Locations
NPM includes two features that lift it above a simple port‑mapper. Access Lists let you define simple authentication rules — basic auth, IP whitelisting, or both — and apply them to any proxy host. To create one, go to Access Lists, add a new list, and specify allowed IP ranges or a username/password. Then, on a proxy host’s Advanced tab, assign the access list. This is how I protect the admin UI of my services without relying on their own (often flimsy) authentication.
Custom Locations give you nginx‑style location blocks through the UI. If your service exposes an API at /api that needs different timeouts, or you want to serve static files directly from a folder, you can define a location with its own proxy pass or document root. It’s not full regex rewriting, but it covers the common cases.
Wrapping Up
Nginx Proxy Manager strips away the ceremony of reverse proxy configuration without hiding what’s happening under the hood. In an afternoon, you can go from a pile of port numbers to a set of https:// URLs with auto‑renewing certificates, all managed through a web interface that won’t make you miss vi. The key decisions are choosing the right challenge method for your network environment and being deliberate about what you expose to the internet. Start with one service, get SSL working, and then add the rest. If you ever need to debug, the generated nginx configs live in /data/nginx/proxy_host — they’re surprisingly readable, and they’ll teach you the nginx directives you thought you were avoiding.