How To Install Nginx Proxy Manager on Ubuntu 26.04 LTS

Install Nginx Proxy Manager on Ubuntu 26.04

Every sysadmin eventually hits the same wall. You have three apps on one VPS, a Grafana dashboard on port 3000, a Nextcloud instance on 8080 and some half-forgotten Node service on 5173. Clients and teammates are typing http://203.0.113.10:8080 into their browsers, and nobody is happy about it. Certificates are either missing or renewed by hand with a cron job someone wrote in 2021.

A reverse proxy fixes this. The classic answer is to hand-write Nginx server blocks, run Certbot and keep the configs in your head. That works, and plenty of production servers still run that way. But when you manage a dozen domains, or when colleagues who aren’t comfortable in /etc/nginx/sites-available need to add a host, a web interface starts to make real sense. That is where Nginx Proxy Manager (NPM) fits in.

NPM wraps Nginx in a clean admin panel. You get proxy hosts, redirections, streams, access lists and automated Let’s Encrypt certificates without touching a config file. Ubuntu 26.04 LTS, codenamed Resolute Raccoon, was released on April 23, 2026, and is supported with standard security updates until May 2031. It is a solid base for this kind of setup.

This guide walks through a clean install on Ubuntu 26.04. It covers Docker, Compose, the firewall, first login, SSL, hardening and troubleshooting. It also shares a few lessons that usually only show up after something breaks at 2 a.m.

What Nginx Proxy Manager Actually Is

NPM is a Docker-based application. It bundles Nginx, a Node.js backend, a web UI and Certbot logic into one image published as jc21/nginx-proxy-manager. Configuration is stored in a database. By default that is SQLite, and MariaDB is supported for heavier use.

When you add a proxy host in the UI, NPM generates Nginx config files, validates them and reloads Nginx. You never edit those files directly. That is both the strength and the limit of the tool.

When NPM is the right choice

  • Small to mid-sized self-hosted stacks with many subdomains.
  • Homelabs and internal tools that need valid HTTPS certificates.
  • Teams where a non-sysadmin needs to publish a service safely.
  • Staging servers where hosts change often.

When to think twice

  • Large, high-traffic edge deployments where you need version-controlled, reviewable config. Plain Nginx managed by Ansible or Terraform fits better there.
  • Setups needing advanced Nginx modules or heavy Lua scripting.
  • Environments where an admin UI is simply not allowed to exist.

Be honest about which category you are in before you commit. A GUI is convenient, but it is also one more attack surface.

Prerequisites and Planning

Don’t skip this section. Most failed installs trace back to something here.

You need:

  • A fresh or updated Ubuntu 26.04 LTS server, either bare metal, a VM or a cloud instance.
  • A user with sudo privileges.
  • At least 1 vCPU and 1 GB RAM. NPM idles at a few hundred MB, but 2 GB gives breathing room.
  • A domain name with DNS access.
  • Ports 80 and 443 free. If Apache or a host-level Nginx is already running, stop and disable it first.

DNS comes first

Create A records (and AAAA if you use IPv6) pointing your subdomains to the server’s public IP before you request any certificates. A wildcard record such as *.example.com saves effort later. DNS propagation delays are the number one reason Let’s Encrypt validation fails on a first attempt.

Check what is already listening

sudo ss -tulpn | grep -E ':(80|443|81)\s'

If anything shows up, resolve it now. Otherwise Docker will refuse to bind the ports and the container will fail to start.

Step 1: Update Ubuntu 26.04

sudo apt update && sudo apt upgrade -y
sudo reboot

Reboot if a new kernel was installed. It’s easy to skip, but running Docker on a kernel that no longer matches the installed modules produces strange networking behavior later.

Set the hostname and timezone while you’re here. Accurate time matters for certificate validation and log correlation.

sudo hostnamectl set-hostname proxy01
sudo timedatectl set-timezone Asia/Jakarta
timedatectl status

