Skip to content
Panda Valley Technology

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:

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.com or git.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_file required: false syntax, 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)]
  end

Four 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:

  • ports entries are appended to the base list. Part 1 uses this for NPM stream ports.
  • volumes entries 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.