homelab@debian:~$ cat /etc/homelab.md bensoussan.de ↗
HL-001·rev E·2026-07
[nodes]2 × debian 13 · kernel 6.12 · amd64
[hardware]2 × second-hand laptops · no rack · no server · no cloud bill
[vip]192.168.2.100 · keepalived VRRP · primary MASTER · secondary BACKUP
[ufw]opened 80/tcp 443/tcp 51820/udp · 0 others
[wg0]up · 10.200.200.1/24 · 4 peers
[dns]split · *.bensoussan.de → 192.168.2.100 · dnsmasq on each node
[acme]wildcard · porkbun · DNS-01 · independent acme.json per node
[podman]rootless · 73 containers up across 2 nodes · systemd-supervised · 0 unhealthy
# a realistic setup

Most homelab builds you're shown run past two thousand euros before the first container starts — a rack, 10-gigabit switching, redundant everything. Genuinely reliable, and out of reach for most of the people it would help. This runs on two second-hand laptops and the electricity they draw. It's a smaller, cheaper setup that anyone can rebuild without a budget.

§ 01 intent architecture goals
the constraint is the design · two second-hand laptops, and the effort went into the design instead
one source of truth · the repo is the system, the server is just the latest run
TLS everywhere · including for things only my laptop will ever see
per-service exposure · public, vpn-only, or internal · set explicitly for every service, not guessed
no LAN dependency · same hostnames inside and out, the laptop never knows where it is
§ 02 posture defense in depth · see also §03
a transport · 443
TLS everywhere — DNS-01 wildcards.
Let's Encrypt via Porkbun DNS-01. Wildcards renew automatically; no service ever has to be reachable from the internet just to prove ownership.
b routing · traefik
Per-service exposure middleware.
Each service mounts an allowlist middleware keyed by its policy label. Public services skip it; VPN-only services drop anything outside 10.200.200.0/24; internal services drop anything not loopback.
c identity · IP
Allowlists for VPN & LAN.
Trust is sourced from Wireguard peer addresses and the LAN CIDR. The proxy doesn't ask "who are you?" — it asks "what subnet are you on?" and pairs that with TLS.
d edge · UFW
Three open ports — and that's all.
80 redirects to 443. 443 is Traefik. 51820/udp is Wireguard. Everything else is dropped at the host firewall before Docker even sees it.
e resolver · dnsmasq
Split DNS — same name, both sides.
Inside the LAN, dnsmasq answers *.bensoussan.de with the local IP; outside, public DNS answers with the public IP. No hairpin NAT, no cert mismatch, no second config to maintain.
f provisioning · IaC
Ansible & Podman Quadlet.
Every box gets the same playbook. Quadlet .container units are templated from the inventory and compiled into systemd services — no daemon, no root, one supervisor. A wiped server is a single site.yml away from being identical to the one that broke.
§ 03 topology 2 views
§ 04 services 45 entries · 3 views
service purpose exposure host backend
i. core
Core OS & firewall
Debian 13, UFW with three rules, unattended-upgrades, an ssh allowlist, and the unprivileged operator account that everything else runs under.
debian 13ufwopensshansible-bootstrap
ii. network
Network & TLS edge
Wireguard for everything that isn't public, dnsmasq for split DNS, and Traefik as the single TLS-terminating front door, fed by Let's Encrypt DNS-01.
wireguarddnsmasqtraefiklets-encryptporkbun-dns
iii. data
Data layer
A single PostgreSQL instance with per-service roles and databases, plus a media volume for everything too large to live in Postgres.
postgresqlmedia volumekopia backups
iv. apps
Applications
All user-facing services arrive last, declared as Quadlet .container units templated from inventory. They inherit Postgres, Traefik, and an exposure label — nothing else.
freshrssimmichjellyfinseafilen8npostizgrafanastatic-sites
§ 05 pipelines 25 workflows · 56 jobs · 3 matrices

Every pipeline here shares the same engineering foundation: dependencies baked into the container image so no tooling is installed at job runtime; rootless Podman with layer caching for fast incremental rebuilds without a registry round-trip; Trivy security scans between build and push, failing hard on any HIGH or CRITICAL CVE; Renovate automatically opening PRs to update every version pin; and RustFS (S3-compatible) for all artifact storage — GitHub's free-tier cap is never touched.

Recipes 11 jobs · matrix 4×Generate + 4×Generate

The most complex pipeline in the repo. A single YAML recipe source fans out into PDFs (4 languages × 2 formats via Gotenberg), a Flutter mobile app, a Tailwind website, and a Flutter asset bundle — all assembled through RustFS artifact handoffs and published to the homelab registry and Google Play.

