SelfhostRealm
Isometric 3D scene of a desktop monitor and tablet cabled to a central proxy box with a blue padlock, which routes blue cables to three backend server towers
guides

How to Set Up a Reverse Proxy with Nginx Proxy Manager

Run Nginx Proxy Manager in Docker, add proxy hosts by container name, and get Let's Encrypt certs. Compose file, DNS-01 wildcards and backups.

By SelfhostRealm Editorial · · 8 min read

Setting up a reverse proxy with Nginx Proxy Manager takes five steps: run the jc21/nginx-proxy-manager container on ports 80, 443 and 81, create the admin account on port 81, point DNS at the host, add a Proxy Host forwarding to the app’s container name and internal port, and request a Let’s Encrypt certificate.

The container takes minutes. DNS, certificates and the one toggle you forgot take the rest of the evening. What follows is the version that survives month nine, not just day one.

Who this is for, and who should skip it

Nginx Proxy Manager (NPM) suits someone with five to fifteen web apps on a mini-PC who wants jellyfin.home.example.com instead of 192.168.1.20:8096, valid HTTPS, and a web UI instead of hand-written nginx configs. Per the official guide, it wraps nginx and Let’s Encrypt in an admin panel with access lists, redirects and TCP/UDP streams on top. HTTPS is not just cosmetic either: some apps, password vaults from our self-hosted password manager roundup in particular, misbehave in the browser without it.

Skip it if:

  • You want proxy config in Git. Caddy and Traefik keep routes in files or labels; NPM keeps them in a database behind a GUI. That is the whole appeal and the whole drawback.
  • You run two services that only you reach over a VPN. An IP and port is fine, and a proxy is one more load-bearing container to babysit.
  • You want it on a Synology without fuss. More on that below.

You need Docker with compose v2, a domain you control, and access to your router or local DNS.

Hardware that works

NPM is light. An N100-class box such as a Beelink S12 Pro or a GMKtec NucBox runs it next to the rest of your stack without noticing. A Raspberry Pi 4 or 5 works on a 64-bit OS: the image ships amd64 and arm64, and the setup docs say armv7 support ended in 2.14, so 32-bit Raspberry Pi OS is stuck on 2.13.7.

The awkward one is a Synology NAS like the DS923+. DSM’s own web server already holds ports 80 and 443, so NPM cannot bind them without macvlan tricks or remapping DSM. Run NPM on the mini-PC and proxy to the NAS by LAN IP. Less clever, far less fragile.

The stack

Create one shared network first so NPM can reach apps by container name. Docker’s bridge network docs explain why: on a user-defined bridge, “containers can resolve each other by name or alias,” which the default bridge does not do.

docker network create proxy
mkdir -p ~/stacks/npm && cd ~/stacks/npm

Then ~/stacks/npm/compose.yaml:

services:
  npm:
    image: 'jc21/nginx-proxy-manager:2.16.0'
    container_name: npm
    restart: unless-stopped
    ports:
      - '80:80'                # HTTP and ACME HTTP-01
      - '443:443'              # HTTPS
      - '192.168.1.10:81:81'   # admin UI, LAN IP only
    environment:
      TZ: 'Europe/London'
      # DISABLE_IPV6: 'true'   # if the host has no IPv6
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    healthcheck:
      test: ["CMD", "/usr/bin/check-health"]
      interval: 10s
      timeout: 3s
    networks:
      - proxy

networks:
  proxy:
    external: true

Swap 192.168.1.10 for the host’s LAN address. Binding port 81 to one interface keeps the admin panel off anything public, even if this later moves to a VPS. The healthcheck comes from the advanced configuration docs. The version pin is deliberate: the proxy fronts everything, so it updates on your schedule.

An app joins the same network and publishes no ports at all:

services:
  jellyfin:
    image: jellyfin/jellyfin:latest
    container_name: jellyfin
    restart: unless-stopped
    volumes:
      - ./config:/config
      - ./cache:/cache
      - /mnt/nas/media:/media:ro
    networks:
      - proxy

networks:
  proxy:
    external: true

Run docker compose up -d in each directory. If you are still deciding what deserves a place behind the proxy, read how to decide what goes in your Docker Compose stack first.

Step 1: create the admin account

Open http://192.168.1.10:81. First boot generates JWT keys and builds the database, which the docs say “can take a couple of minutes.” Since version 2.13.0 a setup wizard asks for your name, an email address and a password, and that becomes the administrator. For scripted installs, the INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD variables create the first user instead. Turn on two-factor authentication while you are there.

Step 2: point DNS at the proxy

Every hostname you proxy must resolve to the NPM host. Pick one pattern:

  • Public: a wildcard A record (or a dynamic DNS name) pointing at your home IP, with 80 and 443 forwarded on the router to the mini-PC.
  • Internal only: a wildcard record for *.home.example.com in Pi-hole, AdGuard Home or your router, pointing at 192.168.1.10. Nothing forwarded, nothing exposed.

The second is the right default for most households. It pairs with a DNS-01 certificate in step 4.

Step 3: add the first proxy host

Under Hosts, then Proxy Hosts, add a new host:

  • Domain Names: jellyfin.home.example.com
  • Scheme: http, because the hop between containers stays on the Docker network
  • Forward Hostname / IP: jellyfin, the container name
  • Forward Port: 8096, the port inside the container, not a host-published one
  • Websockets Support: on. Plenty of apps need it, and the failure looks like a half-loaded page.
  • Block Common Exploits: on

