DEV Community

DeployLark
DeployLark

Posted on Originally published at deploylark.com

Nginx Proxy Manager with Docker: Proxy Hosts and Free SSL

Once you run more than one self-hosted app, you hit the same problem every time. Each app wants its own port, and none of them has HTTPS. Nginx Proxy Manager (NPM) fixes both. It sits in front of your apps, answers on ports 80 and 443, sends each domain to the right container, and gets free Let's Encrypt certificates for you, all from a web UI.

It's a popular choice. On October 14, 2026, the project's GitHub repository had about 34,400 stars, and the jc21/nginx-proxy-manager image had been pulled more than 285 million times from Docker Hub.

This guide covers a clean install, the first-run setup (it changed in recent versions, so a lot of older guides are wrong), your first proxy host, both ways to get a certificate, and the errors you're most likely to hit.

TL;DR:

  • Run jc21/nginx-proxy-manager:2.16.0 with ports 80 and 443 public, bind the admin UI to 127.0.0.1:81, and keep both the /data and /etc/letsencrypt volumes.
  • There's no admin@example.com / changeme login on 2.16. You create the admin account on the first-run screen, so use a real email, because NPM also uses it for Let's Encrypt.
  • Put NPM and your apps on one shared Docker network and forward to the service name and the container's internal port. localhost there means NPM itself and gives you a 502.
  • Request the certificate in the SSL tab. HTTP-01 needs DNS pointing at the server and port 80 open. DNS-01 with a scoped Cloudflare token also covers wildcards.

Install Nginx Proxy Manager with Docker Compose

Before you start

Without a reverse proxy, you reach apps like this: http://203.0.113.10:3001, http://203.0.113.10:8080, and so on. That's one open port per app and no TLS.

With a reverse proxy, only ports 80 and 443 are open. A request for https://status.example.com reaches NPM. NPM reads the hostname and passes the request to the container that serves it (for example uptime-kuma:3001) over the internal Docker network. NPM handles HTTPS, so the app behind it doesn't need to know anything about certificates.

Diagram of the setup in this guide. A browser request for https://status.example.com reaches Nginx Proxy Manager on ports 80 and 443. NPM picks the app by hostname and forwards over the proxy Docker network to uptime-kuma:3001 or n8n:5678. The admin UI listens on 127.0.0.1:81 only and is reached through an SSH tunnel.

You need:

  • A Linux server with Docker and the Docker Compose plugin.
  • A domain, with an A record for each hostname (or a wildcard *.example.com) pointing at the server's public IP.
  • Ports 80 and 443 reachable from the internet (cloud firewall, router port forwarding). Port 80 matters even if you only want HTTPS, because Let's Encrypt's HTTP-01 check only works on port 80.
  • Nothing else already listening on 80, 443 or 81 on the host.

The compose file and first start

Create a folder and a compose file. This is the official quick-setup file with three changes. The image is pinned to the current release, there's a timezone you'll want to change, and the admin port is bound to 127.0.0.1:

mkdir -p ~/npm && cd ~/npm
nano compose.yaml
Enter fullscreen mode Exit fullscreen mode
services:
  app:
    image: 'jc21/nginx-proxy-manager:2.16.0'
    restart: unless-stopped
    ports:
      - '80:80'              # public HTTP
      - '443:443'            # public HTTPS
      - '127.0.0.1:81:81'    # admin UI, only reachable from the server itself
    environment:
      TZ: "Europe/London"   # change to your timezone
      # DISABLE_IPV6: 'true'  # uncomment if your host has no IPv6
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
Enter fullscreen mode Exit fullscreen mode

Start it:

docker compose up -d
docker compose logs -f app
Enter fullscreen mode Exit fullscreen mode

The first start runs database migrations and takes a little while. When you see Backend PID ... listening on port 3000, it's ready. Check from the server itself, since port 81 only answers there now:

curl -s http://127.0.0.1:81/api/
Enter fullscreen mode Exit fullscreen mode

On a fresh install that returned:

{"status":"OK","setup":false,"version":{"major":2,"minor":16,"revision":0}}
Enter fullscreen mode Exit fullscreen mode

"setup":false means no admin account exists yet. That's the next step.

Why bind port 81 to localhost? It's your admin panel, and upstream's file publishes '81:81' on every interface. A host firewall won't save you here. Docker's firewall docs say that when you publish a container's ports, "traffic to and from that container gets diverted before it goes through the ufw firewall settings." On my test box, with the 127.0.0.1 binding, the API answered on 127.0.0.1 but not on the server's network address. To use the admin UI from your laptop, open an SSH tunnel:

ssh -L 8181:127.0.0.1:81 you@your-server
Enter fullscreen mode Exit fullscreen mode

