Nginx Proxy Manager with Docker Compose: Reverse Proxy and Free SSL Setup

nginx proxy manager docker compose

Table of Contents

  1. What is Nginx Proxy Manager and Why Use It
  2. How a Reverse Proxy Works
  3. NPM vs Writing Nginx Config by Hand
  4. Prerequisites
  5. Project Structure
  6. Step 1: Deploy Nginx Proxy Manager with Docker Compose
  7. Step 2: First Login and Credential Setup
  8. Step 3: Add Your First Proxy Host
  9. Step 4: Enable Free SSL with Let’s Encrypt
  10. Step 5: Integrate with Other Docker Containers
  11. Step 6: Access Control Lists
  12. Step 7: DNS Challenge for Internal Services (Cloudflare)
  13. Securing the Admin Panel (Port 81)
  14. Common Issues and Quick Fixes
  15. NPM vs Traefik: Which One to Choose
  16. Next Steps

Every Docker-based infrastructure eventually runs into the same problem: you have multiple services — a dashboard on port 3000, an API on port 8080, a database UI on port 8888 — and you need to expose them cleanly to the outside world without opening a dozen ports or manually writing Nginx server blocks every time something changes.

Nginx Proxy Manager (NPM) solves this with a web-based GUI built on top of the same Nginx engine that powers a significant share of the internet. Adding a new service behind a reverse proxy takes about 30 seconds. Getting a free SSL certificate from Let’s Encrypt is a single checkbox. No config files to edit by hand, no certbot commands to memorize, no manual certificate renewals.

What is Nginx Proxy Manager and Why Use It

NPM is an open-source Docker image that wraps Nginx in a point-and-click management interface. Under the hood it’s generating real Nginx server blocks — the same configuration you’d write by hand — but it handles:

  • Reverse proxy routing — forward traffic by domain name to any backend (container, IP, hostname)
  • Free SSL certificates — automated Let’s Encrypt issuance and renewal, no certbot needed
  • HTTP to HTTPS redirect — one checkbox forces all traffic to SSL
  • HSTS and HTTP/2 — toggleable in the GUI without touching config files
  • Access control lists — restrict specific proxy hosts to IP ranges or require HTTP basic auth
  • Stream proxying — TCP/UDP passthrough for non-HTTP services

If you’re already running Docker with Docker Compose, NPM fits into your existing stack without any additional dependencies beyond a shared Docker network.

How a Reverse Proxy Works

Understanding the traffic flow makes troubleshooting much easier:

Browser / Client
      │
      │ requests app.yourdomain.com:443
      ▼
Nginx Proxy Manager (:80, :443)
      │
      │ looks at the hostname in the request
      │ matches "app.yourdomain.com" → forwards to grafana:3000
      ▼
Backend Container (Grafana, Superset, etc.)
      │
      │ returns response to NPM
      ▼
Nginx Proxy Manager → returns response to browser

The key insight: your backend containers never need to be directly reachable from the internet. They don’t need their own ports exposed to the host. NPM sits in front of everything and routes by hostname, acting as the single public entry point for all services.

NPM vs Writing Nginx Config by Hand

Nginx Proxy ManagerHand-written Nginx config
Setup time for new proxy host~30 seconds (GUI)5-15 minutes (config file + reload)
SSL certificateCheckbox in UIManual certbot + renewal cron
Config errorsValidated before savingBreaks nginx until fixed manually
Learning curveMinimalRequires Nginx knowledge
Flexibility/advanced configGood (custom Nginx directives via “Advanced” tab)Unlimited
Best forSelf-hosted, homelabs, small teamsLarge-scale, custom requirements, infra-as-code

NPM is not trying to replace hand-written Nginx for every use case. It is the right tool when you want a reverse proxy running in an hour, not a day — and when the team managing it may not have deep Nginx expertise.

