Skip to content
Panda Valley Technology

Lab walkthrough

Self-Hosted Foundation, Part 1: Nginx Proxy Manager

· 6 min read

Contents

The Overview set up the conventions and the shared Docker network. This part adds the reverse proxy: the only container that publishes ports on the host, and the place where every hostname gets its certificate. The files for this part are in npm/ in the repo.

Why Nginx Proxy Manager

Nginx Proxy Manager (NPM) is nginx plus a web UI and built-in Let’s Encrypt. Plain nginx or Traefik would also work. NPM wins here because adding a host is a two-minute form, the generated config is still plain nginx you can read and extend, and the Advanced tab takes raw nginx when the form isn’t enough. Part 2 leans on that.

The compose file

# Extra stream ports (e.g. databases proxied via NPM streams) go in
# compose.override.yaml, see compose.override.example.yaml.
services:
  app:
    image: jc21/nginx-proxy-manager:2.16.0
    container_name: nginx-proxy-manager
    restart: unless-stopped
    ports:
      - "80:80"   # HTTP
      - "443:443" # HTTPS
      - "${NPM_ADMIN_BIND:-127.0.0.1}:81:81" # Admin UI
    environment:
      TZ: ${TZ:-Etc/UTC}
    volumes:
      - ./data/data:/data
      - ./data/letsencrypt:/etc/letsencrypt
      - ./snippets:/snippets:ro

networks:
  default:
    name: ${APP_NETWORK:-apps}
    external: true

Things worth noticing:

  • The admin UI binds to 127.0.0.1 by default. It’s only reachable from the host itself (or over an SSH tunnel). You’ll usually add a proper proxy host for it later and put it behind SSO, but this port stays as the way in when SSO is broken. Set NPM_ADMIN_BIND=0.0.0.0 in npm/.env only if you have a reason to expose it on the LAN.
  • snippets/ is mounted read-only. It holds nginx include files that live in Git instead of in NPM’s database. Part 2 uses them for forward auth.
  • State is two directories. data/data holds the SQLite database, generated nginx config and logs. data/letsencrypt holds certificates and account keys.

Host-specific overrides

Two things about NPM tend to differ from host to host: extra ports and where the data lives. Both go in the gitignored compose.override.yaml described in the Overview. Copy it from compose.override.example.yaml, which has commented examples of both.

Stream ports

NPM can also proxy raw TCP/UDP (“streams”), for example a database port. Each stream needs its host port published, and that list differs from host to host. Compose can’t build a port list from an env variable, so the ports go in the override:

services:
  app:
    ports:
      - "5432:5432" # PostgreSQL

The override’s ports are appended to the base list, so 80, 443 and 81 stay. docker compose config shows the merged result.

Moving data to a NAS

By default NPM’s state lives in ./data on the host. To keep it on a NAS instead, the override swaps both bind mounts for named volumes backed by NFS. Volumes merge by container path, so these replace the base mounts:

services:
  app:
    volumes:
      - npm-data:/data
      - npm-letsencrypt:/etc/letsencrypt

volumes:
  npm-data:
    driver: local
    driver_opts:
      type: nfs
      o: &nfs-opts addr=nas.example.lan,nfsvers=4.1,rw,hard
      device: ":/export/npm/data"
  npm-letsencrypt:
    driver: local
    driver_opts:
      type: nfs
      o: *nfs-opts
      device: ":/export/npm/letsencrypt"

The third mount, /snippets, isn’t listed, so it stays a local bind mount. The YAML anchor (&nfs-opts) keeps the mount options in one place. Two things to know about NFS volumes:

  • Volume options are fixed when the volume is created. Changing the address or options in the file does nothing to an existing volume. Run docker compose down, then docker volume rm <project>_<volume>, before the next up.
  • The export has to be writable by the container’s user. Watch out for root_squash on the NAS.

Bringing it up

With the shared network created (see the Overview):

cd npm
cp .env.example .env
direnv allow
docker compose up -d

Then open http://127.0.0.1:81 on the host and create the admin account.

Certificates: use the DNS challenge

NPM can get Let’s Encrypt certificates two ways. The HTTP challenge needs port 80 reachable from the internet. The DNS challenge proves you control the domain by creating a TXT record through your DNS provider’s API instead.

For a homelab or an internal network, the DNS challenge is the better default:

  • Nothing has to be exposed. Services that only resolve on your LAN or VPN still get real, publicly trusted certificates. No browser warnings, no private CA to distribute.
  • You can request a wildcard. One certificate for example.com and *.example.com covers every service you’ll add later. Adding a host doesn’t mean waiting on a new certificate.

In NPM: SSL Certificates → Add Let’s Encrypt certificate, enter both names, turn on Use a DNS Challenge, pick your provider and paste an API token. Scope the token as tightly as your provider allows (DNS edit on one zone). It’s stored in NPM’s data directory, which is one reason that directory counts as a secret.

Adding a proxy host

With the network in place, a proxy host is short:

  • Domain: auth.example.com
  • Scheme / forward host / port: http, authentik-server, 9000
  • Websockets support: on
  • SSL: the wildcard certificate, with Force SSL on

The forward host is the container name, not an IP and not a host port. That’s the payoff of the shared network: the service doesn’t publish anything, and NPM reaches it directly over the Docker network. The pattern is the same for every service you add.

Things that bite later

Never put a literal hostname in proxy_pass

This one cost the most debugging time, so it goes first. When you write custom nginx in a host’s Advanced tab or in a snippet, it’s tempting to write:

proxy_pass http://myapp:8080;

nginx resolves a literal hostname once, at startup, and keeps that IP. When myapp is recreated (an upgrade, a docker compose up -d after a config change) it usually gets a new IP, and that host starts failing with 502s until NPM reloads.

It gets worse. A literal hostname in proxy_pass creates an implicit upstream group with that name. Any other host that proxies to the same name through a variable now matches that group instead of being resolved at runtime, so it breaks too, even though its own config was fine.

The fix is to always go through a variable:

set $upstream myapp;
proxy_pass http://$upstream:8080;

With a variable, nginx resolves the name per request through the configured resolver, here Docker’s embedded DNS. The hosts NPM generates from the UI already work this way. The rule only matters for config you write yourself.

Test before you reload

docker exec nginx-proxy-manager nginx -t
docker exec nginx-proxy-manager nginx -s reload

NPM does this for its own changes. For snippet edits on disk, it’s on you.

Where the logs are

Per-host logs are in npm/data/data/logs/proxy-host-<id>_access.log and proxy-host-<id>_error.log. The ID is shown in each proxy host’s menu in the UI. When something returns a 502, the error log for that host usually names the upstream it couldn’t reach.

Backups

Everything NPM knows is in npm/data/: the database, the generated config, the certificates and their private keys, and your DNS API token. Stop the container before copying it (SQLite doesn’t like being copied mid-write), and store the copy like any other secret.

Upgrades

Bump the image tag in compose.yaml, read the release notes for every version you’re skipping, then:

docker compose pull && docker compose up -d

Because the tag is pinned, this is the only way NPM changes version, and the change is a commit you can roll back.

Where this leaves us

At this point there’s one way into the host, every hostname has a valid certificate, and adding a service means one compose file and one proxy host. What’s missing is control over who gets in. Each app either has its own login or, worse, none at all.

Part 2 puts Authentik on the same network and uses it two ways: as an OIDC provider for apps that support it, and as forward auth in front of apps that don’t.