Then browse to http://localhost:8181. Once NPM works, you can also put the admin UI behind its own proxy host with an access list.

A few more things I learned in testing:

  • Keep both volumes. When I started the image without /etc/letsencrypt mounted, it refused to start and logged ERROR: /etc/letsencrypt is not mounted! Check your docker configuration.
  • SQLite is the default and is fine for a single server. The full setup docs also have MariaDB, MySQL and Postgres variants.
  • The project doesn't publish minimum requirements. On my test box the idle container used about 110 to 130 MB of RAM.
  • Images are built for amd64 and arm64. The setup docs say armv7 was dropped in 2.14 and point 32-bit ARM users to the 2.13.7 tag.

Create the admin account

The setup screen replaced the default login

Open the admin UI through the SSH tunnel at http://localhost:8181. If you kept upstream's '81:81', use http://<server-ip>:81.

If you've read an older guide, you're expecting to log in with admin@example.com and changeme. That was true up to the 2.12.x releases (it's in the v2.12.6 README), but it was removed from the README in 2.13.0. On 2.16.0 there is no default account. You get a "Welcome! Get started by creating your admin account" screen with Full Name, Email address and New Password.

Nginx Proxy Manager 2.16 first-run setup screen, where you create the admin account with a full name, email address and password

Use a real email address here. NPM uses your account email as the Let's Encrypt account email. In my test the certificate request ran certbot ... -m admin@lab.test ..., and Let's Encrypt rejected it with The ACME server believes admin@lab.test is an invalid email address. A made-up address here means certificate requests fail later.

Scripted setup and two-factor login

If you'd rather create the admin account without the setup screen (useful for scripted installs), the advanced config docs list two variables for it. Don't type the password into compose.yaml. Put it in a .env file next to it that only you can read:

cat > .env <<'EOF2'
INITIAL_ADMIN_EMAIL=you@example.com
INITIAL_ADMIN_PASSWORD=a-long-random-password
EOF2
chmod 600 .env
Enter fullscreen mode Exit fullscreen mode

Then reference the variables in compose.yaml:

    environment:
      INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL}
      INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD}
Enter fullscreen mode Exit fullscreen mode

After the first start, delete both lines from compose.yaml, delete .env, and run docker compose up -d. I tested this. The account was created, /api/ reported "setup":true, and after removing the variables and recreating the container I could still log in with the same password. Change the password in the UI if it was ever stored anywhere else.

NPM has supported TOTP two-factor authentication since 2.13.6. The 2.16 UI includes an "Enable Two-Factor Authentication" option for your account. Turn it on, because this panel controls all your traffic.

Add a proxy host

Connect NPM to your apps with a shared network

The easiest way to connect NPM to your apps is a shared Docker network. NPM then reaches each app by its service name, and the apps don't need to publish any ports to the host. This is the approach the NPM docs recommend:

docker network create proxy
Enter fullscreen mode Exit fullscreen mode

Add this to the bottom of NPM's compose.yaml and to each app's compose file:

networks:
  default:
    external: true
    name: proxy
Enter fullscreen mode Exit fullscreen mode

Then run docker compose up -d again in each folder. If an app's compose file already defines its own networks, attach just the web service to proxy instead.

In the NPM UI, go to Hosts > Proxy Hosts > Add Proxy Host. The Details tab has:

  • Domain Names: status.example.com
  • Scheme: http. This is the protocol between NPM and the app, which is almost always plain HTTP.
  • Forward Hostname / IP: the container's service name, for example uptime-kuma.
  • Forward Port: the port the app listens on inside its container (for example 3001), not a host port.
  • Access List: leave it as Publicly Accessible for now.
  • Options: Block Common Exploits is a sensible default. Turn on Websockets Support for any app with a live UI (see below).

This is the Details tab from my lab run, pointing a test hostname at a container named whoami:

Add Proxy Host dialog in Nginx Proxy Manager 2.16, Details tab: domain status.lab.test, scheme http, forward hostname whoami, forward port 80, access list Publicly Accessible, with Block Common Exploits and Websockets Support switched on

Save, then test from any machine:

curl -I http://status.example.com
Enter fullscreen mode Exit fullscreen mode

In my lab, a proxy host pointing at a container named whoami on the shared network returned that container's page, and a hostname with no proxy host got NPM's "Congratulations!" default site. You can change that default under Settings > Default Site.

The saved host shows up in the Proxy Hosts list:

Nginx Proxy Manager Proxy Hosts list with one entry: source status.lab.test, destination http://whoami:80, SSL HTTP Only, access Public, status Online

For a full example app behind NPM, see Uptime Kuma with Docker.