Prerequisites

  • Docker and Docker Compose installed — see our Install Docker on Ubuntu 26.04 LTS guide
  • A domain name with DNS pointing to your server’s public IP (A record for your domain or a wildcard *.yourdomain.com)
  • Ports 80 and 443 open on your server’s firewall (required for Let’s Encrypt HTTP challenge)
  • Port 81 accessible from your local machine (admin panel — restrict this later)

Project Structure

npm-stack/
├── compose.yml
├── .env
├── data/          ← NPM config and proxy data (auto-created)
└── letsencrypt/   ← SSL certificates (auto-created)
mkdir -p ~/npm-stack && cd ~/npm-stack

Step 1: Deploy Nginx Proxy Manager with Docker Compose

.env file:

NPM_ADMIN_EMAIL=your@email.com
NPM_INITIAL_ADMIN_PASSWORD=changeme123
TZ=Asia/Jakarta
# compose.yml
version: '3.8'

networks:
  proxy:
    name: proxy
    driver: bridge

services:
  npm:
    image: jc21/nginx-proxy-manager:latest
    container_name: nginx-proxy-manager
    restart: unless-stopped
    ports:
      - '80:80'      # HTTP - public, required for Let's Encrypt HTTP challenge
      - '443:443'    # HTTPS - public
      - '81:81'      # Admin UI - restrict this after setup
    environment:
      TZ: ${TZ}
      # Optional: pre-configure first admin user (NPM 2.x+)
      INITIAL_ADMIN_EMAIL: ${NPM_ADMIN_EMAIL}
      INITIAL_ADMIN_PASSWORD: ${NPM_INITIAL_ADMIN_PASSWORD}
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    networks:
      - proxy
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:81/api/"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s

The proxy network is created as an external named network (name: proxy). This means other Docker Compose stacks on the same host can join this network by name, putting their containers directly reachable by NPM using service names — without exposing ports to the host machine.

Start the stack:

docker compose up -d
docker compose logs -f npm

Wait until you see Server Listening on 0.0.0.0:81 in the logs (typically 30-60 seconds on first boot).

Step 2: First Login and Credential Setup

Open http://<your-server-ip>:81 in a browser.

Default credentials (if not pre-configured via environment variables):

  • Email: admin@example.com
  • Password: changeme

NPM immediately prompts you to change both the email address and password on first login — do this before doing anything else. The admin panel has full control over every proxy rule and SSL certificate on the server; default credentials left in place have been exploited in the wild.

After changing credentials, you’ll see the NPM dashboard: Proxy Hosts, Redirection Hosts, Streams, SSL Certificates, and Access Lists.

Step 3: Add Your First Proxy Host

A Proxy Host is a rule that says: “when a request comes in for app.yourdomain.com, forward it to this backend.”

  1. Click Proxy Hosts → Add Proxy Host
  2. Fill in the Details tab:
  • Domain Names: app.yourdomain.com (the public-facing hostname)
  • Scheme: http (NPM talks to your backend over HTTP internally; SSL termination happens at NPM)
  • Forward Hostname / IP: the container name (e.g., grafana) or an IP address
  • Forward Port: the port your service listens on internally (e.g., 3000 for Grafana)
  • Websockets Support — enable this for any service that uses WebSockets (Grafana alerts, chat apps, code editors)
  1. Click Save

Your service is now reachable at http://app.yourdomain.com (plain HTTP). SSL comes next.

Step 4: Enable Free SSL with Let’s Encrypt

  1. Edit the proxy host you just created → click the SSL tab
  2. SSL Certificate: select “Request a new SSL Certificate”
  3. Force SSL — redirects all HTTP traffic to HTTPS automatically
  4. HTTP/2 Support — improves performance for modern browsers
  5. HSTS Enabled — tells browsers to always use HTTPS (enable after confirming SSL works)
  6. Enter your email address for Let’s Encrypt expiry notifications
  7. ✅ Agree to Let’s Encrypt Terms of Service
  8. Click Save

