Skip to content

psimaker/homelab

Repository files navigation

this is airbase my Homelab

Infrastructure-as-Code for my self-hosted stack on one server (airbase, Debian 12). Each service lives in its own directory with a docker-compose.yml/compose.yml and an .env.example as a secret template.

📌 Overview. This README is the central entry point: service overview, architectural cornerstones, and conventions.

🔓 Public variant. This is the published version of my homelab — compose files, configs, and docs, but without secrets. Every service ships an *.env.example with placeholders; the real .env files and all runtime data are excluded via .gitignore. Domains, IPs, and account names are replaced with placeholders (example.com, 192.168.1.x, youruser). Not deployable 1:1 — intended as a reference/inspiration.

Hardware

The entire stack runs on one bare-metal server (airbase). Installed components:

Component Model / Specification
CPU AMD Ryzen 9 5950X — 16 cores / 32 threads (Zen 3 "Vermeer", AM4), boost up to ~5.08 GHz live, 64 MB L3
Mainboard ASUS TUF Gaming B550-PLUS (AM4)
CPU cooler be quiet! Dark Rock Pro 5 (dual-tower air cooler)
RAM 64 GB DDR4 — 4 × 16 GB Corsair Vengeance LPX (CMK32GX4M2E3200C16, 2× dual kit), single-rank, DDR4-3200 CL16, currently 2133 MT/s
GPU NVIDIA GeForce GTX 1660 SUPER 6 GB (TU116) — 125 W. For Plex/Tdarr transcoding (NVENC/NVDEC)
NVMe SSD Lexar NM790 4 TB (PCIe, DRAM-less) — system/root via LVM (airbase-vg), boot/EFI
HDD Toshiba MG08ACA16TE 16 TB (enterprise, 7200 rpm, SATA) — media & data, mounted at /mnt/hdd
LAN (active) Intel 82599ES 10-Gigabit SFP+ (PCIe add-in card) — enp4s0, 10 Gb/s link, 192.168.1.10
LAN (onboard) Realtek RTL8125 2.5 GbEenp6s0, currently unused
Case Lian-Li O11D EVO RGB
OS Debian 12 (bookworm)

Architecture / Cornerstones

  • Host: one server airbase (Debian 12, LAN 192.168.1.10), one dedicated Docker Compose stack per service.
  • Reverse proxy + TLS: Nginx Proxy Manager (npm/) with a wildcard certificate *.example.com via Let's Encrypt DNS-01 — a wildcard can only be validated over DNS, so the proxy needs an API token from your DNS provider (e.g. Cloudflare) to write the _acme-challenge TXT record. Internal services attach to the Docker network proxy-net and are published via NPM — as few ports exposed externally as possible.
  • VPN: The *arr download stack (Sonarr/Radarr/Prowlarr/SABnzbd/Bazarr/umlautadaptarr) runs in the gluetun netns over a WireGuard VPN. Egress exclusively through the VPN — never bypass it or take a service out of the VPN. deunhealth restarts netns children when their tunnel healthcheck fails; configarr maintains the *arr configs as code (oneshot, outside the netns).
  • Auto-updates: Watchtower (watchtower/), only label-enabled containers, scheduled every 3 days at 04:00, via Docker socket proxy.
  • Notifications: everything via Telegram (telegram-notify/) — APT host hook, Watchtower, Seerr, Sonarr/Radarr. Format/setup/gotchas: telegram-notify/README.md.
  • Git: configuration as code — per service, compose + .env.example. In the private original this runs on a self-hosted Gitea instance; secrets stay local there.
  • Port exposing: ports are never implicitly bound to all interfaces — the bind address is a security layer (see Port exposing / interface binding).

Port exposing / interface binding (defense in depth)

In the homelab, the bind address in front of the port is a security layer in its own right. A port is never implicitly bound to all interfaces (0.0.0.0) — its reach is deliberately kept as narrow as possible. Mandatory for all services.

  1. Default: no host port at all. The service attaches only to the Docker network proxy-net; NPM reaches it by container name ( navidrome:4533, vaultwarden:80), TLS terminates at NPM. Unneeded ports: are left commented out in the compose file — as documentation that "this port is intentionally not published" (e.g. in grimmory, immich, n8n, vaultwarden).
  2. Exactly one public entry point: Only NPM binds to all interfaces (0.0.0.0) — 80 + 443. That is the only way in from outside; even the NPM admin UI is locked down to 127.0.0.1:81.
  3. If a host port is unavoidable, bind it explicitly to an interface — tiered by reach:
Bind notation Reach Examples
127.0.0.1:<port>:<port> only the host itself (from outside only via SSH tunnel) NPM admin 81, Gitea web 3000, xBrowserSync 8081
192.168.1.10:<port>:<port> only from the LAN (bound to the 10G NIC IP) Gitea SSH 2222→22
0.0.0.0 (all interfaces) reachable from anywhere — only when the protocol requires it, and justified in the compose file NPM ingress 80/443, Syncthing P2P/discovery 22000+21027, Nextcloud AIO mastercontainer 8080, Plex network_mode: host (DLNA/discovery)

"3000:3000" → ✅ "127.0.0.1:3000:3000"

Implicit 0.0.0.0 is off-limits as soon as the port doesn't need to be reachable from outside. The few genuine 0.0.0.0 cases are all protocol-driven and commented in their respective compose file.

Services

Service Directory Purpose
Gitea gitea/ Git hosting (self-hosted, hosts this repo) · 📄
Gitea Runner gitea-runner/ CI runner for Gitea Actions · 📄
Immich immich/ Photo backup & management · 📄
Navidrome navidrome/ Music streaming (+ beets pipeline) · 📄
Plex plex/ Media streaming (movies/series) · 📄
*arr stack rr/ gluetun(VPN) + Sonarr/Radarr/Prowlarr/SABnzbd/Bazarr/umlautadaptarr (in the VPN) · Seerr/Maintainerr/Tdarr/configarr/deunhealth (outside) — media library automation · 📄
Paperless-ngx paperless/ Document management · 📄
Grimmory grimmory/ E-book library (OPDS server + MariaDB), domain library.example.com · 📄
Nextcloud AIO nextcloud-aio/ Cloud / file sync (all-in-one) · 📄
Vaultwarden vaultwarden/ Password manager (Bitwarden-compatible) · 📄
Nginx Proxy Manager npm/ Reverse proxy + TLS (wildcard *.example.com) · 📄
Syncthing syncthing/ P2P file sync (+ notify/ event relay · 📄) · 📄
Telegram-Notify telegram-notify/ Central Telegram notifications + creds · 📄
n8n n8n/ Workflow automation · 📄
Vikunja vikunja/ To-do / project management · 📄
Watchtower watchtower/ Auto-updates for containers · 📄
Telegram-Bot telegram-bot/ Custom bot (Python) · 📄

📄 = service has its own README in the directory.

Container structure / convention

This is how a service should look — mandatory for new services, existing ones are being brought into line with it:

<service>/
├── docker-compose.yml   # or compose.yml
├── .env.example         # template for config + secrets (the real .env is gitignored)
├── README.md            # service documentation (every service has one; 📄)
└── <runtime>/           # config/ | runtime/ | data/ | mariadb/ … — gitignored
  • One directory per service, named after the service (<service>/). The directory name is also the Docker Compose project nameno deviating COMPOSE_PROJECT_NAME pin (docker compose ls ⇒ name == folder).
  • Flat: docker-compose.yml/compose.yml and .env/.env.example live directly in the service directory — no intermediate folders like stack/.
  • Persistent data/runtime in a gitignored subfolder of the service (config/, runtime/, data/, mariadb/ …) — never in the repo. Bind mounts instead of named volumes (survive a recreate, sit visibly in the service folder).
  • Explicit container names (container_name:); related containers share a common prefix (paperless-*, immich_*, grimmory*).
  • Networks: a service-internal bridge network for DB/cache (*-internal, preferably internal: true); everything NPM publishes additionally on the external proxy-net. As few host ports as possible — and if any, interface-bound (127.0.0.1:/LAN IP, never implicit 0.0.0.0; see Port exposing).
  • Auto-update opt-in via label com.centurylinklabs.watchtower.enable=true; critical/pinned containers deliberately =false.
  • Secrets in .envnever committed; documented per service as .env.example with placeholders.

What is NOT in the repo

Media, databases, and runtime/cache data are excluded via .gitignore (music, photos immich/library/, plex/config/, e-books grimmory/books/, all DB volumes + global **/*.db|*.sqlite*, caches, logs, documents/, downloads/). Also excluded: all real .env files (secrets). Committed are only the declarative configs + one .env.example per service with placeholders.

Starting a service

cd <service>
cp .env.example .env     # then enter secrets/domains in .env
docker compose up -d

Persistent data volumes live outside the repo (see .gitignore paths) and must be created/restored separately.

Notes

  • Don't deploy 1:1. This is a showcase of my setup, not a plug-and-play stack. Domains (*.example.com), IPs (192.168.1.x), and all secrets are placeholders — adapt them to your own environment before use.
  • Generate secrets yourself. None of the CHANGEME values are real; create your own passwords/keys (e.g. openssl rand -hex 32).
  • Reverse proxy + TLS run here via Nginx Proxy Manager with a wildcard cert; any other proxy (Traefik, Caddy) works just as well.

About

homelab

Topics

Resources

License

Stars

Watchers

Forks

Contributors