Adjust the timezone to your own location.

Step 2: Install Docker Engine from the Official Repository

Ubuntu’s own repositories carry a docker.io package, and it does work on 26.04. The catch is that it tends to lag behind upstream. Docker’s official documentation lists Ubuntu 26.04 (Resolute) as a supported release for its own apt repository, so the better route is to use that.

Remove conflicting packages

sudo apt remove -y docker.io docker-compose docker-doc podman-docker containerd runc 2>/dev/null

It is fine if some of these are not installed.

Add Docker’s repository

sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF

On 26.04 that codename expression resolves to resolute. This deb822 format is the same one Docker documents for current Ubuntu releases.

Install the packages

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Verify the service:

sudo systemctl enable --now docker
sudo systemctl status docker --no-pager
docker --version
docker compose version

Note the command is docker compose (with a space), which is the plugin form. The old standalone docker-compose binary is legacy.

A note on the docker group

You can add your user to the docker group so you can skip sudo:

sudo usermod -aG docker $USER
newgrp docker

Be aware that docker group membership is effectively root-equivalent. On a shared server, think twice. Ubuntu 26.04 also ships the Rust-based sudo-rs by default, which is mostly transparent but can behave slightly differently in scripts that rely on obscure sudo flags. If an old provisioning script suddenly complains, test it interactively first.

Step 3: Prepare the Directory Structure

Keep everything in one predictable place. When you migrate or back up, you’ll be glad you did.

sudo mkdir -p /opt/npm/{data,letsencrypt}
sudo chown -R $USER:$USER /opt/npm
cd /opt/npm

The two subfolders matter:

  • data holds the SQLite database, generated Nginx configs and logs.
  • letsencrypt holds your certificates and ACME account data.

Lose these and you lose every host definition. Treat them as production data.

Step 4: Write the Docker Compose File

The official setup guide uses a compose file with ports 80, 443 and 81 published, and two bind mounts for /data and /etc/letsencrypt. Here is a version based on that, with a few practical additions.

nano /opt/npm/compose.yaml
services:
  app:
    image: 'jc21/nginx-proxy-manager:2.16.0'
    container_name: npm
    restart: unless-stopped
    ports:
      - '80:80'
      - '443:443'
      - '127.0.0.1:81:81'
    environment:
      TZ: "Asia/Jakarta"
      DISABLE_IPV6: 'true'
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    healthcheck:
      test: ["CMD", "/usr/bin/check-health"]
      interval: 30s
      timeout: 5s
      retries: 3

The documented example pins version 2.16.0 rather than latest. Check the project’s releases page for the current tag before you deploy, and then pin it. Pinning is a habit worth keeping. Nothing ruins a quiet morning like an automatic pull that changes behavior on a production box.

Why bind port 81 to localhost?

The admin interface on port 81 is served over plain HTTP, which has long been a criticism of the project’s default setup. Anything you type into the login form on an exposed port 81 crosses the network unencrypted. Binding to 127.0.0.1:81 means the panel is only reachable from the server itself. You then reach it through an SSH tunnel, or later through NPM itself behind HTTPS. It is a small change that removes a big risk.

If you want quick access during first setup from your workstation, use a tunnel:

ssh -L 8181:127.0.0.1:81 youruser@your.server.ip

Then open http://localhost:8181 in your local browser.

About DISABLE_IPV6

Uncomment or set DISABLE_IPV6: 'true' when IPv6 is not enabled on the host. If IPv6 is unavailable and you leave this off, you may see odd Nginx startup errors on listen directives.

Step 5: Open the Firewall Correctly

Ubuntu ships with UFW, but it’s often inactive on fresh servers. Set it up carefully so you don’t lock yourself out.

sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

Allow SSH first. Always.

The Docker and UFW gotcha

This one bites experienced admins too. Docker inserts its own iptables rules for published ports, and those rules bypass UFW. A port published with -p 8080:8080 can be reachable from the internet even though UFW shows it as blocked.