NPM contacts Let’s Encrypt, completes the HTTP-01 challenge (Let’s Encrypt reaches your domain on port 80 to verify ownership), receives the certificate, and configures Nginx to use it — all automatically. The certificate renews itself before expiry; no cron jobs needed.

Rate limit warning: Let’s Encrypt allows 5 certificate requests per domain per week. Don’t request and delete certificates repeatedly during testing — use a staging certificate instead by checking “Use a staging Let’s Encrypt Server” while testing, then switch to production once everything works.

Step 5: Integrate with Other Docker Containers

This is where NPM becomes genuinely powerful. Instead of exposing ports for every container, join them to the same proxy network and let NPM route by container name.

Example: putting Grafana from your Prometheus + Grafana monitoring stack behind NPM with a real domain and SSL.

Add to your monitoring stack’s compose.yml:

# In your existing monitoring stack compose.yml
networks:
  monitoring:
    driver: bridge
  proxy:
    external: true    # ← Join the existing proxy network created by NPM

services:
  grafana:
    image: grafana/grafana:latest
    container_name: grafana
    networks:
      - monitoring    # Internal: Grafana talks to Prometheus
      - proxy         # External: NPM can reach Grafana
    # Remove the ports: section entirely — no direct port exposure needed
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD}

Then in NPM, add a proxy host:

  • Domain: grafana.yourdomain.com
  • Forward Hostname: grafana (Docker service name)
  • Forward Port: 3000
  • SSL: Request certificate, enable Force SSL

Grafana is now reachable at https://grafana.yourdomain.com with a valid SSL certificate — and port 3000 is no longer exposed to the internet at all. The same pattern works for any Docker service: Apache Superset on port 8088, Portainer on port 9000, or any custom app you run.

The complete picture with multiple services behind NPM:

Internet
    │
    ▼
NPM (:80, :443)
    ├── grafana.yourdomain.com  →  grafana:3000   (monitoring)
    ├── superset.yourdomain.com →  superset:8088  (analytics)
    ├── portainer.yourdomain.com→  portainer:9000 (docker mgmt)
    └── app.yourdomain.com      →  myapp:5000     (your app)

All four services share the proxy network with NPM. None of them expose ports directly to the host or internet.

Step 6: Access Control Lists

Not every proxied service should be public. NPM’s Access Lists let you restrict a proxy host to specific IP ranges or require HTTP basic authentication.

To create an Access List:

  1. Go to Access Lists → Add Access List
  2. Name it (e.g., “Internal Only” or “Admin Services”)
  3. Under Allow: add your office/home IP range (e.g., 192.168.1.0/24 for local network only)
  4. Optionally add Authorization — username + password for HTTP basic auth on top of IP filtering
  5. Save the list

Apply it to a proxy host:

  1. Edit any proxy host → Details tab
  2. Access List: select the list you created
  3. Save

This is the recommended approach for admin panels (Portainer, Grafana admin, NPM’s own port 81 if proxied) — restrict by IP so they’re never reachable from the open internet even if someone knows the URL.

Step 7: DNS Challenge for Internal Services (Cloudflare)

The HTTP-01 challenge (default Let’s Encrypt method) requires port 80 to be reachable from the internet. If you want SSL certificates for internal services that should never be publicly accessible — services only reachable via VPN, for example — use the DNS-01 challenge instead.

With Cloudflare as your DNS provider:

  1. Get a Cloudflare API token with Zone:DNS:Edit permissions for your domain
  2. In NPM, go to SSL Certificates → Add SSL Certificate → Let’s Encrypt
  3. Enter your domain (e.g., *.internal.yourdomain.com for a wildcard)
  4. ✅ Check Use a DNS Challenge
  5. Select Cloudflare as the provider
  6. Enter your Cloudflare API token
  7. Save — NPM creates a DNS TXT record via the Cloudflare API, Let’s Encrypt verifies it, certificate issued

