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 · ~40 containers up across 2 nodes · systemd-supervised · 0 unhealthy
# realistic, not bulletproof

Most homelab builds you're shown run past two thousand euros before the first container starts — a rack, 10-gigabit switching, redundant everything. Genuinely more reliable than this, and out of reach for most of the people it would help. This runs on two second-hand laptops and the electricity they draw. Less bulletproof; far more realistic — and anyone can rebuild it without a budget.

§ 01 intent architecture goals
the constraint is the design · two second-hand laptops — what's expensive here is the thinking, not the hardware
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 · internal · declared, never inferred
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
     internet ──── x.x.x.x ──────────────────────────────────────────────────────┐
                                                                                  │
     ┌───────────────────────────────────────────────────────────────────────┐    │
     │  Router  ·  NAT  ·  forward  80/tcp  443/tcp  51820/udp               │────┘
     └──────────────────────────────────┬────────────────────────────────────┘
                                        │  LAN  192.168.2.0/24VIP  192.168.2.100  ·  keepalived VRRP
                        ┌───────────────────────────────┐
                                                       
     ┌──────────────────┴──────────────┐   ┌────────────┴─────────────────────────┐
     │  primary  ·  192.168.2.230      │   │  secondary  ·  192.168.2.225         │
     │  10-year-old legion laptop      │   │  thinkpad · rescued from a friend    │
     │  32 GB RAM · 512 GB SSD · GPU   │   │  16 GB RAM · 2 TB SSD                │
     │  VRRP MASTER  (priority 101)    │   │  VRRP BACKUP  (priority 100)         │
     │                                 │   │                                      │
     │  ufw → traefik  (TLS · DNS-01)  │   │  traefik  (TLS · independent cert)   │
     │  ↳ secondary via file provider  │   │                                      │
     │  wireguard  ·  wg0              │   │  dnsmasq                             │
     │  dnsmasq  ·  postgres  ·  redis │   │                                      │
     │                                 │   │  immich  jellyfin  rustfs            │
     │  freshrss  n8n  postiz  grafana │   │  paperless  downloads  open-data     │
     │  prometheus  loki  ollama       │   │                                      │
     │                                 │   │  kopia  ·  peer ↔ primary  (SFTP)    │
     │  5 CI runners  (4 low · 1 high) │   │  2 TB storage snapshots              │
     │  kopia  ·  peer ↔ sec.  (SFTP)  │   │                                      │
     │  512 GB snapshots               │   │  7 CI runners  (6 low · 1 high)      │
     └─────────────────────────────────┘   └──────────────────────────────────────┘
  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  L0 · internet  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
     any IP · any port · any request

                                               ↓  reject on port mismatch
  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  L1 · ufw  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
     pass:  80/tcp  443/tcp  51820/udp
     drop:  everything else — silently, at the kernel

                                               ↓  80 → permanent redirect 443
  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  L2 · traefik  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
     TLS terminated  ·  wildcard cert validated  ·  SNI routed to service
     certificate:  *.bensoussan.de  ·  Let's Encrypt  ·  89 d remaining

                                               ↓  allowlist middleware checked
  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  L3 · middleware  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
     public    →  pass  (no source restriction)
     vpn-only  →  source ∈ 10.200.200.0/24 ∪ 192.168.2.0/24   else  403
     internal  →  source = 127.0.0.1   else  403

                                               ↓  forward to container port
  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄  L4 · container  ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄
     service handles request · no plaintext socket ever externally reachable

    all layers nominal · no anomalies in the last 7 d
public
vpn-only
internal
§ 04 services 46 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 · 55 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.

Build and push docker con… Lint and analyze Run tests Build recipes CSS Generate recipes as PDF ×4 fr en de ru Generate recipe book ×4 fr en de ru Export recipe JSON + imag… Upload PDFs to RustFS Build Flutter APKs + AAB Publish APK downloads Publish to Google Play (i…
· hover or tap a job to inspect
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.

Lint, analyze and test Build Flutter APKs + AAB Publish APK downloads Publish to Google Play (c… Clean up run artifacts
· hover or tap a job to inspect
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.

Build and push ci-metrics… Run tests (pytest) Build Ansible image Deploy ci-metrics
· hover or tap a job to inspect
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.

Build pre-checks container Scan for secrets (gitleaks) Scan dependencies for CVE… Run pre-checks
· hover or tap a job to inspect
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).

Lint and test Build and push homescreen… Deploy homescreen contain…
· hover or tap a job to inspect
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.

Build test container (rus… Run tests (cargo test) Build and push backend image
· hover or tap a job to inspect
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.

Build and push Ansible co… Syntax and inventory checks
· hover or tap a job to inspect
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.

Build and push matchmaker… Install and test
· hover or tap a job to inspect
Recipe Platform 2 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.

Lint and test Build and push Engine image
· hover or tap a job to inspect
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.

publish-resume Publish PDF to open-data
· hover or tap a job to inspect
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.

Build Ansible container ansible-playbook verify-c…
· hover or tap a job to inspect
§ 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