TL;DR: Run Nginx Proxy Manager 2.15.1 with ports 80 and 443 public, bind its admin port 81 to localhost, attach applications to a shared Docker proxy network, and back up both/dataand/etc/letsencrypt.
An Nginx Proxy Manager setup gives every self-hosted application a domain and an automatically renewed HTTPS certificate without making you maintain Nginx server blocks by hand. The dangerous part is not the reverse proxy itself; it is exposing the administrator dashboard or publishing application ports that should remain private.
I run Nginx Proxy Manager (NPM) in front of ByteGuard's public services. On 23 August 2026, the production container was running NPM 2.15.1 on Docker 29.7.2, using 116.5 MiB of RAM, with zero container restarts. This guide uses that same version-pinned pattern and keeps port 81 off the public internet.
What you need before installing Nginx Proxy Manager
You need a Docker host, a domain, and control of ports 80 and 443 before starting.
- An Ubuntu or Debian server with Docker Engine and the Compose plugin.
- A domain with an
Arecord pointing to the server. Add anAAAArecord only if IPv6 is configured and firewalled correctly. - TCP ports 80 and 443 open to the internet.
- SSH or a VPN for reaching the private NPM dashboard.
- A hardened base system. Start with the Linux VPS hardening guide and the SSH hardening guide if this is a new server.
If you are still choosing a server, see the ByteGuard VPS comparison.
Create a secure Nginx Proxy Manager Docker Compose file
The secure baseline exposes only the public web ports globally and binds the admin dashboard to localhost.
Create the working directories:
sudo install -d -m 0750 /opt/npm/data /opt/npm/letsencrypt
cd /opt/npm
Create /opt/npm/compose.yaml:
services:
npm:
image: jc21/nginx-proxy-manager:2.15.1
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "127.0.0.1:81:81"
environment:
TZ: "Africa/Casablanca"
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
networks:
- proxy
networks:
proxy:
name: proxy
Pinning 2.15.1 makes upgrades deliberate and reproducible. The two bind mounts persist NPM's SQLite database, generated configuration, certificates, and account state. Change the timezone to your own IANA timezone.
The official example publishes port 81 on every interface. Binding it as 127.0.0.1:81:81 means an internet scan cannot reach the dashboard even if the host firewall is accidentally loosened.
Start NPM and inspect its health:
docker compose up -d
docker compose ps
docker compose logs --tail=100 npm
The first boot creates keys and initializes the database, so it can take a couple of minutes. Confirm that only ports 80 and 443 are public:
sudo ss -lntp | grep -E ':(80|81|443)\b'
Port 81 should show 127.0.0.1:81, not 0.0.0.0:81 or [::]:81.
The checklist I use for SSH, firewall rules, Docker, monitoring, and backups on a new VPS.
Open the NPM dashboard without exposing port 81
An SSH tunnel lets your browser reach the localhost-only dashboard without opening another public port.
Run this on your own computer:
ssh -L 8181:127.0.0.1:81 <YOUR_USER>@<YOUR_SERVER_IP>
Keep that terminal open and visit http://127.0.0.1:8181. Complete the initial setup immediately, choose a unique password, and enable two-factor authentication from the account settings.
For regular administration, a private VPN such as WireGuard is more convenient than reopening an SSH tunnel each time. The WireGuard server guide covers that route.
Do not proxy the NPM dashboard through NPM itself unless it is protected by a VPN or a strong identity-aware access layer. A hidden subdomain is not access control.
Put an application on the shared proxy network
A shared Docker network lets NPM reach an application by container name while the application's port remains unpublished.
The proxy network already exists because Compose created it. In another application's Compose file, declare that network as external:
services:
app:
image: ghcr.io/example/app:1.2.3
restart: unless-stopped
expose:
- "8080"
networks:
- proxy
- app_private
networks:
proxy:
external: true
app_private:
internal: true
expose documents the container port but does not publish it on the host. NPM can reach app:8080 over the shared network; internet clients cannot bypass the proxy with SERVER_IP:8080.
Do not put every container on one flat network. Attach NPM only to each application's frontend network, while databases stay on separate private networks. This limits lateral movement if one public application is compromised. The Docker security guide explains the broader container controls.
Create your first HTTPS proxy host
A proxy host maps a public hostname to the private container name and port.
In NPM, open Hosts → Proxy Hosts → Add Proxy Host and enter:
- Domain Names:
app.example.com - Scheme:
http - Forward Hostname/IP:
app - Forward Port:
8080 - Block Common Exploits: enabled
- Websockets Support: enable only when the application needs it
On the SSL tab, request a new Let's Encrypt certificate, enable Force SSL, and agree to the certificate terms. Save the host, then verify both the response and certificate:
curl -I https://app.example.com
openssl s_client -connect app.example.com:443 \
-servername app.example.com </dev/null 2>/dev/null \
| openssl x509 -noout -issuer -subject -dates
The request should return an expected success or redirect status, and the certificate dates should be current.
Use Cloudflare without exposing the origin
Cloudflare can hide the VPS origin only when the host firewall accepts web traffic exclusively from Cloudflare's published address ranges.
Set the DNS record to proxied, use Full (strict) TLS mode, and install an origin certificate or retain a valid public certificate at NPM. Then allow Cloudflare's current IPv4 and IPv6 ranges to ports 80 and 443 before rejecting other sources.
This is an advanced change: fetching stale ranges or applying the drop rule before the allows can lock out legitimate traffic. Test the origin directly with a non-Cloudflare address after deployment; it should time out or be rejected while the hostname remains available through Cloudflare.
Back up Nginx Proxy Manager before upgrades
A recoverable NPM backup includes both persistent directories and is tested before the old version is removed.
Stop the container briefly for a consistent filesystem snapshot:
cd /opt/npm
docker compose stop npm
sudo tar --xattrs --acls -czf \
"/var/backups/npm-$(date +%F-%H%M).tar.gz" \
data letsencrypt compose.yaml
docker compose start npm
Copy the archive off the server and record the image tag. Before upgrading, read the upstream release notes, pull the chosen tag, and recreate the container:
docker compose pull npm
docker compose up -d
docker compose logs --tail=100 npm
Never assume that a successful archive command proves restoration works. Periodically extract a backup into a temporary directory, verify its files, and document the rollback command.
Nginx Proxy Manager troubleshooting
Why does NPM show a 502 Bad Gateway?
A 502 usually means NPM cannot reach the upstream container. Confirm both containers share the same Docker network, use the container's internal port rather than its host-published port, and test name resolution from the NPM container:
docker exec nginx-proxy-manager getent hosts app
docker exec nginx-proxy-manager curl -I http://app:8080
Why does Let's Encrypt certificate issuance fail?
HTTP validation fails when DNS points elsewhere, port 80 is blocked, or a CDN/proxy rule interferes with the challenge. Verify the public DNS answer and test port 80 from outside your network. For internal-only services, use a DNS challenge instead of exposing the service.
Why can NPM not reach a service on localhost?
Inside the NPM container, 127.0.0.1 refers to NPM itself. Put both containers on a shared network and forward to the application's container name, such as vaultwarden:80.
Why does the application redirect to HTTP or loop forever?
The upstream application may not trust the reverse proxy headers. Configure its external URL as HTTPS, set its trusted-proxy count correctly, and ensure NPM sends the original host and protocol headers.
Why does the dashboard work locally but not at my server IP?
That is intentional in this configuration because port 81 is bound to localhost. Use the SSH tunnel or VPN described above instead of changing it back to 81:81.
Nginx Proxy Manager FAQ
What ports does Nginx Proxy Manager use?
Nginx Proxy Manager listens on ports 80 and 443 for public HTTP and HTTPS traffic and port 81 for administration. Only 80 and 443 normally need internet exposure; keep port 81 on localhost, a trusted LAN, or a VPN.
Is Nginx Proxy Manager free?
Yes. Nginx Proxy Manager is open-source software with no paid tier. You pay only for the server, domain, and any external services you choose.
Should I use SQLite or MariaDB with NPM?
SQLite is appropriate for most single-server self-hosting installations and makes backup straightforward. An external database adds operational complexity and is useful only when your deployment has a concrete reason for it.
Is Nginx Proxy Manager better than Caddy or Traefik?
NPM is usually the easiest choice when you want a graphical interface. Caddy favors concise configuration and automatic HTTPS, while Traefik is stronger for dynamic label-driven infrastructure. See Nginx Proxy Manager vs Traefik vs Caddy and Pangolin vs Nginx Proxy Manager for measured comparisons.
Conclusion
This Nginx Proxy Manager setup exposes only ports 80 and 443, keeps its administrator interface private, and connects applications over explicit Docker networks. It also pins the image version and backs up the database and certificate state before upgrades.
The next step is to proxy one low-risk service, verify HTTPS and renewal, and then migrate the rest of the stack one application at a time. If you want the entire server hardened and configured, ByteGuard's VPS setup service covers the Docker host, reverse proxy, TLS, firewall, and backups.
Sources
- Nginx Proxy Manager setup documentation
- Nginx Proxy Manager releases
- Nginx Proxy Manager security policy
Affiliate disclosure
This post links to a VPS comparison that may contain affiliate links. Recommendations are based on services I use for production workloads, and using them costs you nothing extra.
— enim
← Back
Comments
Sign in with GitHub to comment. Threads live in the byteguard-comments repo.