Nginx Proxy Manager for the Homelab

By LK Wood IV · 2026-06-13 · ~11 min read · St. Louis County, MO

Every self-hosted service runs on its own port. Portainer on 9000, Jellyfin on 8096, Vaultwarden on 8222, Uptime Kuma on 3001. After six services you stop remembering which port is which and start typing 192.168.1.10:???? into every browser.

Nginx Proxy Manager fixes this: one reverse proxy that takes jellyfin.yourdomain.com and routes it to the right container. Let’s Encrypt SSL included. A web UI that doesn’t require touching an nginx config file.

What you’ll have at the end

  • NPM running as a Docker container, listening on ports 80 and 443
  • Each self-hosted service reachable at its own subdomain with a valid HTTPS certificate
  • Access lists that restrict specific services to your homelab IP range
  • DNS challenge SSL for internal-only services that don’t have public internet exposure

Prerequisites

  • Docker running on your homelab host
  • A domain name (DuckDNS is free: yourname.duckdns.org)
  • Port 80 and 443 forwarded on your router to the Docker host (for Let’s Encrypt HTTP challenge) — OR a DNS provider with API support (for DNS challenge, no port forward needed)

Step 1: Deploy NPM with Docker Compose

The Docker Compose starter stack uses a shared proxy network so all containers can talk to NPM. If you’re starting from scratch, create this standalone:

mkdir -p /opt/stacks/npm && cd /opt/stacks/npm
# docker-compose.yml
services:
  npm:
    image: jc21/nginx-proxy-manager:latest
    container_name: npm
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "81:81"   # NPM admin UI
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    networks:
      - proxy

networks:
  proxy:
    external: true

If the proxy network doesn’t exist yet:

docker network create proxy

Start NPM:

docker compose up -d

Open the NPM admin UI at http://your-host-ip:81. Default login:

  • Email: admin@example.com
  • Password: changeme

Change both immediately on first login — NPM prompts you automatically.

Step 2: Get a Let’s Encrypt SSL certificate

Before adding any proxy hosts, get a wildcard certificate — one cert that covers all subdomains (*.yourdomain.com) so you don’t need a separate cert for each service.

In NPM → SSL Certificates → Add SSL Certificate:

  • Domain Names: *.yourdomain.com and yourdomain.com
  • Email address: your email (for Let’s Encrypt expiry notices)
  • Enable “Use a DNS Challenge”
  • Select your DNS provider (Cloudflare, DuckDNS, Namecheap, etc.)
  • Enter your DNS provider API credentials

For DuckDNS:

  • DNS Provider: DuckDNS
  • Credentials file: dns_duckdns_token=YOUR-DUCKDNS-TOKEN

For Cloudflare:

  • DNS Provider: Cloudflare
  • Credentials file:
dns_cloudflare_email = you@email.com
dns_cloudflare_api_key = YOUR-GLOBAL-API-KEY

Click “Save” — NPM creates the TXT challenge record, waits for DNS propagation, and retrieves the cert. This takes 30–60 seconds for DuckDNS, up to 2 minutes for Cloudflare (propagation time).

The wildcard cert covers jellyfin.yourdomain.com, portainer.yourdomain.com, and any other subdomain you add — no additional cert requests needed.

Step 3: Add your first proxy host

NPM → Proxy Hosts → Add Proxy Host:

For Jellyfin running on the same Docker host:

  • Domain Names: jellyfin.yourdomain.com
  • Scheme: http
  • Forward Hostname/IP: jellyfin (the container name on the proxy network)
  • Forward Port: 8096
  • Websockets Support: on (Jellyfin uses websockets for playback events)

Under the SSL tab:

  • SSL Certificate: select the wildcard cert you created
  • Force SSL: on
  • HTTP/2 Support: on

Click Save. Within seconds, https://jellyfin.yourdomain.com routes to your Jellyfin container with a valid cert.

If Jellyfin isn’t on the same Docker host, use its LAN IP instead of the container name:

  • Forward Hostname/IP: 192.168.1.10

Step 4: Add more services

Repeat Step 3 for each service. Common homelab NPM entries:

SubdomainContainer/IPPortNotes
portainer.yourdomain.comportainer9000Enable websockets
uptime.yourdomain.comuptime-kuma3001
vaultwarden.yourdomain.comvaultwarden80Enable websockets; see Vaultwarden guide
nextcloud.yourdomain.comnextcloud443Set scheme to HTTPS if NC uses TLS internally
grafana.yourdomain.comgrafana3000
adguard.yourdomain.com192.168.1.580IP since AdGuard is in LXC, not Docker

For containers on the proxy Docker network: use the container name as the hostname. For services on a different host or in LXC: use the LAN IP address.

Step 5: Restrict access to sensitive services

You don’t want Portainer or Vaultwarden’s admin interface accessible from the internet. Use NPM Access Lists.

NPM → Access Lists → Add Access List:

  • Name: homelab-only
  • Satisfy Any: off
  • Access: Allow 192.168.1.0/24 (your LAN subnet)

Apply it to any proxy host that should be LAN-only:

  • On the proxy host → Access List: select homelab-only

Now portainer.yourdomain.com returns a 403 when accessed from outside your LAN, even though port 443 is open.

If you’re using Tailscale for remote access, add your Tailscale subnet (100.64.0.0/10) to the same access list so you can reach restricted services while traveling:

Allow 192.168.1.0/24
Allow 100.64.0.0/10

Step 6: DNS entries for each subdomain

For external access (public internet), add DNS records at your registrar:

  • A record: *.yourdomain.com → your home IP
  • A record: yourdomain.com → your home IP

For internal-only .lan access, add DNS rewrites in AdGuard Home:

  • jellyfin.yourdomain.com → your Docker host’s LAN IP (192.168.1.10)
  • portainer.yourdomain.com192.168.1.10
  • etc.

With AdGuard Home DNS rewrites, internal clients resolve jellyfin.yourdomain.com directly to the LAN IP instead of going out to the internet and back in. This is split DNS in practice.

Custom error pages and headers

NPM supports custom nginx config snippets for advanced cases. For most homelab services, the defaults work. Two useful additions:

Remove server headers (minor security hardening):

In NPM → Edit proxy host → Advanced tab:

proxy_hide_header X-Powered-By;
more_clear_headers Server;

Increase upload size limit (required for Nextcloud and Immich):

client_max_body_size 0;

Set this on the Nextcloud and Immich proxy hosts — the default 1m limit causes upload failures for large files and photos.

Migrating from manual port-based access

If you’ve been running services on direct ports, the migration is:

  1. Deploy NPM, get the cert, add proxy hosts
  2. Test each service via its new domain
  3. Remove the host-level port mappings from each container’s compose file (the container doesn’t need to expose its port directly anymore — NPM handles it)
  4. Redeploy each container without the port mapping

After migration, only ports 80, 443, and 81 (NPM admin) need to be accessible externally. All other service ports stay internal to the Docker network.


NPM manages ingress; AdGuard Home manages DNS — together they give every service a clean hostname with working SSL on both internal and external access. The full Docker Compose stack that includes NPM, Portainer, Uptime Kuma, and more is in the Docker Compose starter stack.