Nginx Proxy Manager: Reverse Proxy for Your Homelab
The Problem: Managing Multiple Services Behind One IP
Your homelab runs a dozen services across different ports—Plex on 32400, Home Assistant on 8123, Vaultwarden on 80—and you're tired of remembering which port maps to what. You want clean URLs with SSL, granular access control, and the ability to manage it all from a web UI without wrestling with Nginx config files. That's where Nginx Proxy Manager (NPM) fits: it's a Docker-based reverse proxy that sits in front of your services, terminating TLS, handling routing, and managing Let's Encrypt certificates automatically.
This post walks through a production-grade NPM setup on your homelab, covering Docker deployment, wildcard certificates, access lists, and the gotchas that'll save you hours of debugging.
Prerequisites
- Docker 26.1.3 or newer (20.10+ minimum)
- Docker Compose 2.20 or newer
- A domain you control with DNS access (wildcard setup requires this)
- Port 80 and 443 exposed to your WAN (or port-forwarded from your router)
- Static IP or dynamic DNS for your homelab
- Test environment: I'm running this on Ubuntu 24.04.1 LTS with 4GB RAM allocated to Docker
Docker Compose Deployment
Start with a minimal Docker Compose stack. NPM needs a MySQL or MariaDB backend for configuration storage, and a reverse proxy network to communicate with your services.
version: '3.8'
services:
db:
image: mariadb:11.3
container_name: npm-db
restart: always
environment:
MYSQL_ROOT_PASSWORD: change_this_root_pw
MYSQL_DATABASE: npm
MYSQL_USER: npm_user
MYSQL_PASSWORD: change_this_npm_pw
volumes:
- ./db:/var/lib/mysql
networks:
- proxy
nginx-proxy-manager:
image: 'jc21/nginx-proxy-manager:2.11.3'
container_name: npm
restart: always
ports:
- '80:80'
- '443:443'
- '81:81'
environment:
DB_MYSQL_HOST: db
DB_MYSQL_PORT: 3306
DB_MYSQL_USER: npm_user
DB_MYSQL_PASSWORD: change_this_npm_pw
DB_MYSQL_NAME: npm
DISABLE_IPV6: 'true'
depends_on:
- db
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- proxy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:81/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
networks:
proxy:
driver: bridgeDeploy it:
mkdir -p ~/npm/{db,data,letsencrypt}
cd ~/npm
docker compose up -dGotcha #1: Port 80/443 binding on Kubernetes networks. If you're running this on a host with existing containers fighting for ports 80 and 443, NPM will fail silently. Check with docker ps and verify no other container is bound to those ports. Use docker network inspect proxy to validate the network exists.
Access the admin UI at http://your-homelab-ip:81. Default credentials are [email protected] / changeme. Change them immediately in Settings → Users.
Wildcard SSL Certificates with Let's Encrypt
Wildcard certs eliminate the overhead of managing individual certificates for each service. You'll generate *.yourdomain.com once, and every subdomain gets HTTPS automatically.
DNS Challenge Configuration
Navigate to SSL Certificates → Add Certificate in the NPM UI. Select "Let's Encrypt" and choose "Use a DNS challenge." You'll need API credentials for your DNS provider. NPM supports Cloudflare, Namecheap, DigitalOcean, and 40+ others.
For Cloudflare (the most common), generate an API token with "Zone:DNS:Edit" permissions on your domain. Paste it into NPM's UI:
Domain: yourdomain.com
Propagation Seconds: 60
Cloudflare API Token: [your-token-from-cloudflare]Hit "Request Certificate" and wait 2–3 minutes. NPM will handle the DNS validation and certificate provisioning. You'll see it listed under SSL Certificates with an expiry date 90 days out.
Gotcha #2: DNS TTL delays. If your DNS provider has a high TTL (time-to-live) on your records, Let's Encrypt's validation can timeout. Lower your TTL to 300 seconds before requesting the certificate, then raise it back after issuance. Some providers cache aggressively—Cloudflare's free plan can take 10 minutes to propagate changes globally.
Renewal Automation
NPM handles renewals automatically 30 days before expiry. Check Settings → Let's Encrypt to verify renewal is enabled (it is by default). Monitor renewal logs in the web UI under Certificates → Details if you're paranoid.
Adding Your First Proxy Host
With a wildcard certificate in place, adding services is straightforward. Let's say you want Home Assistant available at ha.yourdomain.com.
Basic Proxy Configuration
Go to Hosts → Proxy Hosts → Add Proxy Host:
Domain Names: ha.yourdomain.com
Scheme: http (if service is unencrypted internally)
Forward Hostname/IP: homeassistant (Docker container name, or IP)
Forward Port: 8123
Block Common Exploits: ON
Websockets Support: ON (for real-time updates)
Cache Assets: ONClick the SSL tab and select your wildcard certificate. Choose "Force SSL" to redirect all HTTP traffic to HTTPS.
Save it. NPM will now route https://ha.yourdomain.com to your Home Assistant container. The beauty here is that all your services behind the proxy see the request as coming from 127.0.0.1, not the actual client IP—we'll fix that next.
Real Client IPs with X-Real-IP Headers
By default, your proxied services log all requests as coming from NPM's IP. To preserve the real client IP, add custom Nginx headers. In the proxy host's Advanced tab, add:
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;Your service needs to trust these headers (most modern apps do—check the docs). Home Assistant, for example, respects X-Forwarded-For automatically if you add the proxy to its trusted_proxies list in configuration.yaml.
Access Control Lists: Restricting Who Gets In
You want Vaultwarden accessible only from your home network, but Plex open to the internet. NPM's access lists handle this.
Creating an ACL
Navigate to Access Lists → Create Access List:
Name: HomeNetworkOnly
Satisfy any: OFF (require all rules to match)
Add Rule:
- Type: Client IP
- Operator: is in range
- Value: 192.168.1.0/24Save it. Now edit your Vaultwarden proxy host, go to the Access tab, and select "HomeNetworkOnly." Requests from outside your home network get a 403 Forbidden.
For more granular control, combine multiple rule types. You can enforce time-based access, require HTTP authentication, or whitelist specific countries using GeoIP data. The UI is intuitive—experiment with conditions until you get the policy you want.
Advanced: Docker Service Discovery and Container Networking
If you're running 20+ services, manually adding proxy hosts becomes tedious. NPM can't auto-discover containers (unlike Traefik), but you can leverage Docker's internal DNS.
When you run a service in the same Docker Compose network as NPM, you can reference it by container name instead of IP:
services:
plex:
image: plexinc/pms-docker:latest
container_name: plex
networks:
- proxy
# rest of configIn NPM, set the forward hostname to plex (the container name) and port 32400. Docker's DNS resolver handles the rest. This works across multiple Compose files as long as they all use the same network name.
# Verify the network is shared
docker network inspect proxy | grep plexCommon Issues
502 Bad Gateway on New Proxy Hosts
Ninety percent of the time, it's one of three things:
- Service not running or wrong port: SSH into your homelab and verify the service is up:
curl http://localhost:8123(or whatever port). - Network isolation: Your service is on a different Docker network. Add it to the
proxynetwork, or use the service's IP instead of container name. - Firewall or port-forward misconfiguration: If accessing from outside your network, verify your router forwards ports 80 and 443 to your homelab's IP.
Check NPM's logs with:
docker compose logs npm | tail -50Certificate Renewal Failures
Let's Encrypt limits you to 50 certificate requests per domain per week. If you're testing, use the staging environment first. In the cert creation flow, toggle "Use Staging Server" to test without burning your quota.
Also verify your DNS API credentials are current—if you rotated your Cloudflare token, NPM won't know about it until you update the certificate settings.
MariaDB Connection Errors After Restart
The database takes longer to initialize than NPM expects. Increase the start_period in the healthcheck (I use 40s on my setup). Alternatively, add a restart policy and be patient:
docker compose up -d
docker compose logs -f npm # Wait for "Successfully connected"What You Have Now
You're running a production-grade reverse proxy that terminates SSL, automatically renews certificates, routes traffic to your services with clean URLs, and enforces access policies. No more homelab-ip:8123—you've got ha.yourdomain.com with HTTPS.
Next steps: set up monitoring with Uptime Kuma to alert when proxies go down, or implement a WAF (Web Application Firewall) with ModSecurity if you're running sensitive services. For a deeper dive into Nginx config tuning, check out the official Nginx documentation.
Disclosure: This post contains affiliate links. If you purchase through these links, we may earn a small commission at no extra cost to you. We only recommend services we've tested and trust.