Home Lab — C# All the Way Down
[TO HIRE] Senior Software Developer — Seeking team — 2026 — Full-Time Remote
During my job search period, I'm building a complete development infrastructure in C# — from foundational patterns to CI/CD pipelines to deployment and monitoring — proving that a single developer with the right abstractions can manage serious complexity. This is the operational heart of the FrenchExDev ecosystem.
The Big Picture
The Home Lab is not a toy project. It's a self-hosted software development platform: GitLab as the forge, typed C# wrappers for every CLI tool in the stack, and a CI/CD pipeline that builds, tests, and packages the 57 projects in the FrenchExDev monorepo. Every layer is code. Every tool has a typed API.
Foundational Patterns: Result & Builder
Every project in FrenchExDev builds on two foundational libraries. They aren't utilities — they're the grammar the entire ecosystem speaks.
Result — Explicit Error Handling Everywhere
Result<T> and Result<T, TError> replace exceptions as control flow. Every operation that can fail returns a Result. Every consumer must handle both paths. No silent swallowing, no surprises.
// BinaryWrapper uses Result throughout
Result<Reference<PodmanClient>> client = await new PodmanClientBuilder()
.WithBinding(binding)
.BuildAsync();
// Pipeline orchestration: chain operations that may fail
Result<BuildReport> report = await ParsePipeline(config)
.Bind(pipeline => RunStages(pipeline))
.Bind(stages => CollectArtifacts(stages))
.Map(artifacts => GenerateReport(artifacts));// BinaryWrapper uses Result throughout
Result<Reference<PodmanClient>> client = await new PodmanClientBuilder()
.WithBinding(binding)
.BuildAsync();
// Pipeline orchestration: chain operations that may fail
Result<BuildReport> report = await ParsePipeline(config)
.Bind(pipeline => RunStages(pipeline))
.Bind(stages => CollectArtifacts(stages))
.Map(artifacts => GenerateReport(artifacts));This matters at scale. With 57 projects, error handling conventions must be mechanical, not cultural. If a scraper fails, if a parser hits an edge case, if a version guard rejects a flag — the error propagates through the same type-safe channel. Match, Bind, Map, Recover — the vocabulary is consistent from the lowest library to the highest application.
Builder — Typed Construction for Complex Objects
Builder<T> handles async object construction with validation accumulation, circular reference detection, and SemaphoreSlim-based caching. Every non-trivial object in the ecosystem is built through a Builder.
// Building a GitLab pipeline configuration
var pipeline = await new PipelineConfigBuilder()
.WithProject("FrenchExDev")
.WithStages(stages => stages
.Add(s => s.WithName("build").WithImage("mcr.microsoft.com/dotnet/sdk:10.0"))
.Add(s => s.WithName("test").WithImage("mcr.microsoft.com/dotnet/sdk:10.0"))
.Add(s => s.WithName("package").WithImage("mcr.microsoft.com/dotnet/sdk:10.0")))
.WithArtifacts(a => a.WithExpiry(TimeSpan.FromDays(30)))
.BuildAsync(ct);// Building a GitLab pipeline configuration
var pipeline = await new PipelineConfigBuilder()
.WithProject("FrenchExDev")
.WithStages(stages => stages
.Add(s => s.WithName("build").WithImage("mcr.microsoft.com/dotnet/sdk:10.0"))
.Add(s => s.WithName("test").WithImage("mcr.microsoft.com/dotnet/sdk:10.0"))
.Add(s => s.WithName("package").WithImage("mcr.microsoft.com/dotnet/sdk:10.0")))
.WithArtifacts(a => a.WithExpiry(TimeSpan.FromDays(30)))
.BuildAsync(ct);Result and Builder work together: BuildAsync() returns Result<Reference<T>>, validation errors accumulate into the Result's failure channel, and the builder's Reference<T> handles lazy resolution for object graphs with circular dependencies.
These two patterns are the bedrock. Everything above — BinaryWrapper, CLI wrappers, pipeline orchestration — is built on this foundation.
GitLab as the Forge
The Home Lab runs GitLab CE as its software forge — source control, CI/CD pipelines, package registry, and issue tracking. GitLab is self-hosted on the infrastructure stack, provisioned through Podman containers managed by Docker Compose.
GLab: C# Talking to GitLab
To orchestrate GitLab programmatically from C#, I scraped GLab (GitLab's official CLI) using BinaryWrapper — the same framework that powers the Podman, Podman Compose, Docker, Docker Compose, Packer, and Vagrant wrappers.
[BinaryWrapper("glab", FlagPrefix = "--")]
public partial class GLabDescriptor;
// Full IntelliSense for every glab command
var client = GLab.Create(binding);
// Create a merge request
var cmd = client.MrCreate(mr => mr
.WithTitle("feat: add quality gate to CI pipeline")
.WithDescription("Adds QualityGate step after test stage")
.WithSourceBranch("feature/quality-gate")
.WithTargetBranch("main")
.WithSquashBeforeMerge(true));
// Trigger a pipeline
var pipelineCmd = client.PipelineRun(p => p
.WithBranch("main")
.WithVariables(["DEPLOY_TARGET=staging"]));
// List project releases
var releasesCmd = client.ReleaseList(r => r
.WithPerPage(10));[BinaryWrapper("glab", FlagPrefix = "--")]
public partial class GLabDescriptor;
// Full IntelliSense for every glab command
var client = GLab.Create(binding);
// Create a merge request
var cmd = client.MrCreate(mr => mr
.WithTitle("feat: add quality gate to CI pipeline")
.WithDescription("Adds QualityGate step after test stage")
.WithSourceBranch("feature/quality-gate")
.WithTargetBranch("main")
.WithSquashBeforeMerge(true));
// Trigger a pipeline
var pipelineCmd = client.PipelineRun(p => p
.WithBranch("main")
.WithVariables(["DEPLOY_TARGET=staging"]));
// List project releases
var releasesCmd = client.ReleaseList(r => r
.WithPerPage(10));The same three-phase architecture applies: scrape GLab's --help across versions, generate typed commands and builders via Roslyn, execute with structured output parsing. GLab joins the wrapper family alongside Podman (58 versions, 180+ commands), Docker Compose (57 versions), Vagrant, and Packer.
This means C# can drive the entire GitLab workflow — creating projects, triggering pipelines, managing merge requests, publishing releases — with full type safety and version guards, not string concatenation against a REST API.
57 Projects Need CI/CD
A monorepo with 57 projects isn't viable without automated CI/CD. Each project needs to be built, tested, quality-gated, and — for libraries — packaged and published. Doing this manually is impossible. Doing it with bash scripts is fragile. Doing it with typed C# orchestration against a self-hosted GitLab is the point.
The Pipeline Problem
| Concern | Scale | Solution |
|---|---|---|
| Build | 57 .csproj files, shared Directory.Packages.props |
Central Package Management, incremental builds |
| Test | 400+ tests across 4 test projects (BinaryWrapper alone) | GitLab Runner, parallel test execution |
| Quality | Cyclomatic complexity, coverage, mutation scores | QualityGate tool with per-project quality-gate.yml |
| Package | 15+ libraries need NuGet delivery | GitLab Package Registry, versioned artifacts |
| Orchestration | Pipeline definitions, triggers, dependencies | GLab wrapper + C# pipeline configuration |
From Source to Package
Quality Gates Close the Loop
Each project defines thresholds in quality-gate.yml:
gates:
complexity:
cyclomatic_max: 15
cognitive_max: 20
coverage:
line_min: 80
branch_min: 70
mutation:
score_min: 60gates:
complexity:
cyclomatic_max: 15
cognitive_max: 20
coverage:
line_min: 80
branch_min: 70
mutation:
score_min: 60The QualityGate tool — itself part of the monorepo — runs Roslyn semantic analysis, ingests Cobertura coverage and Stryker mutation reports, and returns a pass/fail exit code. The pipeline stops if quality degrades. No exceptions, no overrides.
Package Delivery
Libraries that pass quality gates are packed and pushed to GitLab's built-in NuGet Package Registry. Downstream projects within the monorepo (and external consumers) pull versioned packages through standard NuGet feeds. The self-hosted registry means:
- No external dependency on nuget.org for internal packages
- Version control tied to the same GitLab instance that runs CI
- Access control through GitLab's existing permission model
Infrastructure Stack
The physical infrastructure runs on the Home Lab hardware, provisioned through the same typed wrappers (all built on BinaryWrapper):
| Layer | Tool | C# Wrapper |
|---|---|---|
| VM provisioning | Vagrant | FrenchExDev.Net.Vagrant (7 versions, custom parser, typed events) |
| Image building | Packer | FrenchExDev.Net.Packer (multi-version, HCL workflows) |
| Containers | Podman | FrenchExDev.Net.Podman (58 versions, 180+ commands, 18 groups) |
| Orchestration | Docker Compose | FrenchExDev.Net.DockerCompose (57 versions, 37 commands) + DockerCompose.Bundle (typed config from 32 schema versions) |
| Forge | GitLab CE | FrenchExDev.Net.GLab (scraped via BinaryWrapper) |
| Reverse proxy | Traefik v3 | Traefik.Bundle (typed static + dynamic config from JSON schemas) + PowerShell module (PoSh) |
Typed Configuration: No More YAML by Hand
The CLI wrappers handle executing Docker Compose and Traefik — but what about the configuration files themselves? Writing docker-compose.yml or traefik.yml by hand means no type checking, no IntelliSense, and no compile-time validation. The Bundle projects solve this.
Both DockerCompose.Bundle and Traefik.Bundle follow the same Roslyn source generator pattern:
- Embed the official JSON schemas as
AdditionalTexts - Parse and merge them into a unified model (handling cross-version differences)
- Generate typed C# models + Builder classes for every configuration object
DockerCompose.Bundle ingests 32 versions of the official Compose spec (v1.0.9 to v2.10.1) and generates typed models for ComposeFile, ComposeService, networks, volumes, and every nested configuration object:
var compose = await new ComposeFileBuilder()
.WithServices(services => services
.Add("gitlab", s => s
.WithImage("gitlab/gitlab-ce:latest")
.WithPorts(["8080:80", "2222:22"])
.WithVolumes(["gitlab-config:/etc/gitlab", "gitlab-data:/var/opt/gitlab"])
.WithRestart("unless-stopped"))
.Add("runner", s => s
.WithImage("gitlab/gitlab-runner:latest")
.WithVolumes(["/var/run/docker.sock:/var/run/docker.sock"])))
.WithVolumes(v => v
.Add("gitlab-config", vol => vol.WithDriver("local"))
.Add("gitlab-data", vol => vol.WithDriver("local")))
.BuildAsync(ct);var compose = await new ComposeFileBuilder()
.WithServices(services => services
.Add("gitlab", s => s
.WithImage("gitlab/gitlab-ce:latest")
.WithPorts(["8080:80", "2222:22"])
.WithVolumes(["gitlab-config:/etc/gitlab", "gitlab-data:/var/opt/gitlab"])
.WithRestart("unless-stopped"))
.Add("runner", s => s
.WithImage("gitlab/gitlab-runner:latest")
.WithVolumes(["/var/run/docker.sock:/var/run/docker.sock"])))
.WithVolumes(v => v
.Add("gitlab-config", vol => vol.WithDriver("local"))
.Add("gitlab-data", vol => vol.WithDriver("local")))
.BuildAsync(ct);Traefik.Bundle reads the Traefik v3 JSON schemas (static configuration + file provider/dynamic configuration) and generates TraefikStaticConfig and TraefikDynamicConfig models with full builder support:
var dynamicConfig = await new TraefikDynamicConfigBuilder()
.WithHttp(http => http
.WithRouters(r => r
.Add("gitlab", router => router
.WithRule("Host(`gitlab.homelab.local`)")
.WithService("gitlab-svc")
.WithEntryPoints(["websecure"])
.WithTls(tls => tls.WithCertResolver("letsencrypt"))))
.WithServices(s => s
.Add("gitlab-svc", svc => svc
.WithLoadBalancer(lb => lb
.WithServers([new() { Url = "http://gitlab:80" }])))))
.BuildAsync(ct);var dynamicConfig = await new TraefikDynamicConfigBuilder()
.WithHttp(http => http
.WithRouters(r => r
.Add("gitlab", router => router
.WithRule("Host(`gitlab.homelab.local`)")
.WithService("gitlab-svc")
.WithEntryPoints(["websecure"])
.WithTls(tls => tls.WithCertResolver("letsencrypt"))))
.WithServices(s => s
.Add("gitlab-svc", svc => svc
.WithLoadBalancer(lb => lb
.WithServers([new() { Url = "http://gitlab:80" }])))))
.BuildAsync(ct);The result: infrastructure configuration with full IntelliSense, compile-time validation, and version-aware type safety. A typo in a service name or a missing required field is caught by the compiler, not by a failed deployment at 2 AM.
PowerShell Glue
While C# handles the heavy lifting, PowerShell modules provide the developer experience layer:
- DevPoSh — Developer shell boilerplate: UTF-8, logging, module auto-loading, VS Code integration
- InfraDev — Infrastructure orchestration cmdlets tying Vagrant, Packer, Docker Compose, and Traefik together
- Claude.PoSh — Claude Code VM lifecycle management
Tech Stack
C# .NET 10 Roslyn Source Generators PowerShell 7 Podman Docker Compose Vagrant Packer GitLab CE GLab Traefik v3 Alpine Linux NuGet
The Point
This Home Lab exists to answer a question: can a single developer, with the right patterns and tooling, build and maintain a professional-grade development platform?
The answer is yes — if you invest in foundations. Result and Builder give you a grammar for error handling and object construction that scales across 57 projects. BinaryWrapper gives you typed APIs for every CLI tool in your stack. GitLab gives you the forge. And C# ties it all together with compile-time safety.
The Home Lab is not the end goal — it's the proof that the ecosystem works. Every library, every wrapper, every pattern converges here: a self-hosted platform where FrenchExDev builds, tests, gates, and packages itself. The infrastructure-as-code philosophy drives every layer.
Built with .NET 10, Roslyn source generators, PowerShell 7, self-hosted GitLab CE, and the conviction that typed APIs beat string concatenation at any scale.