That is one more reason to be explicit in your compose file. Publishing 127.0.0.1:81:81 ensures port 81 is not exposed regardless of the UFW state. For other containers you add later, bind to localhost or place them on an internal Docker network that only NPM can reach.

Step 6: Launch Nginx Proxy Manager

cd /opt/npm
docker compose up -d

Docker pulls the image and starts the container. Check it:

docker compose ps
docker compose logs -f app

Wait for the logs to settle. First start runs database migrations, which can take a minute on small VPS plans. Press Ctrl+C to leave the log view; the container keeps running.

If you see a healthy status, the service is up.

Step 7: First Login and Admin Setup

Open the tunnel (or browse to the server if you chose to publish port 81 publicly for a moment) and visit the admin UI.

On first run, NPM prompts you to create the initial administrator account. Newer versions of the project let you seed the admin through the INITIAL_ADMIN_EMAIL and INITIAL_ADMIN_PASSWORD environment variables, and the account is only created if no active users exist in the database. Older tutorials mention a default admin@example.com with password changeme. Depending on your version, you may or may not see that. Follow what the screen asks for.

Whichever route applies:

  • Use a real email address, because Let’s Encrypt expiry notices go there.
  • Use a long, unique password from a password manager.
  • Don’t reuse the credentials anywhere else.

Enable two-factor authentication

Recent releases support 2FA for user accounts. Turn it on for every admin account. A reverse proxy manager has the keys to every service behind it. Treat it that way.

Step 8: Add Your First Proxy Host

Suppose you run a Grafana container on the same machine, listening on port 3000. You want grafana.example.com to serve it over HTTPS.

  1. Make sure the DNS A record for grafana.example.com points to your server.
  2. In the NPM dashboard, go to Hosts, then Proxy Hosts, then Add Proxy Host.
  3. Enter grafana.example.com as the domain name.
  4. Choose the scheme (http), forward hostname/IP, and forward port (3000).
  5. Enable Block Common Exploits and Websockets Support if the app needs it. Grafana live features do.
  6. Open the SSL tab.

Which forward hostname should be used?

This trips up beginners. Inside the NPM container, localhost refers to the container itself, not your server. So localhost:3000 will not reach Grafana running on the host or in another container.

You have three clean options:

  • Put both containers on a shared Docker network and use the container name as the hostname.
  • Use the server’s private LAN IP.
  • Add extra_hosts: ["host.docker.internal:host-gateway"] to the NPM service and forward to host.docker.internal.

The shared network approach is the tidiest. Create it once:

docker network create proxy

Then add it to the compose file of NPM and each app:

services:
  app:
    networks:
      - proxy
networks:
  proxy:
    external: true

Now NPM can reach grafana:3000 by name, and Grafana does not need any published port at all. That is how it should be.

Step 9: Request a Let’s Encrypt SSL Certificate

In the SSL tab of the proxy host:

  • Choose Request a new SSL Certificate.
  • Enable Force SSL.
  • Enable HTTP/2 Support.
  • Enable HSTS only after you have confirmed HTTPS works. HSTS is sticky; browsers will remember it, so a mistake is painful to undo.
  • Agree to the Let’s Encrypt terms and save.

NPM runs an HTTP-01 challenge over port 80. If DNS is right and port 80 is reachable from the internet, you get a certificate within seconds. Renewal is handled automatically.

Wildcard certificates with DNS challenge

For *.example.com, HTTP-01 doesn’t work. You need the DNS challenge, and NPM supports many DNS providers through Certbot plugins. Cloudflare is the most common. You create an API token scoped to DNS edits for just that zone, paste it into the credentials field, and NPM handles the TXT records.

This approach also works for internal-only services. If a server has no public port 80 at all, DNS validation still gets you a trusted certificate.

Secure the admin panel itself

