Lab stack
Self-Hosted Foundation: Overview
· 8 min read
Contents
Every self-hosted service needs the same few things: a hostname, a TLS certificate, a way for the proxy to reach it, and a decision about who’s allowed in. Most guides answer these one app at a time, so each app ends up handled a little differently from the last. After five or six services you have five or six slightly different setups, and nobody remembers why.
This series sets up the foundation once, so that every service added later fits into the same pattern. This post covers the overall design, what to expect, and the conventions every stack follows. The two parts after it build the pieces:
- Part 1: Nginx Proxy Manager: the reverse proxy, the only way into the host, with valid certificates for every hostname.
- Part 2: Authentik: an identity provider, so one login (with MFA) decides who gets past the proxy.
Everything in the series is in the self-hosted-stack repo on GitHub. Clone it to follow along, or read the files next to the posts.
Who this is for
This is written for self-hosters and homelabbers running services on one Docker host:
a mini PC, a NAS that runs containers, a VM on a server in the closet, or a small cloud
VM. You should be comfortable with a shell, editing YAML and reading docker compose
output. You don’t need to know nginx or OIDC. The posts explain the parts that matter.
What you end up with:
- One way in. Only the reverse proxy listens on the host. Every service is reached
through it by hostname, such as
auth.example.comorgit.example.com. - Valid TLS everywhere, including for services that are only reachable on your LAN or VPN. No browser warnings and no private CA to install on every device.
- One login for everything, with MFA, and a group decides who can use what.
- A repeatable pattern. Adding a service means one compose file, one proxy host and one decision about how it logs in.
What it isn’t: there’s no Kubernetes, nothing spans more than one host, and nothing is highly available. If the host goes down, everything on it goes down. For a homelab or a small team that’s usually the right trade-off. One host with Compose is easy to understand, back up and fix at 11pm.
What you’ll need
- A Linux host with Docker and Compose v2.24 or newer. One of the compose files uses
the
env_filerequired: falsesyntax, which arrived in 2.24. - A domain whose DNS provider has an API, such as Cloudflare, Route 53, DigitalOcean, Porkbun or deSEC. Part 1 uses the DNS challenge to get certificates, which needs API access to create TXT records. DNS records for your hostnames point at the host.
- direnv (optional). It loads the env files automatically. Everything works without it.
- Time: about an hour for each part, most of it waiting on first starts and certificates. Budget longer if you’re new to DNS API tokens.
The design
flowchart LR
client([Client]) -->|":80 / :443"| npm[nginx-proxy-manager]
subgraph apps ["Shared network: apps"]
npm --> auth[authentik-server:9000]
npm --> svc[other services]
end
subgraph backend ["Private network: authentik backend"]
auth --> pg[(authentik-postgresql)]
endFour decisions shape everything else:
- One Compose stack per service. The proxy, the identity provider and each app live in
their own directory with their own
compose.yaml. You can upgrade, restart or remove one without touching the others, and a broken edit in one file can’t take down the rest. - One shared Docker network. Every stack joins the same external network, so the proxy
can reach any service by container name. No IP addresses, no
host.docker.internal. - One way in. Only the reverse proxy publishes ports on the host. Everything else is reachable only through it, which means TLS and logging happen in one place.
- Identity in one place. Authentik decides who gets in, either through the app’s own SSO support or in front of the app at the proxy. Part 2 covers how to choose.
The rest of this post covers the conventions that make those decisions hold up as the number of stacks grows.
Repo layout
The repo is laid out like this:
.envrc / .env.example shared settings (APP_NETWORK, TZ)
scripts/network.sh creates the shared Docker network
npm/ Nginx Proxy Manager (part 1)
snippets/ nginx includes for Authentik forward auth (part 2)
authentik/ Authentik server, worker, Postgres (part 2)
Each stack directory has the same shape: compose.yaml, .envrc, .env.example, a
compose.override.example.yaml, and gitignored .env, compose.override.yaml and
data/. The .gitignore at the root is three lines:
.env
data/
compose.override.yaml
That split is deliberate. Everything committed is generic and safe to publish. Everything specific to one host (domains, secrets, extra ports, storage) lives in a file Git never sees. The same repo can run on two hosts with different settings and no merge conflicts.
Conventions
- Pin image versions. Never
latest. An upgrade should be a commit you made on purpose, not something that happened when a container restarted. - Bind mounts, not named volumes. State lives in
<stack>/data/, right next to the compose file that uses it. Backing up a stack means stopping it and copying one directory. A host that needs its data somewhere else changes that in an override file (see below). - Defaults in Compose, overrides in
.env. Compose files use${VAR:-default}so they work even without any env file, and${VAR:?message}for secrets, so a missing password fails loudly instead of starting a database with an empty one.
Env files with direnv
Shared settings live in the root .env:
# Docker network all stacks join. Created by scripts/network.sh.
APP_NETWORK=apps
TZ=Etc/UTC
direnv loads it. The root .envrc is one line, and each stack’s .envrc loads the parent
first, then its own file:
# .envrc (root)
dotenv_if_exists .env
# npm/.envrc, authentik/.envrc
source_up
dotenv_if_exists .env
When you cd authentik, you get the root values (so APP_NETWORK is the same in every
stack) plus Authentik’s own settings. Without direnv, Compose falls back to the defaults in
the compose files, and the only thing you lose is overriding the shared values in one
place.
The shared network
Every stack’s compose.yaml ends with the same block:
networks:
default:
name: ${APP_NETWORK:-apps}
external: true
Making it the default network means services join it without listing it. Making it
external means Compose never creates or deletes it. That matters, because otherwise the
first stack to come up would own the network and docker compose down in that stack would
try to remove it out from under everyone else.
Since it’s external, something has to create it once. A small idempotent script,
scripts/network.sh, does that:
#!/usr/bin/env bash
# Create the shared Docker network all stacks join (idempotent).
set -euo pipefail
network="${APP_NETWORK:-apps}"
if docker network inspect "$network" >/dev/null 2>&1; then
echo "network '$network' already exists"
else
docker network create "$network"
echo "created network '$network'"
fi
Container names have to be unique
On a shared network, containers find each other by name through Docker’s embedded DNS.
Compose service names like db or app are fine inside one stack. On a network shared
by several stacks they collide, and which container answers is anyone’s guess. So every
container that joins the shared network gets an explicit, stack-prefixed container_name:
nginx-proxy-manager, authentik-server, authentik-postgresql. Those names are also
what you type into the proxy’s forward-host field, so they’re worth keeping readable.
Databases stay off it
Only the containers the proxy needs to reach join the shared network. Databases and other
internals go on a network private to their stack. In Part 2, Authentik’s Postgres sits on
a backend network that only the Authentik server and worker can see. Nothing else on
the shared network, including a compromised app, can even open a connection to it.
Host-specific changes in an override file
Some settings are different on every host, and Compose can’t take them from an env
variable. They go in a gitignored compose.override.yaml next to the stack’s
compose.yaml. Compose picks it up automatically and merges it into the base file. Each
stack commits a compose.override.example.yaml to copy from.
How the merge behaves depends on the key:
portsentries are appended to the base list. Part 1 uses this for NPM stream ports.volumesentries are merged by container path. An override entry with the same target replaces the base one, and the others are kept.
The second rule is how a host moves a stack’s data without touching compose.yaml. If the
base file mounts ./data/app:/data, an override that mounts something else at the same
container path replaces it:
services:
app:
volumes:
- /mnt/storage/app:/data # replaces ./data/app:/data
The target could just as well be a named volume, for example one backed by an NFS share on a NAS. Part 1 walks through that for NPM.
For any override, docker compose config shows the merged result. It’s the quickest way to check
what Compose will actually run.
Getting started
With the conventions in place, the shared setup is a clone and three commands:
git clone https://github.com/PandaValleyTech/self-hosted-stack.git
cd self-hosted-stack
cp .env.example .env # set APP_NETWORK if you don't want "apps"
direnv allow # or: set -a; . ./.env; set +a
scripts/network.sh
Everything else happens stack by stack, starting with the reverse proxy in Part 1.
After the foundation
Once both parts are done, the foundation doesn’t change much. What changes is what runs on it. A follow-up series, Self-Hosted Services, puts real applications on top of it using the same pattern: Gitea for Git hosting, Jellyfin for media, Immich for photos. It also covers something most guides skip: running more than one instance of the same app without the two colliding on names, networks or data. For example, one Immich for photo sync and a separate one for a shared family album.