The IaC system for my homelab and other devices.
vps0is a cloud VPS that uses Docker Compose to run public-facing services likeapps.obtainium.imranr.devand tunnel some requests through tosrv0.srv0is a lightweight home server that uses Kubernetes (K3s) to run personal services like Immich. It is the only K8s node in the system.bigpcis a gaming PC that runs llama.cpp in Docker Compose on its AMD RX 9070 (ROCm), exposed viasrv0.pcis a laptop that runs Syncthing (via Docker Compose) to sync files tosrv0.rpiis an SBC that streams a live camera feed to Frigate onsrv0.
- Infrastructure as Code: Everything should be declarative and automated, using standard tooling wherever possible. Custom scripts should be minimal and only where necessary.
- Security: As the codebase is public and the system runs public-facing services containing highly personal data, security must be taken seriously. To that end:
- Authelia SSO guards every service that needs it.
- CrowdSec automated threat response guards all public services.
- Geoblocking is used for services that do not need to be globally accessible.
- Network policies are used in the
srv0Kubernetes stack to ensure that pod-to-pod communication is only allowed where necessary. - WireGuard key pairs (as opposed to symmetric token-based encryption) protect the public-exposure tunnel between
srv0andvps0; each peer authenticates with its own key, preventing certain kinds of MITM attacks. - The Principle of Least Privilege is applied to containers, with elevated privileges and root runtime user only allowed where necessary. Host devices are granted as Kubernetes device-plugin resources (via the generic-device-plugin) rather than
privileged: true. - Comprehensive Monitoring and Alerting is done using Alloy + Prometheus, Loki, Grafana, Ntfy.sh + Logtfy, Headlamp, Dozzle, Uptime Kuma, and Falco.
- A Honeypot (Opencanary) is used to discover intruders.
- Regular update checking is done via Renovate: a six-hourly Renovate-only run opens PRs and auto-merges non-major app updates after their test gate, while every Infra digest/chart PR is a manual rollout gate.
# Install prerequisites on every machine
bash scripts/prereqs.sh
# Create the gitignored config tree (never overwrites existing files).
# On srv0, --detect fills MY_UID/GPU_PROFILE/HA_CONBEE/HA_BLUETOOTH.
bash scripts/init-config.sh srv0 --detect
bash scripts/init-config.sh vps0
# Fill config/VARS.env, values/compose envs, Authelia users/rules, and the
# optional vps0 agent config. The explorer Configuration tour covers the full surface.
bash scripts/validate.sh srv0
bash scripts/validate.sh vps0
# Deploy — validate.sh must run before every deploy (it vendors the D4TA chart
# dependency into a gitignored charts/ directory on first run).
# Copy-paste commands for Compose targets and srv0 are in "Deploy" below.
# Run Renovate manually (the in-cluster jobs do this on a schedule) — machine-local
bash scripts/renovate.sh [apps|infra|all|clear-cache] [--dry-run]Run these on the target machine, in its repo checkout. bash scripts/prereqs.sh
must have run there once: it writes the machine-fact
targets/<t>/compose/.env and creates ~/infra_state/.
docker compose needs sudo on these machines; validate.sh and the Helm
commands do not.
bash scripts/validate.sh vps0
# vps0 only: regenerate the Authelia config from config/vps0/authelia/.
bash scripts/render-vps0-authelia.sh
sudo docker compose \
--env-file config/vps0/compose.env \
-f targets/vps0/compose/compose.yaml \
-f targets/vps0/compose/compose.private.yaml \
--env-file targets/vps0/compose/.env \
up -d --remove-orphansbigpc has a config_template, so it keeps both env files and only drops the
compose.private.yaml line:
sudo docker compose \
--env-file config/bigpc/compose.env \
-f targets/bigpc/compose/compose.yaml \
--env-file targets/bigpc/compose/.env \
up -dpc and rpi have no config_template, so they have no config env and use the
machine-fact file alone:
sudo docker compose \
-f targets/pc/compose/compose.yaml \
--env-file targets/pc/compose/.env \
up -dsrv0's Compose sidecar (the public-exposure client) uses
config/srv0/compose.env and targets/srv0/compose/compose.yaml.
Repeat a target's flags for every other Compose command on it — ps,
logs -f <svc>, down <svc>, up -d --force-recreate --no-deps <svc>:
sudo docker compose \
--env-file config/vps0/compose.env \
-f targets/vps0/compose/compose.yaml \
-f targets/vps0/compose/compose.private.yaml \
--env-file targets/vps0/compose/.env \
psWhy each flag:
--env-file config/<t>/compose.env— the target's VARS and secrets, for the targets that have aconfig_template(srv0,vps0,bigpc). Without it the${VAR:?}guards abort and optional vars interpolate empty.pcandrpihave no config env; skip this flag there.--env-file targets/<t>/compose/.env— generated machine facts (MY_UID,DOCKER_GID,MY_USERNAME,COMPOSE_STATE_DIR). Without it containers run as root and bind mounts end up root-owned. This file also carriesCOMPOSE_PROFILES, so no--profileflag is needed.- Passing any
--env-filedisables Compose's.envauto-discovery, which is why the machine-fact file is passed explicitly. --remove-orphansdrops containers for services removed from the file.
Keep the flags explicit: sudo strips exported variables, so shortcuts like
COMPOSE_FILE/COMPOSE_ENV_FILES (or a shell alias) do not survive.
# Terminal 1, for the whole session (the admin kubeconfig is root-only):
bash scripts/kubeconfig-unlock.sh
# Terminal 2:
bash scripts/validate.sh srv0
helm upgrade --install srv0-base targets/srv0/k3s-base -n base --create-namespace \
-f targets/srv0/k3s-base/values.yaml -f config/srv0/values.yaml
helm upgrade --install srv0-apps targets/srv0/k3s-apps -n apps --create-namespace \
-f targets/srv0/k3s-apps/values.yaml -f config/srv0/values.yamlApply base before apps; delete in reverse order. helm returning success is
not the end: the base chart ships HelmChart CRs that k3s's helm-controller
installs asynchronously.
kubectl -n base get helmcharts
kubectl -n apps get helmcharts
for ns in base apps; do
kubectl -n "$ns" get deploy,statefulset,daemonset -o name |
while read -r r; do kubectl -n "$ns" rollout status "$r" --timeout=120s || echo "FAILED: $r"; done
done
kubectl get pods -A --no-headers | grep -vE 'Running|Completed' || echo "pods OK"
kubectl get --raw /readyz
kubectl get events -A --field-selector type=Warning --sort-by=.lastTimestamp | tail -20vps0, pc, bigpc, and rpi deploy from their own checkout, so pull first
and then run the block above on that machine:
ssh -A <user@host> 'cd <repo path> && git pull --ff-only'The architecture, request path, public edge, WireGuard, apps/data, operations, configuration, first boot, and maintenance walkthroughs live in the guided explorer app in explorer/. It requires Node.js 18+ and npm.
cd explorer
npm install # first run only
npm run devOpen http://localhost:4173/. For a production build:
npm run build
npm run previewThe explorer is the documentation for this repo; AGENTS.md remains the contributor/operations guide.
The guided explorer is the documentation: start with the Start here tour, then use the topic tours for the public edge, WireGuard, apps/data, operations, configuration/decisions, first boot, and maintenance. Contributor and agent rules remain in AGENTS.md.
Note that while LLMs are used in development, the LLM isn't the one putting its data on the line. It is just a tool and is used like one.