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.examplewith placeholders; the real.envfiles 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.
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 GbE — enp6s0, currently unused |
| Case | Lian-Li O11D EVO RGB |
| OS | Debian 12 (bookworm) |
- Host: one server
airbase(Debian 12, LAN192.168.1.10), one dedicated Docker Compose stack per service. - Reverse proxy + TLS: Nginx Proxy Manager (
npm/) with a wildcard certificate*.example.comvia 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-challengeTXT record. Internal services attach to the Docker networkproxy-netand 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.
deunhealthrestarts netns children when their tunnel healthcheck fails;configarrmaintains 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).
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.
- 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. Unneededports:are left commented out in the compose file — as documentation that "this port is intentionally not published" (e.g. ingrimmory,immich,n8n,vaultwarden). - 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 to127.0.0.1:81. - 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.0is off-limits as soon as the port doesn't need to be reachable from outside. The few genuine0.0.0.0cases are all protocol-driven and commented in their respective compose file.
| 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.
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 name — no deviatingCOMPOSE_PROJECT_NAMEpin (docker compose ls⇒ name == folder). - Flat:
docker-compose.yml/compose.ymland.env/.env.examplelive directly in the service directory — no intermediate folders likestack/. - 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, preferablyinternal: true); everything NPM publishes additionally on the externalproxy-net. As few host ports as possible — and if any, interface-bound (127.0.0.1:/LAN IP, never implicit0.0.0.0; see Port exposing). - Auto-update opt-in via label
com.centurylinklabs.watchtower.enable=true; critical/pinned containers deliberately=false. - Secrets in
.env— never committed; documented per service as.env.examplewith placeholders.
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.
cd <service>
cp .env.example .env # then enter secrets/domains in .env
docker compose up -dPersistent data volumes live outside the repo (see .gitignore paths)
and must be created/restored separately.
- 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
CHANGEMEvalues 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.