The benefit: port 80 never needs to be open. Let’s Encrypt validates via DNS, not HTTP. Your internal service gets a real, trusted SSL certificate without any internet exposure.

Securing the Admin Panel (Port 81)

Port 81 is the biggest security risk in a default NPM deployment. Anyone who reaches it has full control over your proxy configuration and SSL certificates. Three options, in order of preference:

Option 1: Firewall rule (simplest)

# Allow port 81 only from your IP, block everything else
sudo ufw allow from YOUR.HOME.IP.HERE to any port 81
sudo ufw deny 81

Option 2: Put NPM admin behind itself
Create a proxy host pointing npm.yourdomain.comnpm:81, add an Access List restricting it to your IP, enable SSL — then remove port 81 from the host’s published ports in compose.yml. NPM proxies its own admin panel through itself.

Option 3: VPN-only access
Only allow port 81 from the VPN subnet. Anyone who needs to manage NPM connects via VPN first.

Whatever approach you choose, the key point from our Docker Container Security Best Practices guide applies directly here: any admin interface with full system control should not be exposed to the open internet.

Common Issues and Quick Fixes

SymptomLikely CauseFix
Can’t reach admin UI on port 81Firewall blocking port 81, or using https:// by mistakeAdmin panel is plain HTTP; try http://ip:81. Check ufw status
SSL certificate won’t issuePort 80 not reachable from internet, or DNS not yet propagatedConfirm port 80 is open; wait for DNS propagation; check Let’s Encrypt rate limits
502 Bad GatewayNPM can’t reach the backend containerVerify both containers are on the same Docker network; use container name, not localhost
WebSocket connections dropWebsockets not enabled on proxy hostEdit proxy host → Details tab → enable “Websockets Support”
Container name not resolvingBackend container on a different network than NPMAdd proxy network to both NPM and the backend container’s Compose config
Certificate works but browser shows “not secure”HSTS not enabled, or mixed content (HTTP resources on HTTPS page)Enable HSTS in SSL tab; fix mixed content in your app
Let’s Encrypt rate limit hitToo many certificate requests in 7 daysWait 7 days or use staging certificates for testing

NPM vs Traefik: Which One to Choose

A common question for anyone setting up a Docker-based reverse proxy:

Nginx Proxy ManagerTraefik
Configuration methodWeb GUILabels in compose.yml / config files
Learning curveLowModerate to high
Docker integrationManual (shared network)Native (auto-discovers containers via labels)
Best forSelf-hosted, homelabs, small teamsDevOps/infra-as-code, automated deployments
DashboardBuilt-in web UIBuilt-in web UI
SSL automationLet’s Encrypt (HTTP + DNS challenge)Let’s Encrypt (HTTP + DNS challenge)
Config in version controlNot natural (config stored in DB)Native (labels/files are code)

Use NPM when: you want something running in under an hour, the team isn’t deeply technical, or you prefer clicking over YAML.

Use Traefik when: you want containers to self-register as proxy hosts automatically (zero-touch deployments), or you need infra-as-code where the proxy config lives in Git alongside everything else.

Both are valid choices — the “right” answer depends on your workflow, not which one is technically superior.

Conclusion

With Nginx Proxy Manager running, every other Docker-based service in your stack can now have a real domain and SSL certificate in minutes:

  • Put your Prometheus + Grafana monitoring stack behind NPM — grafana.yourdomain.com with HTTPS, no direct port exposure
  • Put Apache Superset behind NPM — superset.yourdomain.com with SSL and access list for internal access only
  • Apply access lists to any admin-facing service — Docker Container Security Best Practices covers the full principle of restricting management interfaces
  • Add a Cloudflare DNS challenge certificate for wildcard domains — one certificate covers every subdomain you create

NPM turns what used to be an afternoon of Nginx config files and certbot commands into a 30-second form submission. The time saved compounds every time you add a new service to your stack.

(Visited 2 times, 2 visits today)

You may also like