VPS Tutorials
Deploying Nginx Proxy Manager on a VPS (2026): Docker, Domain HTTPS, Reverse Proxy and Security Configuration
Deploy Nginx Proxy Manager 2.15.1 on a VPS with Docker Compose, configure domain reverse proxying, Let's Encrypt HTTPS, WebSocket, Access Lists and Cloudflare, and learn about MariaDB, backup and upgrades, security hardening, and 502/certificate troubleshooting.

Deploying Nginx Proxy Manager on a VPS (2026): Docker, Domain HTTPS, Reverse Proxy and Security Configuration
Once you have Uptime Kuma, Jellyfin, n8n, a blog and a few Docker apps running on one VPS, the most troublesome part is usually not getting the containers to start, but assigning a domain to each service, obtaining HTTPS, handling WebSocket, and remembering dozens of Nginx configuration snippets.
Nginx Proxy Manager, or NPM for short, turns this work into a web management interface. You can point status.example.com at a monitoring dashboard, media.example.com at a media server, and request a separate Let's Encrypt certificate for each domain, without hand-writing a full Nginx Server Block every time.
This article uses the current stable version Nginx Proxy Manager 2.15.1 and MariaDB 11.4.12. A special reminder: many old tutorials online still use [email protected] / changeme, but the current version enters an initialization wizard on first startup by default, so you should not continue to rely on that old set of credentials.
NPM is well suited to the following needs:
- Multiple web services on one VPS that need to be routed by subdomain;
- You do not want to hand-write Nginx configuration for every application;
- You want to request, renew and inspect HTTPS certificates through a web interface;
- A home lab or small team needs unified reverse proxy management;
- Docker applications are frequently added, migrated or have their ports changed;
- You need simple Basic Auth, Access Lists or WebSocket support.
The following situations do not necessarily require NPM:
- You only have one website and two or three lines of Caddy configuration are enough;
- All configuration is managed by Git, Ansible or CI/CD and you do not want to modify it in a web interface;
- You need complex traffic governance, dynamic service discovery or Kubernetes Ingress;
- You expect it to directly replace Cloudflare WAF, application authentication or a zero-trust gateway;
- What you manage is not HTTP/HTTPS services but a large number of complex Layer 4 protocols.
NPM's core is still Nginx. The web interface lowers the configuration barrier, but it does not automatically solve the application's own permission, vulnerability, database backup and access control issues.
| Option | Pros | Cons | Better suited for |
|---|---|---|---|
| Nginx Proxy Manager | Graphical interface, intuitive certificate management, quick to get started | One more admin backend and database | Multiple apps, home labs, small teams |
| Caddy | Short configuration, automatic HTTPS, simple maintenance | Weak graphical management | Those who prefer config files and have few services |
| Native Nginx | Flexible, mature ecosystem, controllable details | Higher learning and maintenance cost | Complex production configurations and automated operations |
If you are used to configuration files and only proxy three to five services, the Complete guide to reverse proxying with Caddy on a VPS is usually lighter. If you need non-ops colleagues to be able to add domains, or services change frequently, NPM will save you a lot of trouble.
NPM itself is not heavy; what really consumes resources are the applications it proxies.
| Scenario | Recommended configuration | Notes |
|---|---|---|
| Up to 5 lightweight apps | 1 core, 1GB, 20GB SSD | Only run NPM and a few low-traffic services |
| 10–30 apps | 2 cores, 2GB, 40GB SSD | Leave headroom for MariaDB, logs and certificates |
| Database and business apps on the same machine | 2–4 cores, 4GB+ | Choose the configuration based on the actual load of the business apps |
| High-traffic website | Dedicated CPU, ample bandwidth | Focus on the upstream applications and network, not just NPM |
Three ports are needed:
80/tcp: HTTP and Let's Encrypt HTTP-01 validation;443/tcp: public HTTPS traffic;81/tcp: NPM admin backend.
This article opens 80 and 443 to the public internet but binds 81 only to 127.0.0.1. The admin backend is accessed through an SSH tunnel to avoid direct exposure to the public internet.
Before going live, check whether the ports are already in use:
sudo ss -lntp | grep -E ':(80|81|443)\b' || true
If Nginx, Apache, Caddy or another proxy already occupies 80/443, first decide who takes over the entry point; do not start two services fighting over the same port. For detailed troubleshooting, see What to do when a VPS port is occupied.
This article uses two Docker networks:
npm-internal: connects only NPM and MariaDB, so the database is not exposed to the public internet;proxy: shared by NPM and the proxied Docker applications, accessing upstreams by container name.
The external request path is roughly:
用户浏览器
↓ 80/443
Nginx Proxy Manager
↓ Docker proxy 网络
应用容器:内部端口
If the application is not in Docker, you can also point the Proxy Host at the VPS internal address or the private IP of another server.
First confirm Docker and Compose are available:
docker version
docker compose version
If not installed, you can first read the Docker deployment practical guide. For production servers, it is recommended to install from the official Docker repository rather than long-term using the outdated Compose in the distribution.
Create the directory and shared network:
sudo mkdir -p /opt/nginx-proxy-manager/data
sudo mkdir -p /opt/nginx-proxy-manager/letsencrypt
sudo mkdir -p /opt/nginx-proxy-manager/mariadb
sudo chown -R "$USER":"$USER" /opt/nginx-proxy-manager
docker network create proxy
If the proxy network already exists, Docker will report a name conflict, and you do not need to create it again. You can confirm with the following command:
docker network inspect proxy
Do not use the npm, password or domain names in the examples directly as passwords. Generate two sets of random values:
openssl rand -base64 36
openssl rand -base64 36
Save them in /opt/nginx-proxy-manager/.env:
NPM_DB_ROOT_PASSWORD=替换为第一组随机密码
NPM_DB_PASSWORD=替换为第二组随机密码
Restrict file permissions:
chmod 600 /opt/nginx-proxy-manager/.env
If the password contains spaces, newlines or Compose special interpolation characters, parsing may fail. Using openssl rand -hex 32 generates pure hexadecimal passwords that are easier to put into .env.
Create /opt/nginx-proxy-manager/compose.yaml:
services:
app:
image: jc21/nginx-proxy-manager:2.15.1
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "80:80/tcp"
- "443:443/tcp"
- "127.0.0.1:81:81/tcp"
environment:
TZ: Asia/Shanghai
DB_MYSQL_HOST: db
DB_MYSQL_PORT: 3306
DB_MYSQL_USER: npm
DB_MYSQL_PASSWORD: ${NPM_DB_PASSWORD}
DB_MYSQL_NAME: npm
volumes:
- /opt/nginx-proxy-manager/data:/data
- /opt/nginx-proxy-manager/letsencrypt:/etc/letsencrypt
networks:
- npm-internal
- proxy
depends_on:
db:
condition: service_healthy
db:
image: linuxserver/mariadb:11.4.12
container_name: nginx-proxy-manager-db
restart: unless-stopped
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: ${NPM_DB_ROOT_PASSWORD}
MYSQL_DATABASE: npm
MYSQL_USER: npm
MYSQL_PASSWORD: ${NPM_DB_PASSWORD}
volumes:
- /opt/nginx-proxy-manager/mariadb:/config
networks:
- npm-internal
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h 127.0.0.1 -uroot -p\"$${MYSQL_ROOT_PASSWORD}\" --silent"]
interval: 10s
timeout: 5s
retries: 12
start_period: 30s
networks:
npm-internal:
internal: true
proxy:
external: true
Here the NPM and MariaDB versions are pinned to avoid an accidental major-version upgrade during a docker compose pull. The official project also supports SQLite, which is indeed simpler for personal testing; but with multiple proxies, certificates and long-term operation, a separate database is easier to back up, inspect and restore.
Check the final configuration to make sure the passwords are read correctly:
cd /opt/nginx-proxy-manager
docker compose config --quiet
Do not paste the full output of docker compose config into a public ticket; it may expand the database password in .env.
Start the services:
cd /opt/nginx-proxy-manager
docker compose up -d
docker compose ps
The first startup generates the JWT secret, initializes the database and table structure, and may take one or two minutes. View the logs:
docker compose logs --tail=150 app
docker compose logs --tail=100 db
Confirm the local backend port:
curl -I http://127.0.0.1:81
If it returns a failure right after startup, wait for the database health check to pass, then check the logs again. Do not delete the data directory and reinstall just because the first connection failed.
Because 81 only listens on localhost, you need to establish an SSH tunnel from your own computer:
ssh -L 8181:127.0.0.1:81 your_user@your_vps_ip
Keep the terminal connection open, then visit in your browser:
http://127.0.0.1:8181
The current version shows an initialization wizard by default, letting you create the first administrator. Use a real email address that can receive notifications and a separate random password.
The following set of credentials, common in old articles, is no longer suitable as the default flow for the current tutorial:
[email protected]
changeme
If you upgrade from an old data directory, the original account will still be retained; if you see the old account interface on a fresh install, check whether you reused a previous /data or database.
Suppose you want to proxy app.example.com to an application:
- Create an A record at your DNS provider pointing to the VPS public IPv4;
- If you have IPv6, also create an AAAA record and confirm that the VPS's 80/443 are also open in the IPv6 firewall;
- Wait for the resolution to take effect;
- Check the resolution result locally.
dig +short app.example.com A
dig +short app.example.com AAAA
If the AAAA points to the wrong server, Let's Encrypt and some users may fail to access via IPv6. If you are not using IPv6 for now, deleting the wrong AAAA is safer than letting it keep pointing to the wrong place.
After entering NPM, select Hosts → Proxy Hosts → Add Proxy Host.
Basic configuration example:
| Field | Example | Notes |
|---|---|---|
| Domain Names | app.example.com | A domain already resolved to the VPS |
| Scheme | http | Use when the upstream application does not have TLS enabled |
| Forward Hostname / IP | my-app | The container name in the same Docker network |
| Forward Port | 3000 | The port the application container listens on internally |
| Cache Assets | Off by default | Do not blindly enable for dynamic applications |
| Block Common Exploits | Can be enabled | Not a full WAF; compatibility still needs testing |
| Websockets Support | Enable per application | Real-time dashboards, terminals and notifications often need it |
Before saving, test the upstream inside the NPM container:
docker exec nginx-proxy-manager getent hosts my-app
docker exec nginx-proxy-manager curl -I http://my-app:3000
If the container name cannot be resolved, the two containers are not joined to the same proxy network.
The application's own Compose can be written like this:
services:
my-app:
image: your-app-image:固定版本
restart: unless-stopped
expose:
- "3000"
networks:
- proxy
networks:
proxy:
external: true
expose only declares the container's internal port and does not publish 3000 directly to the public internet. External users can only access through NPM's 80/443, reducing the attack surface.
Already running containers can temporarily join the network:
docker network connect proxy my-app
But after the container is recreated, the manual connection may be lost. Long-term configuration should be written into the application's Compose file.
If the application runs directly on the VPS host and not in Docker, do not fill in 127.0.0.1 in NPM. The 127.0.0.1 inside the NPM container points to the NPM container itself, not the host.
You can use one of the following approaches:
- Have the application listen on the host's private address, then fill in that address;
- Add an
host-gatewaymapping in the Linux Compose; - Use the private IP of another server in the same LAN;
- Migrate the application into the shared Docker network as well.
host-gateway example:
services:
app:
extra_hosts:
- "host.docker.internal:host-gateway"
After that you can try using host.docker.internal as the Forward Hostname. The host firewall and the application's listening address still need to allow connections from the Docker bridge.
On the Proxy Host's SSL page:
- Select
Request a new SSL Certificate; - Enable
Force SSL; - For normal websites you can enable
HTTP/2 Support; - Fill in the email and agree to the Let's Encrypt terms;
- After saving, check the certificate status.
HTTP-01 validation requires:
- The domain's A/AAAA records correctly point to the current VPS;
- Port 80 is reachable from the public internet;
- The cloud firewall, UFW and security groups allow 80/443;
- Port 80 is not occupied by another reverse proxy;
- Cloudflare or another proxy is not sending requests to the wrong origin.
If certificate issuance fails, do not click a dozen times in a row. Let's Encrypt has rate limits; fix DNS and ports first, then request again.
Many admin panels, chat applications, online terminals and development tools need WebSocket. The first thing to do is enable Websockets Support in the Proxy Host.
If it still disconnects:
- Confirm the upstream application's WebSocket path;
- Look for 400, 403 or 502 in the browser developer tools;
- Check whether the application requires a trusted proxy or the correct external URL;
- Confirm Cloudflare's WebSocket and timeout limits;
- Check the Upgrade requests in the NPM and application logs.
Do not start by pasting large blocks of Nginx configuration of unknown origin into Advanced. NPM already generates common proxy headers, and duplicate settings may cause conflicts.
NPM's Access List can do two types of basic restrictions:
- HTTP Basic Auth with username and password;
- Allow or deny access by IP or subnet.
It is suitable for temporary demo sites, simple internal tools or a second access barrier, but it is not equivalent to a full identity system. Do not rely on Basic Auth alone for the following scenarios:
- Backends that manage cloud accounts, passwords or payment information;
- Multi-person collaboration that requires auditing and single sign-on;
- Public APIs, Webhooks or mobile clients;
- Scenarios requiring MFA, device policies and fine-grained permissions.
More sensitive backends can be placed behind WireGuard, Tailscale, Headscale or Cloudflare Access. When managing NPM itself, an SSH tunnel is usually more direct than exposing port 81.
Technically you can reverse proxy npm.example.com to the admin port, but this exposes the login surface to the public internet. If you really need remote web login, at least do all of the following:
- Use a separate subdomain;
- Enable HTTPS;
- Configure an Access List or an external identity gateway;
- Have administrators use unique long passwords;
- Do not open port 81 directly;
- Set up login alerts and check logs regularly;
- When restricting the source regions or IPs allowed to access, prepare a recovery method first.
If you are the only one managing a single VPS, just keep using an SSH tunnel; there is no need to add a public entry point just to "look convenient".
When integrating Cloudflare, it is recommended to first use the grey cloud "DNS only" to complete NPM and certificate validation, confirm that direct origin access works, and then enable the orange cloud proxy.
Common points to note:
- Use
Full (strict)for the SSL/TLS mode, not Flexible; - The origin NPM still needs a valid HTTPS certificate;
- Flexible easily causes HTTP/HTTPS redirect loops;
- Cloudflare 521/522 usually indicates an origin port, firewall or connection problem;
- The real client IP requires trusted proxy configuration; do not blindly trust any
X-Forwarded-For; - Large file uploads, long connections and streaming need to be checked against Cloudflare plan limits and terms of service.
For redirect loops, see Troubleshooting HTTPS redirect loops; for 521/522, see Cloudflare 521/522 origin troubleshooting.
If the application supports large files but you get 413 Request Entity Too Large when uploading through NPM, you can set the following in the corresponding Proxy Host's Advanced as needed:
client_max_body_size 2g;
proxy_request_buffering off;
Do not set it to tens of GB without judgment. Also check in sync:
- The application's own upload limit;
- PHP, Node.js or backend framework limits;
- Cloudflare upload limits;
- VPS disk space and temporary directory;
- Request timeouts and the client network.
After modifying, verify with a controllable test file; do not use your only large file for the first test.
First look at the container output:
cd /opt/nginx-proxy-manager
docker compose logs --tail=200 app
docker compose logs --tail=100 db
NPM's persistent logs and generated configuration are stored in /opt/nginx-proxy-manager/data. During troubleshooting you can check:
sudo find /opt/nginx-proxy-manager/data -maxdepth 3 -type f | sort | tail -n 50
Do not casually hand-edit the auto-generated Nginx configuration files. The next time you save a Proxy Host in the web interface, manual changes may be overwritten. Long-term customization should go in the Advanced or custom configuration locations supported by NPM, and be verified before upgrading.
At minimum back up:
/opt/nginx-proxy-manager/data: users, configuration, JWT secret and application data;/opt/nginx-proxy-manager/letsencrypt: certificates and account information;/opt/nginx-proxy-manager/mariadb: database files;compose.yamland.env;- The current image versions and recovery instructions.
Directly copying files while the database is running does not necessarily produce a consistent backup. It is recommended to export the database first:
cd /opt/nginx-proxy-manager
docker compose exec -T db mariadb-dump \
-unpm \
-p"$NPM_DB_PASSWORD" \
npm > npm-$(date +%F).sql
The above Shell requires loading .env first:
set -a
. /opt/nginx-proxy-manager/.env
set +a
The exported SQL, configuration directory and .env should be encrypted and copied to another machine or object storage. Keeping backups only on the same VPS disk cannot handle disk failure or account suspension.
Before upgrading, check the current images and make a backup:
cd /opt/nginx-proxy-manager
docker inspect nginx-proxy-manager --format '{{.Config.Image}}'
docker compose config > compose.before-upgrade.yaml
Read the target version's Release Notes, confirm database and architecture changes, then modify the image tags in compose.yaml:
docker compose pull
docker compose up -d
docker compose ps
docker compose logs --tail=200 app
After upgrading, test at least:
- Administrator login;
- A normal HTTP upstream;
- A WebSocket application;
- HTTPS certificate status;
- Access Lists;
- Database and certificate renewal logs.
Do not jump across multiple major versions from a very old version without a backup. Image rollback is not the same as database rollback; if the database schema has already been migrated, you need to restore the pre-upgrade data as well.
This article deliberately binds 81 to 127.0.0.1, so accessing http://服务器IP:81 from the public internet will fail by design. Establish the SSH tunnel first, then access local http://127.0.0.1:8181.
Server-side check:
sudo ss -lntp | grep ':81'
curl -I http://127.0.0.1:81
The most common causes are a wrong upstream address, using the host port as the port, the container not being on the shared network, or the application only listening on 127.0.0.1.
Test inside the NPM container:
docker exec nginx-proxy-manager getent hosts my-app
docker exec nginx-proxy-manager curl -v http://my-app:3000
For a more systematic check, see VPS 502/504 Bad Gateway troubleshooting checklist.
Confirm in order:
dig +short app.example.com A
dig +short app.example.com AAAA
sudo ss -lntp | grep -E ':(80|443)\b'
docker compose logs --tail=200 app
Also check the cloud firewall and UFW. If DNS was just changed, you can wait for the TTL to take effect before requesting, to avoid repeatedly triggering failures.
This is usually not NPM automatically clearing things, but rather switching databases, mounting to a new empty directory, or Compose not reading the original .env. Check the container's actual mounts:
docker inspect nginx-proxy-manager --format '{{json .Mounts}}'
docker inspect nginx-proxy-manager-db --format '{{json .Mounts}}'
Before restoring, keep a copy of the current directory; do not directly overwrite your only data.
Check the MariaDB logs and disk:
docker compose logs --tail=200 db
df -h
df -i
Common causes include inconsistent password variables, incorrect permissions on the old database directory, a full disk, or an upgrade spanning too many versions. Do not keep deleting /config and retrying, or you will delete recoverable data as well.
First enable Websockets Support, then confirm the application's external URL, trusted proxy, heartbeat and timeout configuration. If there is also Cloudflare, a load balancer or another Nginx layer in front, you need to confirm layer by layer which segment is closing the connection.
- 80/443 are occupied by only one entry proxy;
- 81 is bound only to localhost, a VPN or a trusted management network;
- Administrators use unique random passwords, not reused with database passwords;
- MariaDB does not map public ports;
- Application containers prefer
exposeand shared networks, and do not directly publish admin ports; .envpermissions are set to 600 and not uploaded to public repositories;- Proxy Hosts only proxy services you have the right to manage;
- Basic Auth does not replace the application's own login and MFA;
- Cloudflare uses Full (strict), avoiding Flexible;
- Regularly check certificate, login, proxy and database logs;
- Pin versions before upgrading, back up the database and test recovery;
- Close Proxy Hosts, certificates and DNS records that are no longer used;
- Do not treat NPM as a WAF, vulnerability scanner or full zero-trust system.
No. It officially supports SQLite, MySQL/MariaDB and PostgreSQL. Personal testing or a small number of proxies can use SQLite; for long-term operation and when you need clear database backup and migration, a separate database is easier to manage.
Yes, but they cannot bind to the same host 80/443 at the same time. The clearest approach is to keep only one public entry proxy and put other panels and applications on internal ports or in Docker networks.
No. It itself runs on the origin. If you need to hide the origin, provide identity-based access or avoid opening entry ports, you need to separately evaluate Cloudflare Tunnel, a VPN or an independent edge proxy. There is a Cloudflare Tunnel deployment guide on the site for comparison.
NPM manages Let's Encrypt certificate renewal, but only on the premise that DNS, ports, the certificate directory and the container running state remain normal. It is recommended to configure external HTTPS expiry monitoring; do not just trust that "it will renew automatically in theory".
Yes. Use a separate subdomain and Proxy Host for each service, and enable WebSocket, upload limits and trusted proxy settings according to the application. Streaming and large files also need to consider VPS bandwidth, Cloudflare limits and terms of service.
On the first deployment, add only one unimportant test application. Confirm that DNS, HTTP, HTTPS, WebSocket and logs are all normal before migrating production services. Do not switch a dozen domains to the new proxy on the same day, otherwise when problems occur it is hard to tell whether it is DNS, NPM, the Docker network or the application configuration.
For individuals and small teams, NPM's greatest value is not "stronger performance than Nginx", but centralized management of domains, certificates and proxy configuration. The real key to stability remains: keep only one entry point, have a clear upstream network, do not leave the admin backend exposed, pin versions, and make backups recoverable.