- Python 46.6%
- Shell 46.2%
- CSS 2.7%
- Ruby 1.7%
- sed 1.6%
- Other 1.2%
The gate had two checks that could silently no-op. gitleaks being absent printed a warning but left fail=0, and private_patterns returning nothing collapsed check 3 to the Porkbun key prefix alone. On a workstation clone with neither, two of three checks were inert and the commit still looked gated. A check that cannot run is now a failure, overridable on purpose with HOMELAB_ALLOW_DEGRADED=1 for docs and tooling. Also whitelist three curl-auth-user hits in the paperless README, where the password half of the -u pair is a two-letter placeholder. Installing gitleaks made these block every commit. The .gitleaksignore comment describes them rather than quoting them, because quoting made that file trip the rule it was suppressing. Note in architecture.md that private/ is the only thing here git does not protect, and needs a backup off this host. |
||
|---|---|---|
| .githooks | ||
| bin | ||
| docs | ||
| hosts | ||
| pve | ||
| transforms | ||
| .gitignore | ||
| .gitleaks.toml | ||
| .gitleaksignore | ||
| hosts.tsv | ||
| LICENSE | ||
| manifest.tsv | ||
| README.md | ||
homelab
Config for a single-box Proxmox homelab — one i9-10900 with 64 GB RAM running nine LXCs and a Home Assistant VM, about 100 containers between them.
This repo is the change log. Everything here is collected off the live machine by
bin/sync.sh, so the history is a record of what actually changed and when, not a set of
files someone remembered to copy.
The box
milotron — Dell Precision 3240 Compact, Proxmox VE 9.2, Debian 13.
Two ZFS pools: rpool (mirrored NVMe, boot + container roots) and miloarmy
(5-disk raidz2, 27 TB raw) for bulk data.
internet
│
Caddy (CT100) ── 45 vhosts on *.motaphe.dev, DNS-01 certs
│ CrowdSec at the edge; private vhosts are
│ LAN + tailnet only
┌──────────────┼──────────────┬───────────────┐
│ │ │ │
personal(101) media(102) dashboard(105) games(109)
60 containers 25 containers 12 containers Terraria
│ │
└── /data on miloarmy ──┘ hardlink-friendly single tree,
Intel iGPU passed through to both
adguard(104) headscale(107) exitnode(108) hermesagent(106) haos(103)
DNS + DoT tailnet control ProtonVPN agent Home Assistant
plane + DERP exit nodes
| Guest | Role | Notable |
|---|---|---|
CT100 caddy |
Reverse proxy | Single ingress. internal_only snippet restricts private vhosts to LAN + 100.64.0.0/10 |
CT101 personal |
App host | Immich, Nextcloud, Vaultwarden, Forgejo, Paperless, n8n, Matrix, Karakeep, SearXNG, Open WebUI |
CT102 media |
Media automation | Jellyfin, Navidrome, the *arr stack, qBittorrent + slskd behind Gluetun |
VM103 haos |
Home Assistant OS | |
CT104 adguard |
DNS | LAN resolver, split-horizon *.motaphe.dev, DoT with ClientID auth |
CT105 dashboard |
Monitoring | Uptime Kuma, Beszel, Scrutiny, Dozzle, Healthchecks, Homepage, UniFi |
CT106 hermesagent |
Agent | |
CT107 headscale |
Tailnet | Self-hosted control plane + embedded DERP |
CT108 exitnode |
VPN egress | Four regional ProtonVPN exit nodes on the tailnet |
CT109 games |
Game server | Terraria/TShock |
How this repo works
Config lives on ten different guests. Rather than ten repos, one repo on the Proxmox host
collects from all of them — unprivileged LXC datasets are mounted on the host whether or not
the container is running, so pve can already read the whole fleet.
homelab # collect, review, commit, push
homelab diff # what changed since the last commit
homelab restore caddy [--apply]
manifest.tsv is the whole design. It's an allowlist: a file is collected only if a rule
names it. Directory rules are depth-1 and must specify what to include — there's no way to
write "everything under here" — and anything in the repo that no rule produced gets deleted on
the next sync. That matters because the compose directories sit right next to hundreds of
thousands of files of app state.
.env files are never copied. For each one, sync generates a .env.example from the live file
with the key names and comments intact and the values stripped. Generated rather than
hand-written, because the hand-written ones had already drifted — the old media-stack example
was missing eight keys that the running stack depends on.
A pre-commit hook blocks the commit if a secret gets through anyway: filename deny-list,
then gitleaks, then a grep for a few values specific to this network. It blocks rather than
quietly redacting, because a secret sitting in a config file is worth finding out about. A check
that can't run counts as a failure too — without the gitleaks binary, or without private/,
the gate refuses rather than reporting a pass it didn't earn (HOMELAB_ALLOW_DEGRADED=1 to
commit docs or tooling from a clone that has neither).
Some files legitimately contain something that can't be published — a WAN IP, an API key with no
env indirection, a hardware address. Those go through a sed transform before they're written
into the repo.
Those transforms come in two kinds, and the split matters. transforms/ holds the ones that
describe the shape of a secret (password: followed by anything) — safe to publish, and
committed. private/ holds the ones that have to name a literal value in order to replace it.
Publishing those would republish exactly what they exist to remove, so private/ is gitignored
and stays on the host. The gate derives its patterns from private/ at runtime, which means
adding a scrub rule automatically extends the gate instead of the two drifting apart.
Layout
manifest.tsv the allowlist
hosts.tsv the fleet
bin/ sync, restore, the wrapper
transforms/ pattern-based redactions (published)
private/ value-bearing redactions (gitignored, never leaves the host)
pve/ hypervisor config, LXC/VM definitions
hosts/<name>/ mirrors each guest's own paths
docs/ architecture, restore, runbook
Not in here
Secrets, obviously — but also app state, databases, media, Tailscale node identities, SSH host
keys, and the certificate store. docs/architecture.md has the reasoning; the short version is
that this repo tracks the things a human wrote.