HomeLab: A Typed, Plugin-Driven Meta-Orchestrator for Local Infrastructure
"You can't build HomeLab without GitLab, and you can't run GitLab without HomeLab. We solve the chicken-and-egg with a 200-line bash bootstrap script — and then HomeLab takes over forever after."
What this series is
This series designs HomeLab in public.
HomeLab is a CLI-first meta-orchestrator that stands up local infrastructure end-to-end: Packer images, Vagrant VMs, Docker (or Podman) hosts, Docker Compose stacks, Traefik routing, TLS certificates, DNS entries, and a fully configured GitLab instance with runners. It is the missing piece between "I want a homelab" and "my homelab is up, my CI is green, and my docs site is live".
It does not yet exist as code. The spec lives in HomeLab/doc/PLAN.md and a few sibling design documents in the FrenchExDev monorepo. This series is the blueprint that turns those documents into a real .NET 10 library + CLI — written before the first line of production code, so the design can be argued, broken and rebuilt in the open.
This is spec-driven blogging. The same pattern as the Ops DSL Ecosystem series, the Typed Docker series, and the GitLab Docker Compose series: design first, build later, and let the design absorb every painful question along the way.
The thesis
A homelab is not a script. It is a typed declaration of an environment, fed through a deterministic pipeline, observed by a typed event bus, and executed by typed binary wrappers. Every artifact — every line of HCL, every line of YAML, every Vagrantfile, every gitlab.rb — is generated. Nothing is hand-written. Drift is eliminated by construction.
The four non-negotiable architectural constraints are:
- Thin CLI → fat lib.
HomeLab.Cliis aSystem.CommandLineshell. Every verb is one method call intoHomeLab(the lib). No business logic in the CLI project. This makes E2E tests = CLI invocations, and unit tests = direct lib calls — the two test surfaces collapse to one. - SOLID, DRY, pipeline, events, plugins. The lib is built around an explicit
IHomeLabPipelineof six stages (Validate → Resolve → Plan → Generate → Apply → Verify), anIHomeLabEventBusthat publishes domain events at every meaningful step, and anIHomeLabPlugincontract that lets third-party NuGets add machine kinds, compose contributors, DNS providers, TLS providers, container engines, and Ops.Dsl sub-DSLs without forking HomeLab. [Injectable]everywhere. HomeLab's DI container is generated, not hand-written. Every service, stage, plugin, contributor, binary wrapper and event subscriber is declared with[Injectable(...)]fromFrenchExDev.Net.Injectable, and the source generator emits the entireAddHomeLab(this IServiceCollection)composition root at compile time. The cross-cutting concerns (logging, retries, timing, audit, chaos faults) are layered with[InjectableDecorator], not by hand-rolledDecorate<T,D>()calls.- Built on Ops.Dsl. HomeLab consumes the Ops.Dsl M3 vocabulary —
OpsTarget,OpsProbe,OpsThreshold,OpsPolicy,OpsEnvironment,OpsSchedule,OpsExecutionTier,OpsRequirementLink— and the eight sub-DSLs that matter for a homelab:Ops.Infrastructure,Ops.Deployment,Ops.Observability,Ops.Configuration,Ops.Resilience,Ops.Security,Ops.Networking,Ops.DataGovernance. Both HomeLab and Ops.Dsl are spec-only today; the series is honest about this and treats it as a feature: we are co-designing two libraries that will compile against each other.
Eat your own dog food: there is no fictional project
Most "build a homelab" series ship with a toy demo — a fake todo app, an invented documentation platform, a contrived microservice. This series doesn't. The thing HomeLab stands up is the FrenchExDev development environment itself.
We call it DevLab, and it contains:
- A real GitLab Omnibus instance that hosts the FrenchExDev.Net monorepo (the same repo HomeLab lives in)
- Real GitLab runners that build and test HomeLab on every push
- A real Vagrant box registry that hosts the very
.boxfiles HomeLab produces withhomelab packer build - A real NuGet feed (
baget) that shipsFrenchExDev.Net.HomeLab.nupkg - A real docs site — this very blog — served by Traefik over a self-signed wildcard cert
- Postgres for GitLab, MinIO for CI artifacts and box storage, Meilisearch for the docs search index
- Prometheus + Grafana + Loki + Alertmanager for observability
- PiHole for DNS so internal services can resolve
gitlab.frenchexdev.lab
The five dogfood loops:
Loop 1: Source GitLab in DevLab hosts the HomeLab repo →
runner in DevLab builds HomeLab.dll
Loop 2: Packages Runner publishes nupkg → baget in DevLab →
next dotnet restore pulls from a feed HomeLab is hosting
Loop 3: Boxes homelab packer build → .box → homelab box publish →
Vagrant.Registry in DevLab → next homelab vos up
pulls from a registry HomeLab is hosting
Loop 4: Backups Backup framework backs up the GitLab that holds
HomeLab's source. Periodic restore-into-throwaway-lab
proves we can rebuild ourselves from a tarball.
Loop 5: Docs This blog is hosted in DevLab. The documentation of
HomeLab is served by HomeLab.Loop 1: Source GitLab in DevLab hosts the HomeLab repo →
runner in DevLab builds HomeLab.dll
Loop 2: Packages Runner publishes nupkg → baget in DevLab →
next dotnet restore pulls from a feed HomeLab is hosting
Loop 3: Boxes homelab packer build → .box → homelab box publish →
Vagrant.Registry in DevLab → next homelab vos up
pulls from a registry HomeLab is hosting
Loop 4: Backups Backup framework backs up the GitLab that holds
HomeLab's source. Periodic restore-into-throwaway-lab
proves we can rebuild ourselves from a tarball.
Loop 5: Docs This blog is hosted in DevLab. The documentation of
HomeLab is served by HomeLab.And the bootstrap paradox? A 200-line Stage-0 bash script stands up just enough GitLab to compile the first HomeLab binary. From the moment HomeLab v0.1 boots, the bash script is dead and HomeLab takes over forever after. We never touch it again.
Three topologies, one config field
HomeLab supports the same DevLab in three topologies, picked by a single topology field in config-homelab.yaml:
| Topology | VMs | What it's for |
|---|---|---|
single |
1 | Dev iteration. Everything (GitLab + runners + Postgres + MinIO + Traefik + obs + PiHole) in one VM, one compose file. Cheap, fast, the default. |
multi |
4 | "Real" DevLab. gateway (Traefik + PiHole), platform (GitLab + runners + baget), data (Postgres + MinIO + Meilisearch), obs (Prometheus + Grafana + Loki + Alertmanager). One compose file per VM. |
ha |
~10 | GitLab Reference Architecture on Omnibus, without Kubernetes. 2× Rails behind HAProxy, Gitaly Cluster + Praefect, Patroni Postgres, Redis Sentinel, Consul, shared MinIO. Yes, HA GitLab works without K8s. |
A fourth topology, ha-k8s, is sketched as a future plugin against an as-yet-unwritten K8s.Dsl C# library. When K8s.Dsl lands, HomeLab gains it without touching the core — exactly the point of the plugin contract.
Multi-instance is also first-class: you can run a dev-single, a prod-multi and an ha-stage HomeLab on the same workstation simultaneously. Each instance gets its own subnet, its own VM-name prefix, its own DNS namespace, its own cert namespace, and its own docker-network space. Two HomeLabs cannot accidentally collide. CI even spins up a fresh pr-1234 instance for every pull request to validate the change end-to-end.
The FrenchExDev toolbelt
HomeLab does not reinvent anything. Every cross-cutting concern is solved by an existing in-house library from the FrenchExDev_i2 monorepo:
| Concern | Library | What HomeLab gets |
|---|---|---|
| DI | Injectable |
Generated AddHomeLab(), decorator chains via [InjectableDecorator] |
| Errors | Result, Result<T>, Result<T,TError> |
No exceptions for control flow; every stage returns Result<T> |
| Builders | Builder ([Builder]) |
Async, validated, cycle-safe config builders |
| Validation | Guard |
Boundary defensiveness |
| Time | Clock (IClock, FakeClock) |
Cert expiry, backup schedules, cost wall-clock — testable |
| State | FiniteStateMachine |
VM lifecycle, cert lifecycle, deployment lifecycle |
| Sagas | Saga |
Bootstrap + dogfood loops as compensable transactions |
| Reactive | Reactive (IEventStream<T>) |
Event bus internals, hot-reload of config-homelab.yaml |
| Optionality | Options (Option<T>) |
Optional config slots without null |
| Mapping | Mapper |
YAML DTO → domain model without reflection |
| Mediator | Mediator |
CLI verb dispatch into the lib |
| Outbox | Outbox |
Reliable event delivery to plugins / audit |
| Wrappers | BinaryWrapper ([BinaryWrapper("docker")]) |
Docker, Podman, Packer, Vagrant, Git, mkcert |
| CI YAML | GitLab.Ci.Yaml |
DevLab's .gitlab-ci.yml is generated, not hand-written |
| Versions | Alpine.Version |
Packer base image pinning + drift detection |
| Tracing | Requirements |
HomeLab features traced via [ForRequirement] |
| Quality | QualityGate (dotnet quality-gate test) |
Coverage / mutation / dashboard the dev-loop ships under |
| Domain | Ddd + Entity.Dsl |
Lab, Machine, Service, Cert, DnsEntry aggregates |
| M3 | Dsl |
The Ops.Dsl substrate |
| HTTP | HttpClient |
Typed clients for PiHole, GitLab, the box registry |
Part 11 of this series is the dedicated tour: every library, with one runnable code block per library showing how HomeLab uses it.
Architecture cheat-sheet
┌────────────────────────────────────────────────────────────────────┐
│ HomeLab.Cli (thin) │
│ System.CommandLine — one verb = one HomeLabRequest │
└───────────────────────────────┬────────────────────────────────────┘
│ HomeLabRequest
v
┌────────────────────────────────────────────────────────────────────┐
│ HomeLab (lib) │
│ IHomeLabPipeline │
│ └─ IHomeLabStage[] │
│ 0 Validate (config + Ops.Dsl constraints) │
│ 1 Resolve (load plugins, merge local overrides) │
│ 2 Plan (Ops.Dsl → IR: planned actions, DAG) │
│ 3 Generate (Packer HCL, Vagrantfile, compose, traefik) │
│ 4 Apply (binary wrappers: packer build, vagrant up…) │
│ 5 Verify (probes from Ops.Observability) │
│ │
│ IHomeLabEventBus ──────► loggers, progress, audit, plugins │
│ IPluginHost ──────► IHomeLabPlugin discovery │
│ AddHomeLab(IServiceCollection) ◄─ generated by [Injectable] SG │
│ Decorators (logging/timing/retry/audit/chaos) via │
│ [InjectableDecorator] │
│ Result<T> everywhere; no exceptions for control flow │
└─────┬──────────────────────────────────────────────────────────────┘
│ uses
v
┌────────────────────────────────────────────────────────────────────┐
│ Ops.Dsl (M2) │
│ Built on FrenchExDev.Net.Dsl (M3) │
│ Sub-DSLs HomeLab depends on: │
│ Ops.Infrastructure Ops.Deployment Ops.Observability │
│ Ops.Configuration Ops.Resilience Ops.Security │
│ Ops.Networking Ops.DataGovernance │
└────────────────────────────────────────────────────────────────────┘┌────────────────────────────────────────────────────────────────────┐
│ HomeLab.Cli (thin) │
│ System.CommandLine — one verb = one HomeLabRequest │
└───────────────────────────────┬────────────────────────────────────┘
│ HomeLabRequest
v
┌────────────────────────────────────────────────────────────────────┐
│ HomeLab (lib) │
│ IHomeLabPipeline │
│ └─ IHomeLabStage[] │
│ 0 Validate (config + Ops.Dsl constraints) │
│ 1 Resolve (load plugins, merge local overrides) │
│ 2 Plan (Ops.Dsl → IR: planned actions, DAG) │
│ 3 Generate (Packer HCL, Vagrantfile, compose, traefik) │
│ 4 Apply (binary wrappers: packer build, vagrant up…) │
│ 5 Verify (probes from Ops.Observability) │
│ │
│ IHomeLabEventBus ──────► loggers, progress, audit, plugins │
│ IPluginHost ──────► IHomeLabPlugin discovery │
│ AddHomeLab(IServiceCollection) ◄─ generated by [Injectable] SG │
│ Decorators (logging/timing/retry/audit/chaos) via │
│ [InjectableDecorator] │
│ Result<T> everywhere; no exceptions for control flow │
└─────┬──────────────────────────────────────────────────────────────┘
│ uses
v
┌────────────────────────────────────────────────────────────────────┐
│ Ops.Dsl (M2) │
│ Built on FrenchExDev.Net.Dsl (M3) │
│ Sub-DSLs HomeLab depends on: │
│ Ops.Infrastructure Ops.Deployment Ops.Observability │
│ Ops.Configuration Ops.Resilience Ops.Security │
│ Ops.Networking Ops.DataGovernance │
└────────────────────────────────────────────────────────────────────┘Act I — The Problem & The Shape (parts 01–06)
- Part 01: The Problem — Why Every Dev Rebuilds the Same Homelab
- Part 02: The CLI-First Thesis — Testable by Construction
- Part 03: Thin CLI, Fat Lib — One Verb, One Method Call
- Part 04: Schema-Validated YAML — Intellisense or Bust
- Part 05: Git-Composable Config — Shared Base, Personal Overrides
- Part 06: The Dogfood Target — DevLab, Not DocHub
Act II — The Lib Architecture (parts 07–14)
- Part 07: The Pipeline — Six Stages, One
Result<T> - Part 08: SOLID and DRY in Practice — Interfaces, Not Folklore
- Part 09: The Event Bus — Cross-Stage Communication Without Coupling
- Part 10: The Plugin System — Third-Party NuGets Without Forking
- Part 11: The FrenchExDev Toolbelt — 19 Libraries HomeLab Stands On
- Part 12: Ops.Dsl as the Substrate — M3, Shared Primitives, the Fixed Point
- Part 13: The Eight Sub-DSLs HomeLab Needs
- Part 14: The Test Pyramid — From Unit to CLI Harness
Act III — Talking to the Tools (parts 15–24)
- Part 15: Talking to the Docker CLI
- Part 16: Talking to Docker Compose v1 vs v2
- Part 17: Talking to Podman — Rootless, Daemonless, Different
- Part 18: Talking to Podman Compose — The Diff That Matters
- Part 19: Talking to Packer JSON — Legacy We Cannot Ignore
- Part 20: Talking to Packer HCL2 — The Default
- Part 21: Talking to Vagrant — The Vagrantfile We Generate
- Part 22: Talking to Vagrant — The Data YAML We Read
- Part 23: Talking to Traefik — Static and Dynamic
- Part 24: Talking to GitLab — Omnibus, CLI, REST
Act VI — Standing Up DevLab (parts 36–42)
- Part 36: The Bootstrap Paradox — Stage 0 Bash, Then HomeLab Forever
- Part 37: Bringing DevLab Up — Single-VM and Multi-VM in One Sequence
- Part 38: DevLab Postgres and MinIO
- Part 39: DevLab GitLab and Runners — Three Flavors, One Truth
- Part 40: DevLab — The Box Registry HomeLab Hosts for Itself
- Part 41: DevLab — The NuGet Feed HomeLab Hosts for Itself
- Part 42: DevLab — The Docs Site That Documents HomeLab
Act VII — Operational Depth (parts 43–48)
- Part 43: The Secrets Store —
ISecretStoreand Its Providers - Part 44: The Observability Stack — Prometheus, Grafana, Loki
- Part 45: The Backup / Restore Framework
- Part 46: Multi-Host Scheduling — N VMs Across M Machines
- Part 47: Cost Tracking — Yes, a Homelab Has a Cost
- Part 48: GPU Passthrough — The Subset That Actually Works
How to read this series
Architects should read Act I + Act II + part 30 (topology composition) + part 56 (roadmap) for the vision, the lib internals, the topology picker and the implementation phases.
Developers should read Act II + Act III + Act V + Act VI for the architecture, the tool wrappers, the compose authoring and the DevLab walk-through.
SRE / Platform engineers should read Act IV (host VMs), Act VI (DevLab), and Act VII (operational depth) for everything between "the box exists" and "the lab is running in production".
Plugin authors should read Act II part 10 + Act IX (parts 53–54).
Everyone should read Part 06 — the dogfood pivot is the spine of the rest of the series.
Prerequisites
- Familiarity with C# attributes and source generators
- Basic understanding of Roslyn analyzers (see Contention over Convention)
- Understanding of the M3 meta-metamodel (see Building a Content Management Framework and the Dsl framework README)
- The Ops DSL Ecosystem series — HomeLab consumes Ops.Dsl directly
- The Typed Docker and Typed Specs series for the binary-wrapper and spec-driven patterns
- A working dev machine: VirtualBox or Hyper-V or Parallels, Vagrant, Packer, Docker (or Podman),
mkcert(optional),dotnet10
Related posts
- Ops DSL Ecosystem — the typed operational substrate HomeLab is built on
- Typed Docker — how the
Docker/andDockerCompose/wrappers were generated - Wrapping Docker — the precursor to the binary wrapper pattern
- Wrapping Traefik — the typed Traefik config story
- GitLab Docker Compose — the typed GitLab compose service definition
- Injectable DDD — the source generator behind HomeLab's composition root
- The Loop — the dev-loop quality bar (
dotnet quality-gate test) - This Website, Static — how this very blog is built and served