Never type localhost as the forward host. Inside the NPM container, localhost is NPM itself. For anything outside Docker, such as the NAS admin page or a Home Assistant VM, use its LAN IP and port.

Step 4: get the certificate

On the SSL tab, request a new Let’s Encrypt certificate, tick Force SSL and HTTP/2, and save. Which challenge works depends on step 2.

HTTP-01 is the default and needs nothing extra, but Let’s Encrypt’s challenge type docs are blunt: it “can only be done on port 80.” No forwarded port 80, no certificate.

DNS-01 proves ownership with a TXT record through your DNS provider’s API, so it works for internal-only names and is the only route to a wildcard. Request *.home.example.com once with the DNS challenge enabled and reuse it on every host. For Cloudflare, the certbot plugin docs say the token needs Zone:DNS:Edit “for only the zones you need certificates for.” Scope it that tightly; Let’s Encrypt itself warns that API credentials on the server raise the cost of a breach.

When a request fails, fix DNS before clicking again. The rate limits allow five failed authorizations per identifier per account per hour, and five certificates for the exact same set of names every seven days.

Should you port-forward, use Tailscale or Cloudflare Tunnel?

Keep it internal unless someone outside your house genuinely needs a browser link. For family on phones, Tailscale plus the DNS-01 setup above gives real certificates and zero open ports. The trade-offs are in WireGuard vs Tailscale for home networks.

Forwarding 80 and 443 is what the project README describes, and it works, but expect constant scanner traffic against every login page you publish. Put an Access List on anything without strong authentication of its own. Cloudflare Tunnel avoids open ports and hides your home IP, at the price of Cloudflare terminating TLS and seeing your traffic. Reasonable for a public blog, a poor fit for a photo library.

Storage and backups

Everything NPM knows lives in two bind mounts: ./data (the SQLite database, generated nginx configs, JWT keys) and ./letsencrypt (certificates and ACME account keys). Back up both nightly to the NAS, as part of a real 3-2-1 backup strategy. Stop the container or snapshot the dataset first so the SQLite file is consistent.

Run the restore drill once a year: on a spare VM, copy both directories into a fresh ~/stacks/npm, run docker compose up -d, and confirm your hosts and certificates appear. Skip the backup and a dead SSD means re-creating every host by hand and re-requesting every certificate straight into those rate limits.

Operations: updates, logs, alerts

Update by hand, and read the release notes first. They matter here: 2.15.0 told users to “exercise caution” because DNS plugins might not work after a base image change. That is exactly the kind of upgrade that silently breaks renewal weeks later. Bump the tag, run docker compose pull && docker compose up -d, then renew one DNS-01 certificate manually to prove the plugin still works. Watchtower is fine for disposable apps, not for the container every other service depends on. Renovate works if your compose files already live in Git.

Version 2.16.0 added a log viewer to the UI. For alerts, point an uptime monitor such as Uptime Kuma at one proxied hostname with certificate-expiry warnings on, so a failed renewal shows up long before browsers complain.

When it still does not work

  • Buttons spin forever: Websockets Support is off.
  • Large uploads fail: raise client_max_body_size in the host’s Advanced tab. Photo apps, including anything you set up from our Google Photos alternatives list, hit this first.
  • Login loop behind an Access List: the NPM FAQ explains that basic-auth Access Lists and apps that also use the Authorization header fight over it. Remove one layer.

FAQ

Is Nginx Proxy Manager better than Traefik or Caddy?

Nginx Proxy Manager is better if you want a web UI and a handful of hosts you rarely change. Caddy suits people who like one short config file in Git. Traefik pays off when many containers come and go, because routes live in Docker labels. For a single mini-PC, any of the three is plenty.

Why does Nginx Proxy Manager show 502 Bad Gateway?

A 502 means NPM could not get a response from the app behind it: the forward host or port is wrong, or the app is not on a network NPM can see. Use the container name and internal port, confirm both containers share the proxy network, and choose http unless the app itself serves HTTPS.

Can Nginx Proxy Manager proxy to another computer on my network?

Yes, set the Forward Hostname to the other machine’s LAN IP and the Forward Port to the port the service listens on. This covers a NAS admin page, a Home Assistant VM or a printer. If the target only serves HTTPS with a self-signed certificate, set the scheme to https. Reserve that IP in DHCP.

Does Nginx Proxy Manager renew certificates automatically?

Yes, Nginx Proxy Manager renews Let’s Encrypt certificates on its own, but only if the original challenge still works. HTTP-01 needs port 80 reachable from the internet at renewal time; DNS-01 needs a valid API token. Rotating a token or closing port 80 quietly breaks renewal, so monitor certificate expiry externally.

Sources

  1. Nginx Proxy Manager: Guide
  2. Nginx Proxy Manager: GitHub repository
  3. Nginx Proxy Manager: Full Setup Instructions
  4. Nginx Proxy Manager: Advanced Configuration
  5. Nginx Proxy Manager: FAQ
  6. Nginx Proxy Manager v2.13.0 release notes (GitHub)
  7. Let's Encrypt: Challenge Types
  8. Let's Encrypt: Rate Limits
  9. Docker Docs: Bridge network driver
  10. certbot-dns-cloudflare documentation
#nginx-proxy-manager#reverse-proxy#docker-compose#lets-encrypt#networking

Related