Websockets, access lists and custom locations

Some apps won't work at all without HTTPS. Vaultwarden's web vault is one, as the Vaultwarden HTTPS guide shows. I tested that fix with Caddy, and an NPM proxy host with a Let's Encrypt certificate should fix it the same way.

Websockets Support (Details tab) matters for apps with a live UI, such as Uptime Kuma, n8n, Home Assistant or Open WebUI. They break in strange ways without it, with "connection lost" banners and dashboards that never update. The n8n guide and the Open WebUI guide show what that looks like. When I turned the toggle on, NPM added these lines to the generated nginx config:

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
Enter fullscreen mode Exit fullscreen mode

If you aren't sure whether an app needs it, turn it on. It doesn't hurt plain HTTP apps.

Access lists (Access Lists menu) hold HTTP basic-auth users and allow or deny IP rules. Pick one in a proxy host's Access List field. It's a cheap way to lock down admin panels that have weak or no auth of their own. NPM 2.16.0 also added per-path access lists inside the proxy host modal.

Custom Locations (a tab in the proxy host) send a path such as /api to a different container or port than the rest of the host. Most single-app hosts don't need them.

The Advanced tab (gear icon) takes raw nginx directives for the server block. Use it sparingly. If a snippet breaks the config, 2.16.0 keeps the failed file as .conf.err so you can see what went wrong.

Get a free Let's Encrypt certificate

Edit the proxy host and open the SSL tab. Under SSL Certificate, pick "Request a new Certificate (with Let's Encrypt)". The other toggles are:

  • Force SSL redirects HTTP to HTTPS. Turn it on once the certificate works.
  • HTTP/2 Support is safe to enable.
  • HSTS Enabled and HSTS Sub-domains tell browsers to use only HTTPS for this host (and its subdomains) for a long time. Leave these off until you're sure HTTPS works everywhere, because they're hard to undo.

SSL tab of the Add Proxy Host dialog with the SSL Certificate dropdown open, offering None (this host will not use HTTPS) or Request a new Certificate with Let's Encrypt

Then pick a challenge type.

HTTP-01 (the default)

Per Let's Encrypt's docs, HTTP-01 asks for a token at http://<your-domain>/.well-known/acme-challenge/... and checks that your server returns it. NPM serves that path itself, from a letsencrypt-acme-challenge folder in its data volume. The challenge can only succeed when DNS for the hostname already points at this server and port 80 is reachable from the internet. On Oct 14, 2026, I verified this domain and HTTP-01 path by getting a real Let's Encrypt certificate over HTTP-01 on a public Debian 13 server running Caddy, while NPM's own certificate request screen follows NPM's documentation.

HTTP-01 can't issue wildcard certificates, and it fails if your ISP blocks port 80.

NPM 2.16's code also includes a reachability check for HTTP-01. It writes a test file and asks an outside service (site24x7.com) to fetch it from your domain. Run it before you request a real certificate, because failed attempts count toward rate limits (covered below).

DNS-01 with Cloudflare

With DNS-01, according to Let's Encrypt's docs, certbot proves you control the domain by creating a TXT record at _acme-challenge.<your-domain>. Use it when:

  • you want a wildcard certificate (*.example.com),
  • the server isn't reachable on port 80 (a home lab behind CGNAT, or an internal-only service), or
  • you have several servers.

In the SSL tab, turn on Use DNS Challenge, choose your DNS Provider, and paste the credentials. NPM 2.16.0 ships certbot plugins for a long list of providers, Cloudflare included. For Cloudflare, NPM's template is:

# Cloudflare API token
dns_cloudflare_api_token=your-cloudflare-api-token
Enter fullscreen mode Exit fullscreen mode

Create an API token (not the Global API Key) in the Cloudflare dashboard. The certbot-dns-cloudflare docs say it needs Zone:DNS:Edit permission, only for the zones you need certificates for.

Two cautions:

  • That token can change your DNS. Let's Encrypt's challenge docs warn that keeping DNS API credentials on a web server raises the stakes if the server is compromised, so scope the token as narrowly as you can.
  • 2.16.0 updated certbot, and the release notes warn that some DNS plugins may need dependency tweaks. If your provider's plugin errors after an upgrade, check the issue tracker before you assume your token is wrong.

Fix common SSL errors

Certificate requests that fail

"Internal Error" when requesting a certificate

This is NPM's generic message. The real reason is in the certbot log, which sits in your data volume:

sudo tail -n 50 ~/npm/data/logs/letsencrypt.log   # the data folder is owned by root
docker compose logs app | grep -i -A5 "SSL\|certbot"
Enter fullscreen mode Exit fullscreen mode

