Why a reverse proxy on a VPS
A VPS exposes a single public IP address. If you run three Docker applications — an API, a front-end and an admin tool — each occupies a different port: 3000, 8080, 9000. Without a reverse proxy, your visitors must type the port in the URL, SSL certificates must be managed application by application, and exposing all those ports publicly increases the attack surface.
A reverse proxy centralizes traffic entry: it receives all requests on ports 80 and 443, inspects the domain name or path, then forwards the request to the right container on the internal network. Applications no longer expose public ports. SSL terminates at the proxy level, which redistributes plain HTTP over the private Docker network.
This architecture offers three concrete benefits: a single certificate management point, network isolation of applications, and the ability to add or remove an app without touching the others.
What Nginx Proxy Manager simplifies
- Web interface: add, modify and delete proxy hosts without command line or manual reload
- One-click Let's Encrypt SSL: NPM requests, renews and deploys certificates automatically via HTTP-01 or DNS-01
- Wildcard DNS: a single certificate for
*.mydomain.comif your DNS provider supports the Certbot API - Access lists: HTTP basic authentication or IP whitelisting directly from the interface
- Redirects and custom URLs: HTTP to HTTPS, 301/302 redirects, without modifying nginx.conf
- Reload without interruption: NPM reloads the nginx configuration in the background, without downtime
Prerequisites
To follow this guide, you need a VPS running Ubuntu 22.04 or Debian 12 with at least 1 GB of RAM (2 GB recommended if multiple apps run simultaneously). Docker Engine and Docker Compose v2 must be installed.
Ports 80 and 443 must be accessible from outside. Check that your firewall (ufw or control panel rules) allows them. If you use a cloud firewall (security group, VPS firewall), open these two ports for inbound traffic.
Finally, you must own at least one domain name or subdomain pointing to your VPS IP. NPM can manage multiple domains simultaneously — the minimum requirement is that a DNS A record exists for each domain you want to proxy.
Installing NPM with Docker Compose
Create the directory structure
Create a dedicated folder and navigate into it:
mkdir -p /opt/npm && cd /opt/npmThis folder will contain the Compose file and NPM's persistent volumes (SQLite database, certificates, logs).
Write docker-compose.yml
Create the file
/opt/npm/docker-compose.ymlwith the following content:services: app: image: jc21/nginx-proxy-manager:latest restart: unless-stopped ports: - "80:80" - "443:443" - "81:81" volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt networks: default: name: proxy-net external: falsePort
81is the administration interface. Theproxy-netnetwork will be shared with your other containers so they can be reached without exposing public ports.Start NPM
Launch the container in the background:
docker compose up -dWait 30 to 60 seconds for NPM to initialize its database. Verify that the three ports are listening:
ss -tlnp | grep -E ':(80|81|443)'First login and password change
Open
http://YOUR_VPS_IP:81in your browser. Default credentials are[email protected]/changeme.NPM forces you to change the email address and password on first login. Use a valid address: it will be used for Let's Encrypt certificate expiry notifications.
Note: port 81 is publicly exposed. Immediately configure an access list (see "Access Lists" section) or filter this port at the firewall level to restrict it to your IP.
Adding a first proxy host
Create a new proxy host
In the NPM interface, click Proxy Hosts then Add Proxy Host. Fill in the Domain Names field with your domain, for example
app.mydomain.com. Make sure the DNS A record for this subdomain already points to your VPS IP — Let's Encrypt will verify this resolution.Configure the destination
In the Forward Hostname / IP and Forward Port fields, enter the host and port of your application. If the application runs in a Docker container on the same
proxy-netnetwork, use the Docker service name as the hostname (example:myappand port3000). Check Block Common Exploits to enable basic filtering rules.Enable Let's Encrypt
Switch to the SSL tab in the same window. From the dropdown, select Request a new SSL Certificate. Check Force SSL to automatically redirect HTTP to HTTPS, and HTTP/2 Support to enable HTTP/2. Enter your email address, accept the Let's Encrypt terms, then click Save.
NPM immediately launches the certificate request via the HTTP-01 challenge. In less than a minute, your domain is accessible via HTTPS with a valid certificate.
Verify the result
The proxy host list now shows your entry with a green SSL badge. Test from your browser or with curl:
curl -I https://app.mydomain.comThe response should contain
HTTP/2 200and aserver: nginxheader. Certificate renewal is automatic — NPM relaunches the request 30 days before expiry.
Configure a subdomain with forced HTTPS redirect
Forcing HTTPS is not just a best practice: it is the foundation of transport security. When creating or editing a proxy host, the SSL tab exposes three complementary options.
Force SSL: NPM automatically generates a return 301 https://$host$request_uri; block in the nginx vhost configuration. Any HTTP request is redirected server-side before even reaching your application.
HSTS (HTTP Strict Transport Security): by checking this option, NPM adds the Strict-Transport-Security: max-age=63072000; includeSubDomains; preload header to HTTPS responses. The browser remembers that this domain must always be contacted via HTTPS, even if the user types http://. Only enable HSTS if you are certain of maintaining SSL — disabling HSTS afterwards has no immediate effect on browsers that have already cached it.
HTTP/2 Support: enables the HTTP/2 protocol on the client side, without any modification on the application side. Multiplexing reduces perceived latency, especially on pages with many resources.
Advanced case: proxy for a Docker app without exposed port
One of the most underrated advantages of NPM is the ability to proxy containers that expose no public port. Communication takes place solely on the internal Docker network.
For a container to be reachable by NPM without public exposure, both services must share the same Docker network. Example with a Node.js app in /opt/myapp/docker-compose.yml:
services:
web:
image: my-image:latest
restart: unless-stopped
# No 'ports' section — the container is not accessible from the host
networks:
- proxy-net
networks:
proxy-net:
external: trueBy declaring proxy-net as an external network and attaching the service to this network, the web container becomes reachable from NPM by its service name. In the NPM interface, Forward Hostname will simply be web and Forward Port the app's internal port (for example 3000).
This architecture means that even if an attacker compromises a container, they cannot reach other services directly from outside — everything goes through the proxy.
Access Lists: protecting the backoffice with authentication
NPM allows restricting access to certain proxy hosts via access lists. Go to Access Lists then Add Access List. Give the list a name, add entries under the Authorization tab (username + hashed password), and/or restrict by IP under Access.
Then edit the proxy host you want to protect and select this list in the Access List field. NPM automatically injects an auth_basic block into the nginx vhost configuration. This is particularly useful for exposing admin tools (Portainer, Grafana, internal API interfaces) without deploying a full authentication server.
For port 81 itself (the NPM interface), protection goes through the firewall — restrict access to this port to your fixed IP or a VPN.
Comparison: Nginx Proxy Manager vs Caddy vs Traefik
Scroll the table
| Criterion | Nginx Proxy Manager | Caddy | Traefik |
|---|---|---|---|
| Configuration | Graphical web interface, no files to edit | Declarative Caddyfile, concise syntax | YAML/TOML or Docker labels, steeper learning curve |
| Automatic SSL | Let's Encrypt HTTP-01 and DNS-01, graphical interface | Built-in natively, HTTP-01 and DNS-01 without plugin | Built-in ACME, requires YAML configuration |
| Docker auto-discovery | No, manual configuration per host | Not native, possible via labels with caddy-docker-proxy | Native via Docker labels, detects services at startup |
| Ideal use case | Developer managing fewer than 20 apps, prefers UI over config | Simple to medium stack, readable file-based config | Microservices, Kubernetes, dynamic environments |
| Advanced customization | Limited — custom nginx snippets possible but not recommended | High via modules and Caddyfile directives | Very high, chainable middlewares, rich plugins |
| Resources | ~50 MB RAM at rest | ~30 MB RAM at rest | ~40 MB RAM at rest, more depending on plugins |
NPM's limits and when to migrate to Traefik
NPM covers the vast majority of use cases for developers managing a dozen applications on one or two VPS. It starts to show its limits in several scenarios.
Advanced nginx configuration: NPM generates its configuration files and regenerates them with each change from the interface. It is technically possible to add custom snippets, but they can be overwritten during an update. If you need fine-grained nginx configurations — per-route rate limiting, complex proxy cache, advanced rewrite logic — NPM becomes a friction layer rather than a help.
Dynamic environments: in a microservices architecture where containers appear and disappear frequently, manually configuring each host in NPM becomes a bottleneck. HAProxy or Traefik, which automatically detect services via Docker labels, are better suited to this context.
Kubernetes: NPM has no place in a Kubernetes cluster. Traefik has a native Ingress Controller; ingress-nginx is the other common option.
Practical rule: if your configuration fits in the NPM interface and does not require automation scripts to stay current, NPM is the right choice. As soon as you find yourself writing scripts to interact with the NPM API or managing configuration files outside the interface, that is the signal to evaluate Caddy or Traefik depending on your context.