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.1by 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. SetNPM_ADMIN_BIND=0.0.0.0innpm/.envonly 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/dataholds the SQLite database, generated nginx config and logs.data/letsencryptholds 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, thendocker volume rm <project>_<volume>, before the nextup. - The export has to be writable by the container’s user. Watch out for
root_squashon 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.comand*.example.comcovers 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.