The usual causes are DNS not pointing at the server yet, port 80 blocked, or an invalid account email (see above).

Challenge fails with "Timeout", "Connection refused" or the wrong IP

Let's Encrypt couldn't fetch the token. Check these from outside your network (mobile data works):

dig +short status.example.com       # should print your server's public IP
curl -I http://status.example.com   # should answer from NPM, not time out
Enter fullscreen mode Exit fullscreen mode

If the IP is wrong, fix DNS and wait for it to update. If you get a timeout, open port 80 in the cloud firewall or router. If you use Cloudflare's proxy (orange cloud) and HTTP-01 keeps failing, switch to the DNS challenge, which doesn't depend on the HTTP path.

Rate limited

Retrying over and over burns Let's Encrypt's rate limits. The ones you're most likely to hit while troubleshooting:

  • 5 failed validations per hostname, per account, per hour.
  • 5 certificates for the exact same set of hostnames every 7 days. Reinstalling and requesting again is the classic way to hit this one.
  • 50 certificates per registered domain every 7 days.

Revoking certificates doesn't reset these limits. Fix the cause first, test it, then request once.

Errors after the certificate is issued

ERR_TOO_MANY_REDIRECTS behind Cloudflare

If Cloudflare's SSL/TLS mode is Flexible, Cloudflare talks to your server over plain HTTP. With Force SSL on, NPM redirects that to HTTPS, and Cloudflare requests HTTP again, in a loop. Cloudflare's fix is to set the mode to Full or higher, which needs a valid certificate on the origin (NPM's Let's Encrypt certificate works here), or else to stop redirecting at the origin.

502 Bad Gateway after a "successful" setup

The certificate is fine, but NPM can't reach the app. The most common mistake is putting localhost or 127.0.0.1 in Forward Hostname. Inside the NPM container, that address means NPM itself. In my lab, a proxy host pointed at 127.0.0.1:8080 returned 502, and the host's error log said:

connect() failed (111: Connection refused) while connecting to upstream ... upstream: "http://127.0.0.1:8080/"
Enter fullscreen mode Exit fullscreen mode

Use the app's service name on the shared Docker network, with the container's internal port. If the app runs directly on the host instead of in Docker, use the host's LAN IP. Per-host logs are in ~/npm/data/logs/proxy-host-<id>_error.log.

FAQ

Logging in and updating

Why doesn't admin@example.com / changeme work?

Because 2.16 has no default account. Since 2.13.0, a fresh install shows a setup screen where you create the admin account yourself.

How do I update Nginx Proxy Manager?

The upgrading page says to run docker compose pull and docker compose up -d, and adds: "This project will automatically update any databases or other requirements so you don't have to follow any crazy instructions." Some releases do add migrations, so I'd back up first. With a pinned tag, change it in compose.yaml, then:

cd ~/npm
docker compose stop                       # don't copy the SQLite file while it's in use
sudo cp -a data data.bak-$(date +%F)
docker compose pull && docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Read the release notes before major bumps. 2.15.0 and 2.16.0 both updated certbot and warned that DNS plugins may need changes. Version 2.13.3 added a migration and warned that you can't easily downgrade after it.

Why are my settings gone after Docker restarts?

NPM keeps its database and config in /data and its certificates in /etc/letsencrypt. If those aren't mounted to folders or volumes on the host, recreating the container starts from scratch. Use both volume lines from the compose file above. The official image won't even start without /etc/letsencrypt mounted.

Certificates and errors

How do I get a wildcard certificate?

Use the DNS challenge. HTTP-01 can't issue wildcards. Request a certificate for *.example.com (and example.com if you want the bare domain too), turn on Use DNS Challenge, pick your provider and paste a scoped API token, as in the Cloudflare example above.

Why do I get 502 Bad Gateway?

NPM can't reach the app at the Forward Hostname and port you gave it. Don't use localhost or 127.0.0.1, which mean the NPM container itself. Put NPM and the app on the same Docker network and use the service name and the container's internal port.

How do I protect the admin panel on port 81?

Don't publish it to the internet. Port 81 serves the login form over plain HTTP. The compose file above binds it to 127.0.0.1, and on my test box it then answered only on the server itself. Reach it through an SSH tunnel (ssh -L 8181:127.0.0.1:81 you@your-server). A reply in issue #481 suggests another option: a proxy host such as npm.example.com that forwards to 127.0.0.1 port 81 with SSL, after which you remove the port 81 mapping from the compose file. If you go that way, I'd also add an access list to that proxy host so only your own IP addresses can reach it. Turn on two-factor login either way.


Originally published on DeployLark: Nginx Proxy Manager with Docker: Proxy Hosts and Free SSL

Top comments (0)