How to Deploy Nginx Proxy Manager on a VPS
Updated Sep 2026
verified on Ubuntu 26.04 · Sep 2026Put Nginx Proxy Manager in front of your self-hosted apps — Let's Encrypt certificates from a web UI, apps on a private Docker network, and an admin panel that is not left open on port 81.
- A VPS with 1 GB RAM or more (Nginx Proxy Manager itself needs about 512 MB)
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain whose A records you can point at the server
- Docker Engine + Compose installed (see the base guide below)
What Nginx Proxy Manager is
Nginx Proxy Manager (NPM) is nginx with a web interface on top. You add a "proxy host" in a form — a domain name, the address and port of the app behind it, a tick box for a Let's Encrypt certificate — and it writes the nginx config, requests the certificate and renews it. It also does redirects, raw TCP/UDP "streams", access lists with basic auth, and custom certificates. It is MIT-licensed, built on Node.js and nginx, and ships only as a Docker image.
Its stated goal is to make reverse proxying with TLS simple enough for anyone, and it delivers on that: nothing in this guide needs you to write an nginx config. The trade-off is that the configuration lives in a database behind a UI rather than in a file you can review and commit. If you want routes defined next to each app's compose file, look at Traefik; Nginx Proxy Manager vs Traefik and Caddy vs Nginx Proxy Manager compare the options.
Server sizing
On our test box (GCP e2-standard-2, Ubuntu 26.04, Docker 29.8.1, September 2026) an idle Nginx Proxy Manager container used about 80 MB of RAM and about 1.8 GB of disk for its image and data. Our catalog lists 512 MB as the working minimum.
As with any proxy, the apps behind it decide the server size, not NPM. A 1 GB VPS runs NPM plus a couple of light apps; size up for whatever you are actually hosting.
Prepare the server
This guide assumes Docker Engine and the Compose plugin are installed. If not, work through Docker & Compose on Ubuntu first.
sudo ufw allow OpenSSH
sudo ufw allow 80
sudo ufw allow 443
sudo ufw --force enable
sudo ufw status verbose
Note that port 81, the admin UI, is not opened here — and that on its own
would not be enough. Ports published by Docker bypass ufw, because Docker
manages its own iptables rules. So the compose file below binds port 81 to
127.0.0.1 only, which is what actually keeps it off the internet.
Paid link — we earn a commission if you shop through it.
Install Nginx Proxy Manager (Docker Compose)
NPM's docs recommend a dedicated Docker network: NPM and every app it fronts join it, and the apps then need no published ports at all. Create it once:
mkdir -p ~/npm && cd ~/npm
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
NPM can create its first admin account from environment variables instead of a setup screen. Generate the password now, so the admin account never exists with a guessable one:
cd ~/npm
if [ ! -f .env ]; then
echo "INITIAL_ADMIN_EMAIL=admin@example.com" > .env
echo "INITIAL_ADMIN_PASSWORD=$(openssl rand -hex 16)" >> .env
chmod 600 .env
fi
Use your real email in place of admin@example.com — it is the admin login name. Then
write the compose file, based on the upstream setup page:
cd ~/npm
cat > docker-compose.yml <<'YAML'
services:
app:
image: jc21/nginx-proxy-manager:latest
restart: unless-stopped
ports:
- "80:80" # public HTTP
- "443:443" # public HTTPS
- "127.0.0.1:81:81" # admin UI, loopback only
environment:
TZ: "UTC"
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD}
volumes:
- ./data:/data
- ./letsencrypt:/etc/letsencrypt
healthcheck:
test: ["CMD", "/usr/bin/check-health"]
interval: 10s
timeout: 3s
networks:
default:
external: true
name: proxy
YAML
docker compose up -d
On first start NPM generates its keys, creates its SQLite database and the admin user; upstream notes this can take a minute or two. Wait for the healthcheck to go green:
cd ~/npm
for i in $(seq 1 30); do
docker compose ps --format '{{.Status}}' | grep -q '(healthy)' && break
sleep 5
done
docker compose ps
Two directories on the host now hold everything: ./data (the SQLite
database, generated nginx configs, logs) and ./letsencrypt (certificates and
their private keys).
Check it answers
From the server itself, confirm the admin API accepts the generated credentials, and that port 80 answers with NPM's default site:
cd ~/npm && . ./.env
curl -s -o /dev/null -w "admin UI: %{http_code}\n" http://127.0.0.1:81/
curl -s -X POST http://127.0.0.1:81/api/tokens -H "Content-Type: application/json" \
-d "{\"identity\":\"$INITIAL_ADMIN_EMAIL\",\"secret\":\"$INITIAL_ADMIN_PASSWORD\"}" \
| grep -q '"token"' && echo "admin login: OK"
curl -s -o /dev/null -w "public port 80: %{http_code}\n" http://127.0.0.1/
Reach the admin UI
Because port 81 only listens on the server's loopback, reach it through an SSH tunnel from your own computer:
ssh -L 8181:127.0.0.1:81 you@SERVER_IP
Then open http://localhost:8181 in your browser and sign in with the email
and the password from ~/npm/.env. Change the password under your user
menu once you are in, and delete the INITIAL_ADMIN_* lines from .env —
they only matter on the very first start.
HTTPS + domain: your first proxy host
Point an A record for the app's hostname (say app.example.com) at the
server's public IP and wait for it to resolve. Start the app on the proxy
network without publishing its port, for example:
services:
myapp:
image: example/myapp:latest
restart: unless-stopped
networks:
default:
external: true
name: proxy
Then in the admin UI, Hosts → Proxy Hosts → Add Proxy Host:
- Domain Names:
app.example.com - Scheme / Forward Hostname / Forward Port:
http,myapp(the compose service name), and the port the app listens on inside its container - Websockets Support: on for apps with live updates (chat, dashboards, code editors) — a page that loads and then never updates usually means this is off
- SSL tab: "Request a new SSL Certificate", then enable Force SSL and HTTP/2 Support
Save, and NPM runs Certbot's HTTP challenge on port 80 and reloads nginx. The certificate renews automatically. For a wildcard certificate, choose the DNS challenge on the SSL tab: NPM installs the Certbot DNS plugin for your provider. Upstream warns those plugins vary in quality and that mixing several DNS providers in one instance can cause dependency conflicts, so stick to one.
Securing it
- Keep port 81 on loopback. The admin UI controls every route and certificate on the box. The SSH tunnel is simpler and safer than exposing it, even behind its own proxy host.
- Don't publish app ports. The
proxynetwork is the point: an app with noports:line can only be reached through NPM. - Access lists (Access Lists menu) add basic auth or IP allow/deny rules to
a proxy host. One catch, from upstream's FAQ: an access list with a
username/password breaks apps that use the
Authorizationheader for their own login, because a request can only carry one. Use IP rules for those. - Change the admin password after first login, and add extra users with limited permissions rather than sharing the admin login.
Backups
The two directories are the whole application state. Stop the container for a consistent copy of the SQLite database:
cd ~/npm
docker compose stop
sudo tar czf ~/npm-backup-$(date +%F).tar.gz data letsencrypt docker-compose.yml .env
docker compose start
ls -lh ~/npm-backup-*.tar.gz
sudo is needed because files the container writes are owned by root. Copy
the archive off the box. Restoring is unpacking it into ~/npm on a new
server and running docker compose up -d; every proxy host and certificate
comes back.
Upgrades
cd ~/npm
docker compose pull
docker compose up -d
Upstream's upgrade page says NPM migrates its own database on start. Back up
first anyway, and skim the release notes for version-specific steps. If you
prefer to stay on the 2.x line instead of latest, the image is also tagged
jc21/nginx-proxy-manager:2.
Troubleshooting
502 Bad Gateway on a proxy host. NPM cannot reach the app. Confirm the
app is on the proxy network (docker network inspect proxy), that the
Forward Hostname is its compose service name, and that the port is the one
the app listens on inside the container, not a host port.
The certificate request fails. Let's Encrypt must reach the domain on port
80: check the A record resolves to this server, that no cloud firewall blocks
port 80, and read docker compose logs app for the Certbot error.
Locked out of a proxied app after adding an access list. That is the
Authorization header clash above. Remove the username/password rule from the
access list.
The admin UI does not load through the tunnel. Check the container is
healthy (docker compose ps) and that you tunnelled to 127.0.0.1:81, not
the public IP.
Verification + next steps
You're done when: the admin UI loads through the tunnel, a proxy host serves
your app at https://app.example.com with a valid certificate, plain HTTP
redirects to HTTPS, and ss -ltn on the server shows port 81 bound to
127.0.0.1 only.
From there, add a proxy host per app and keep every app on the proxy
network. If you later want each route defined in code, next to the app it
serves, the Traefik guide covers that
approach. For hosting picks, see Best VPS for Docker.