Here is a neat trick. Create a proxy host such as npm.example.com that forwards to 127.0.0.1:81… except NPM runs inside the container, so forwarding to the container’s own port 81 works with the hostname localhost in that case. Here localhost is correct, because the admin UI lives in the same container. Attach a certificate and an access list, and you get an HTTPS admin panel without exposing port 81 to the world.

Step 10: Access Lists, Rate Limits and Hardening

Access lists in NPM let you restrict a host by IP or by basic auth. Two common patterns:

  • Allow only your office or VPN subnet for internal tools.
  • Add basic auth to a staging site so search engines and strangers cannot reach it.

Pair that with a few more habits.

Security checklist

  • Keep Ubuntu patched. Enable unattended upgrades for security updates (commands below).
  • Use SSH keys and disable password logins in /etc/ssh/sshd_config.d/.
  • Install Fail2ban to catch brute-force attempts against SSH and, with a custom filter, against NPM logs.
  • Don’t publish database ports (3306, 5432, 6379) to the internet.
  • Run the container with a non-root user where possible. The advanced configuration docs describe PUID and PGID variables that make the services run as a specific user and adjust ownership of the data folders at startup.
  • Back up before every upgrade.
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Security headers and custom config

Under each host’s Advanced tab you can add raw Nginx directives. A sensible baseline:

add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

Skip X-Frame-Options for apps that must be embedded. Test each header against the real application, because a strict policy can break login flows in surprising ways.

Troubleshooting Common Problems

Real servers misbehave in predictable ways. These are the ones that come up most.

Port 80 or 443 already in use

Symptom: bind: address already in use.

sudo ss -tulpn | grep -E ':(80|443)\s'
sudo systemctl disable --now apache2 nginx

Stop whatever owns the port, then run docker compose up -d again.

Cannot reach the admin panel on port 81

This is a very common support thread on the project’s GitHub. Typical causes:

  • You bound port 81 to localhost (as recommended here) and are trying to reach it from another machine. Use the SSH tunnel.
  • The cloud provider’s security group blocks the port.
  • The container is still running migrations. Check docker compose logs app.

Let’s Encrypt fails: “Internal Error” or validation failure

Work through this list:

  • Does the domain actually resolve to this server? Run dig +short grafana.example.com.
  • Is port 80 open on the cloud firewall and UFW?
  • Are you behind Cloudflare’s orange-cloud proxy? HTTP-01 can still work, but the DNS challenge is more reliable.
  • Did you hit Let’s Encrypt rate limits after repeated failures? Wait and fix the root cause before retrying.

502 Bad Gateway

NPM could not reach the upstream. Nine times out of ten the forward hostname is wrong, especially the localhost mistake described earlier. Test from inside the container:

docker exec -it npm curl -I http://grafana:3000

If that fails, the networking is the problem, not Nginx. Also check that the upstream scheme (http vs https) matches what the app expects.

413 Request Entity Too Large

Add client_max_body_size 512m; to the Advanced tab of that host.

Websocket apps disconnect or fail

Enable Websockets Support on the proxy host. Applications like Home Assistant, Portainer consoles and Grafana Live depend on it.

Container restarts in a loop

docker compose logs --tail=100 app

Look for permission errors on the bind-mounted folders, a malformed environment variable, or a corrupted SQLite file. If you set PUID/PGID, make sure the volumes are owned by a matching user.

Changes in the UI don’t take effect

Occasionally a bad custom Nginx snippet breaks config generation and the host shows as offline. Remove the last Advanced config you added, save, and retest. Invalid directives are the usual culprit.

r00t is an experienced Linux enthusiast and technical writer with a passion for open-source software. With years of hands-on experience in various Linux distributions, r00t has developed a deep understanding of the Linux ecosystem and its powerful tools. He holds certifications in SCE and has contributed to several open-source projects. r00t is dedicated to sharing her knowledge and expertise through well-researched and informative articles, helping others navigate the world of Linux with confidence.

Related Posts