Stolpersteine 5 jobs

Cross-platform pipeline for the Stolpersteine offline memorial app: builds the Flutter Android APK (on a high-memory runner), runs widget tests, and publishes to both the self-hosted downloads server and Google Play on master merges.

ci-metrics 4 jobs

Builds and deploys the ci-metrics GitHub Actions run poller: lints and tests the async Python poller, builds and pushes the container, then deploys to the primary host via Ansible on master merges.

Pre-checks 4 jobs

Gatekeeper pipeline that runs before any code lands: secret scanning with Gitleaks, Trivy filesystem vulnerability scan, and the full pre-commit suite (ruff, shellcheck, shfmt, hadolint, codespell) inside a dedicated CI image — so no tooling is installed at job runtime.

Recipe Platform 3 jobs

CI/CD pipeline for the HTMX recipe platform: builds the FastAPI/Python container, runs backend tests and linting inside it, then deploys to the primary host via Ansible on master merges.

Homescreen 3 jobs

Builds and deploys the wall-mounted homescreen kiosk app: lints and unit-tests the Next.js codebase, builds and pushes the container image, then on master rolls the new image out to the container the kiosk actually displays (running on the secondary host).

Stolpersteine Backend 3 jobs

Builds the Stolpersteine Rust/Axum backend: runs cargo tests, then builds and pushes the container image using sccache with a Podman bind-mount cargo cache for fast incremental Rust compilation.

Ansible 2 jobs

Provisions both homelab hosts via Ansible: validates playbook syntax and runs a check-mode dry-run on PRs, then applies the full site.yml against the live two-node cluster on master merges — all inside an immutable container image with vault access baked in.

Matchmaker Assistant 2 jobs

Lean two-job pipeline for the matchmaker API: builds the Node.js/Fastify container, then runs the full test suite inside it — no npm ci at test time because node_modules is baked into the image.

Resume Publish 2 jobs

Generates the resume PDF via Gotenberg (a containerised Chrome renderer) and publishes both the PDF and open-data exports to the homelab static server, storing intermediate artifacts in RustFS.

Verify Cluster 2 jobs

Post-deploy smoke test that runs the verify-cluster.yml Ansible playbook against the live homelab after every master merge, confirming all services are up and reachable from outside the internal network.

§ 06 decisions tap to expand reasoning
§ 07 engineering practices & tooling
tool what it does
§ 08 backup 4 layers · Kopia + Databasus + HDD
a peer · kopia
Node-to-node encrypted snapshots over SFTP — 03:00 nightly.
Each node snapshots its /home/podsvc/ tree (secrets, quadlet units, service data) to the other node via SFTP. Retention: 7 daily · 4 weekly · 12 monthly. Fastest path back — data is already on the LAN.
reach for this when a file or directory is accidentally deleted or corrupted, or one node fails while the other is still healthy.
b offsite · B2
Encrypted Kopia snapshots to Backblaze B2 — 04:00 primary / 04:30 secondary.
Same source paths as the peer backup, pushed to a B2 bucket after peer completes. Survives both nodes dying simultaneously: hardware failure, fire, theft. No size limits, charged per GB stored.
reach for this when the peer node is also unavailable, or both nodes are gone. Stage via kopia restore --target /home/podsvc/restore/ then run ansible-playbook playbooks/disaster-recovery.yml.
c logical · databasus
Per-database logical dumps in Zstandard format — 02:00 nightly.
Databasus dumps every registered database (PostgreSQL, MySQL, MariaDB, ClickHouse) to homelab/databasus/backups/. Kopia then picks them up in the 03:00 peer snapshot. Enables surgical restore of a single database without touching filesystem state.
reach for this when a migration goes wrong, a table is truncated, or you need to move one database to a different host.
d hdd · secondary
Kopia snapshots to physical drive at /mnt/backup-hdd — 05:00 nightly.
Bulk media only: Immich originals and Jellyfin library. No cloud egress cost for multi-TB media. Drive can be disconnected and stored off-site manually for a cold physical copy.
reach for this when Immich or Jellyfin media is lost and B2 egress cost would be prohibitive, or you want a cold physical copy to store off-site.
status
all services nominal · 2 nodes active
nodes
debian 13 · amd64 · primary + secondary · VIP 192.168.2.100
open ports
80/tcp · 443/tcp · 51820/udp
drawing
HL-001 · 2026-06-09
homelab@debian:~$ awaiting next intent