Part 13: The Eight Sub-DSLs HomeLab Needs
"You don't need every Ops.Dsl sub-DSL. You need the eight that cover the verbs of running a homelab."
Why
The Ops DSL Ecosystem series defines 22 sub-DSLs. Some of them are clearly about cloud-scale concerns: Ops.LoadTesting is for k6 distributed across cloud VMs; Ops.SupplyChain is for SBOM and license auditing; Ops.Capacity is for autoscaling and KEDA. These are valuable, but they are not what a homelab needs. A homelab does not autoscale. A homelab does not run distributed load tests. A homelab does not have a supply chain in the SOC2 sense.
A homelab does have:
- Infrastructure (containers, certs, DNS records, storage)
- Deployment (the order in which to start services)
- Observability (health checks, metrics, alerts)
- Configuration (env files, secrets, per-environment overrides)
- Resilience (retries, circuit breakers, restart policies)
- Security (RBAC for the GitLab admin, secret rotation)
- Networking (Traefik routing, firewall rules between VMs)
- Data governance (backups, retention, restore tests)
Eight sub-DSLs. Each one maps to a specific set of HomeLab features. This part is the index: which attributes, which generated artifacts, which HomeLab parts later in the series build on each one. By the end, you should know exactly which Ops.Dsl package to import when you want to declare a new operational concern in HomeLab.
1. Ops.Infrastructure — the nouns
Reference: Ops DSL Ecosystem — Part 15.
Attributes used by HomeLab:
[ContainerSpec(Image = "gitlab/gitlab-ce:16.4.2-ce.0", Cpu = "2", Memory = "4Gi")]
[StorageSpec(Mount = "/var/opt/gitlab", Capacity = "20Gi", Persistent = true)]
[CertificateSpec(Domain = "gitlab.frenchexdev.lab", SanList = new[] { "gitlab.frenchexdev.lab" })]
[DnsRecord(Name = "gitlab", Type = "A", Value = "192.168.56.11")]
public sealed class GitLabService { }[ContainerSpec(Image = "gitlab/gitlab-ce:16.4.2-ce.0", Cpu = "2", Memory = "4Gi")]
[StorageSpec(Mount = "/var/opt/gitlab", Capacity = "20Gi", Persistent = true)]
[CertificateSpec(Domain = "gitlab.frenchexdev.lab", SanList = new[] { "gitlab.frenchexdev.lab" })]
[DnsRecord(Name = "gitlab", Type = "A", Value = "192.168.56.11")]
public sealed class GitLabService { }HomeLab generates:
- Compose
services.gitlab.image,cpus,mem_limit,volumes - TLS cert via the
ITlsCertificateProviderplugin (native or mkcert) - DNS entry via the
IDnsProviderplugin (hosts file or PiHole or Cloudflare) - A typed
OpsTargetfor theVerifystage
Where in the series: Parts 31, 33, 34, 35, 38.
2. Ops.Deployment — the verbs of starting things
Reference: Ops DSL Ecosystem — Part 5.
Attributes:
[DeploymentOrchestrator("devlab")]
public sealed class DevLabDeployment { }
[DeploymentApp(Name = "postgres", Image = "postgres:16-alpine")]
[DeploymentDependency(typeof(PostgresApp))] // ← gitlab depends on postgres
public sealed class GitLabApp { }
[DeploymentApp(Name = "gitlab", Image = "gitlab/gitlab-ce:16.4.2-ce.0")]
[DeploymentDependency(typeof(GitLabApp))] // ← runner depends on gitlab
public sealed class GitLabRunnerApp { }[DeploymentOrchestrator("devlab")]
public sealed class DevLabDeployment { }
[DeploymentApp(Name = "postgres", Image = "postgres:16-alpine")]
[DeploymentDependency(typeof(PostgresApp))] // ← gitlab depends on postgres
public sealed class GitLabApp { }
[DeploymentApp(Name = "gitlab", Image = "gitlab/gitlab-ce:16.4.2-ce.0")]
[DeploymentDependency(typeof(GitLabApp))] // ← runner depends on gitlab
public sealed class GitLabRunnerApp { }HomeLab generates:
- A topologically-sorted DAG of services that the
Applystage executes in order - Health-check waits between dependent services (postgres must be ready before gitlab starts)
- Compose
depends_onclauses (which docker-compose v2 respects withcondition: service_healthy) - A
homelab planoutput that shows the DAG as a Mermaid graph
Where in the series: Parts 07, 37, 39.
3. Ops.Observability — the verbs of watching things
Reference: Ops DSL Ecosystem — Part 7.
Attributes:
[HealthCheck(Path = "/api/v4/version", Method = "GET", IntervalSeconds = 30, TimeoutSeconds = 5)]
[Metric(Name = "gitlab_http_requests_total", Type = "counter")]
[Metric(Name = "gitlab_http_request_duration_seconds", Type = "histogram")]
[AlertRule(
Name = "GitLabDown",
Expression = "up{job=\"gitlab\"} == 0",
For = "2m",
Severity = "critical")]
[Dashboard("GitLab Service")]
public sealed class GitLabObservability { }[HealthCheck(Path = "/api/v4/version", Method = "GET", IntervalSeconds = 30, TimeoutSeconds = 5)]
[Metric(Name = "gitlab_http_requests_total", Type = "counter")]
[Metric(Name = "gitlab_http_request_duration_seconds", Type = "histogram")]
[AlertRule(
Name = "GitLabDown",
Expression = "up{job=\"gitlab\"} == 0",
For = "2m",
Severity = "critical")]
[Dashboard("GitLab Service")]
public sealed class GitLabObservability { }HomeLab generates:
- ASP.NET-style health-check probes the
Verifystage runs (in this case, just acurlagainst the path) - Prometheus scrape config that the observability stack picks up
- Prometheus alert rules in
alerts.yaml(mounted into the Alertmanager container) - A Grafana dashboard JSON saved to
data/grafana/dashboards/gitlab.json
Where in the series: Parts 13 (this part), 44, 49.
4. Ops.Configuration — the verbs of providing settings
Reference: Ops DSL Ecosystem — Part 8.
Attributes:
[ConfigTransform(Source = "gitlab.rb.template", Target = "/etc/gitlab/gitlab.rb")]
[Secret(Name = "GITLAB_ROOT_PASSWORD", Source = "bitwarden://devlab/gitlab-root")]
[Secret(Name = "POSTGRES_PASSWORD", Source = "bitwarden://devlab/postgres")]
[EnvironmentMatrix(
Environments = new[] { "single", "multi", "ha" },
Overrides = "config-overrides.yaml")]
public sealed class DevLabConfiguration { }[ConfigTransform(Source = "gitlab.rb.template", Target = "/etc/gitlab/gitlab.rb")]
[Secret(Name = "GITLAB_ROOT_PASSWORD", Source = "bitwarden://devlab/gitlab-root")]
[Secret(Name = "POSTGRES_PASSWORD", Source = "bitwarden://devlab/postgres")]
[EnvironmentMatrix(
Environments = new[] { "single", "multi", "ha" },
Overrides = "config-overrides.yaml")]
public sealed class DevLabConfiguration { }HomeLab generates:
- The
gitlab.rbfile by templating thegitlab.rb.templatewith values from the typed config - An env file (
compose.env) for docker-compose, with secret values injected at apply time - The cross-environment matrix evaluation: which values change between
single,multi, andha
Where in the series: Parts 04, 05, 43.
5. Ops.Resilience — the verbs of recovering from failure
Reference: Ops DSL Ecosystem — Part 8.
Attributes:
[CircuitBreaker(FailureThreshold = 5, CooldownSeconds = 30)]
[RetryPolicy(MaxAttempts = 3, BackoffSeconds = 5)]
[Bulkhead(MaxConcurrent = 10)]
[RollbackPlan(Strategy = "compose-down-restore-volume")]
public sealed class GitLabResilience { }[CircuitBreaker(FailureThreshold = 5, CooldownSeconds = 30)]
[RetryPolicy(MaxAttempts = 3, BackoffSeconds = 5)]
[Bulkhead(MaxConcurrent = 10)]
[RollbackPlan(Strategy = "compose-down-restore-volume")]
public sealed class GitLabResilience { }HomeLab generates:
[InjectableDecorator]chains around the binary wrappers (so_packer.BuildAsyncis wrapped in retry + circuit-breaker)- A
homelab rollbackverb that runs the rollback plan - Saga compensation steps (see
FrenchExDev.Net.Sagafrom Part 11)
Where in the series: Parts 09, 11, 49.
6. Ops.Security — the verbs of access and rotation
Reference: Ops DSL Ecosystem — Part 12.
Attributes:
[RbacRule(Subject = "ops-team", Action = "deploy", Resource = "devlab")]
[RbacRule(Subject = "ops-team", Action = "backup", Resource = "devlab/data")]
[SecretRotation(Secret = "GITLAB_ROOT_PASSWORD", IntervalDays = 90)]
[VulnerabilityScan(Target = "gitlab/gitlab-ce", Tool = "trivy", FailOn = "high")]
public sealed class DevLabSecurity { }[RbacRule(Subject = "ops-team", Action = "deploy", Resource = "devlab")]
[RbacRule(Subject = "ops-team", Action = "backup", Resource = "devlab/data")]
[SecretRotation(Secret = "GITLAB_ROOT_PASSWORD", IntervalDays = 90)]
[VulnerabilityScan(Target = "gitlab/gitlab-ce", Tool = "trivy", FailOn = "high")]
public sealed class DevLabSecurity { }HomeLab generates:
- A
homelab access listthat prints who can do what (sourced from RBAC declarations + the GitLab API) - A
homelab rotate secretsverb that runs the rotation logic via theISecretStoreplugin - A scheduled
trivy image scanjob (viaOpsSchedule) that fails the build if a high-severity CVE is found in the GitLab image
Where in the series: Parts 28, 43.
7. Ops.Networking — the verbs of routing and isolation
Reference: Ops DSL Ecosystem — Part 16.
Attributes:
[IngressRule(
Host = "gitlab.frenchexdev.lab",
Service = "gitlab",
Port = 80,
TlsSecret = "wildcard")]
[FirewallRule(
From = "192.168.56.0/24",
To = "192.168.56.10",
Port = 80,
Action = "allow")]
[NetworkPolicy(
From = "platform-vm",
To = "data-vm",
Ports = new[] { 5432, 9000 })]
public sealed class DevLabNetworking { }[IngressRule(
Host = "gitlab.frenchexdev.lab",
Service = "gitlab",
Port = 80,
TlsSecret = "wildcard")]
[FirewallRule(
From = "192.168.56.0/24",
To = "192.168.56.10",
Port = 80,
Action = "allow")]
[NetworkPolicy(
From = "platform-vm",
To = "data-vm",
Ports = new[] { 5432, 9000 })]
public sealed class DevLabNetworking { }HomeLab generates:
- Traefik dynamic config (host-based routing, TLS termination)
- iptables / nftables rules in the Vagrant provisioning script
- Compose
networks:declarations with explicit driver and subnet - For HA topology: HAProxy backend pools
Where in the series: Parts 23, 28, 30, 33.
8. Ops.DataGovernance — the verbs of data lifecycle
Reference: Ops DSL Ecosystem — Part 17.
Attributes:
[BackupPolicy(
Target = "postgres",
Provider = "pgbackrest",
Cron = "0 2 * * *",
Retention = "30d")]
[BackupPolicy(
Target = "minio",
Provider = "restic",
Cron = "0 3 * * *",
Retention = "14d")]
[RetentionPolicy(Target = "audit-log", Retention = "365d")]
[RecoveryObjective(Rpo = "1h", Rto = "30m")]
[SeedData(Source = "fixtures/devlab-seed.sql", Target = "postgres")]
public sealed class DevLabDataGovernance { }[BackupPolicy(
Target = "postgres",
Provider = "pgbackrest",
Cron = "0 2 * * *",
Retention = "30d")]
[BackupPolicy(
Target = "minio",
Provider = "restic",
Cron = "0 3 * * *",
Retention = "14d")]
[RetentionPolicy(Target = "audit-log", Retention = "365d")]
[RecoveryObjective(Rpo = "1h", Rto = "30m")]
[SeedData(Source = "fixtures/devlab-seed.sql", Target = "postgres")]
public sealed class DevLabDataGovernance { }HomeLab generates:
- Cron jobs in the host crontab (or as compose sidecars) that run
pgbackrest backupandrestic backup - A
homelab backup runverb (immediate) - A
homelab backup restore <id>verb - A scheduled "restore-test" job that spins up a throwaway lab and verifies the restore (this is dogfood Loop 4 — see Part 06)
Where in the series: Parts 38, 45, 49.
What HomeLab does not consume (and why)
Of the 22 Ops.Dsl sub-DSLs, HomeLab does not consume the other 14 in v1. Quick justifications:
| Sub-DSL | Why HomeLab skips it |
|---|---|
Ops.Migration |
HomeLab doesn't run app migrations; the apps it hosts (like GitLab) handle their own |
Ops.Performance |
SLI/SLO is overkill for a homelab; covered indirectly by Ops.Observability |
Ops.LoadTesting |
k6 distributed scenarios are cloud-tier; HomeLab is container-tier only |
Ops.Chaos |
Same — interesting future plugin, not v1 |
Ops.Testing |
HomeLab itself uses Requirements for traceability; this DSL is for the hosted apps |
Ops.Quality |
Covered by the QualityGate library applied to HomeLab itself, not by an Ops.Dsl |
Ops.ApiContract |
For the hosted apps, not for HomeLab |
Ops.EnvironmentParity |
Topology is a single config field; we don't need a full DSL |
Ops.Lifecycle |
Sunset schedules are out of scope for a homelab |
Ops.Cost |
We do track cost (Part 47), but we use Ops.Cost minimally — only [ResourceBudget] |
Ops.Capacity |
Autoscaling is not a homelab concern |
Ops.Incident |
On-call rotations and PagerDuty are overkill for a single developer |
Ops.Compliance |
SOC2 / GDPR matrices are not what a homelab does |
Ops.SupplyChain |
SBOM is partially covered by vulnerability scan in Ops.Security |
We import 8 packages, not 22. The remaining 14 are valuable in their own right and are documented in the Ops DSL Ecosystem series. They are not part of HomeLab's contract.
The wiring
Each Ops.Dsl sub-DSL ships as a separate NuGet:
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Infrastructure" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Deployment" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Observability" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Configuration" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Resilience" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Security" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Networking" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.DataGovernance" Version="1.*" /><PackageReference Include="FrenchExDev.Net.Ops.Dsl.Infrastructure" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Deployment" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Observability" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Configuration" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Resilience" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Security" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.Networking" Version="1.*" />
<PackageReference Include="FrenchExDev.Net.Ops.Dsl.DataGovernance" Version="1.*" />Each NuGet declares its concepts via [MetaConcept]. The MetamodelRegistry discovers them at startup. HomeLab's IPlanProjector knows how to project from HomeLabConfig into the IR for each one — see the projector code in Part 12.
What this gives you that bash doesn't
A bash script does not have a vocabulary. Every operational concern is whatever string the script's author typed. Two scripts that "do health checks" have nothing in common except the verb. There is no analyzer that understands what a health check is. There is no generator that emits Prometheus alerts from a health check. There is no validator that catches "this alert references a metric that no service emits".
Eight typed Ops.Dsl sub-DSLs give you, for the same surface area:
- A shared vocabulary that other Ops.Dsl-aware tools (analyzers, doc generators, future plugins) understand
- Generated artifacts for each declaration (compose YAML, Prometheus rules, Grafana dashboards, iptables rules, cron entries)
- Cross-DSL validation (an alert rule that references a missing metric becomes a compile error)
- Plugin extensibility — a plugin can add a new sub-DSL that the existing analyzers automatically recognise
- Scope discipline — you import the eight packages you need and skip the fourteen you don't
The bargain pays back the moment you write a [HealthCheck] and discover that HomeLab automatically generates a Prometheus scrape config, an alert rule, a Grafana panel, and a Verify probe — all from one attribute.