Part 06: The Dogfood Target — DevLab, Not DocHub
"The compiler does not sleep. The wiki does. So make the wiki the compiler." — paraphrased from the Ops DSL Ecosystem series
Why
Most "build a homelab" series open with a fictional demo project. A fake todo app. An invented documentation platform called something like "DocHub". A toy microservice. The author spends fifteen parts standing up infrastructure for a thing that does not exist, will never exist, and would not survive contact with a real user if it did.
The reader closes the tab thinking "that was nice, but my problem is different". The series has taught them how to deploy a fictional thing. The reader's actual problem — "I want a real GitLab on my laptop" — is left as an exercise.
I am not going to do that to you.
This series ships with no fictional project. The thing HomeLab stands up is the real FrenchExDev development environment, the same environment in which I am writing this blog post, the same environment that hosts the source code of HomeLab itself, the same environment whose CI runs the tests for HomeLab on every push, the same environment whose NuGet feed publishes FrenchExDev.Net.HomeLab.nupkg, the same environment whose docs site serves the very page you are reading right now.
We call it DevLab. It is real. It is what you would see if you visited my laptop. It is what you would be looking at if I gave you SSH access. It is the proof of HomeLab, not a demo.
This pivot — from a fake project to a real one — is the most consequential decision in the series. It changes everything downstream. It forces every chapter to be honest. It eliminates the "that's not what I'd do in production" objection, because what you are reading is the production setup. And it creates a feedback loop that is uniquely powerful: every time HomeLab improves, the DevLab that hosts HomeLab improves; and every time DevLab improves, the next HomeLab build benefits from that improvement.
We call this eating your own dog food. There are five dogfood loops in DevLab. Each one is worth understanding before we touch a line of pipeline code.
The shape: DevLab in one paragraph
DevLab is a homelab that runs:
- GitLab Omnibus — hosts the FrenchExDev.Net monorepo (the same repo HomeLab lives in)
- gitlab-runner — builds and tests every push to HomeLab
- Postgres — for GitLab
- MinIO — for CI artifacts, GitLab LFS, the Vagrant box registry storage, and the NuGet feed storage
- baget — a NuGet feed that ships
FrenchExDev.Net.HomeLab.nupkgand the rest of the FrenchExDev libraries - Vagrant.Registry — a small HTTP service that hosts
.boxfiles HomeLab produces - Traefik — TLS termination, host-based routing, dashboard
- PiHole — internal DNS so containers can resolve
gitlab.frenchexdev.lab - Prometheus, Grafana, Loki, Alertmanager — observability
- A static docs site — this very blog, served via Traefik over HTTPS
That's it. No fictional services. No invented domain. No "for the purposes of this tutorial". Every container is something that runs on my laptop right now and that I would lose work without.
The five dogfood loops
A dogfood loop is a cycle in which the output of HomeLab feeds back into the input of HomeLab. The cycle exists because the thing HomeLab manages is the thing HomeLab depends on. There are five of them in DevLab, and each one is a separate kind of evidence.
Loop 1 — Source
You push a change to HomeLab. The GitLab instance running in DevLab receives the push. The runner running in DevLab picks up the job. The runner builds HomeLab. The next time anyone (you, your colleague, CI) runs homelab vos up, they're using a HomeLab built by the GitLab that HomeLab itself provisioned.
The first time this loop closes — the first time you ship a HomeLab change that fixes a bug in the GitLab provisioning, push it to the GitLab that HomeLab provisioned, watch the runner build the new HomeLab, and then re-run vos up against the same GitLab — is the first time you know HomeLab is real.
Loop 2 — Packages
CI publishes the HomeLab NuGet package to a feed that DevLab hosts. The next dotnet restore — perhaps from a sibling project, perhaps from a different machine on the same LAN — pulls HomeLab from a feed HomeLab is hosting. The package the consumer downloads was built and published by the runner that runs in the lab that was provisioned by HomeLab. The dependency graph forms a cycle.
This is also where the bootstrap question gets interesting. If dotnet restore cannot reach baget.frenchexdev.lab, you cannot build HomeLab. But the only way baget.frenchexdev.lab exists is if HomeLab built DevLab. So how do you build the first HomeLab, if you need HomeLab to build the GitLab that builds HomeLab? See the bootstrap paradox below.
Loop 3 — Boxes
You build a Vagrant box. You publish it to the registry. The registry runs in DevLab. The next vos up — yours, your colleague's, CI's — pulls the box from a registry that HomeLab is hosting, on a VM that HomeLab provisioned, behind a Traefik that HomeLab configured.
This loop is the cleanest of the five, because it is the one with the least magic. The box is a tarball. The registry is a JSON catalog. The catalog is fetched over HTTPS. There is nothing to it — except for the fact that the entire chain is hosted by the lab that the chain hosts.
Loop 4 — Backups
The backup framework backs up the GitLab that holds HomeLab's source. So far, every backup framework on the planet does that. The dogfood part is the restore test: a scheduled job spins up an entirely fresh, throwaway HomeLab instance, restores the backup into it, and asserts that the restored GitLab serves a real API response. If the restore fails, the alert wakes you up. If the restore succeeds, you know — with cryptographic certainty — that your backups are not just bytes on disk but a recoverable state.
The throwaway lab is itself provisioned by HomeLab, in a different namespace from the production DevLab, on the same workstation. The two coexist because Part 51 makes multi-instance a first-class invariant. The restore test is the most powerful evidence that the backup works, and it is only possible because spinning up a new HomeLab instance is cheap.
Loop 5 — Docs
This blog post is hosted by DevLab. The CI pipeline that built this page ran on a runner that runs in DevLab. The Traefik that serves this page over HTTPS was configured by HomeLab. The cert that secures this page was generated by HomeLab's TLS provider. The DNS entry that resolves docs.frenchexdev.lab was added by HomeLab's DNS provider. The documentation of HomeLab is served by HomeLab. When I improve HomeLab, the docs improve. When I improve the docs, you improve at HomeLab. When you improve HomeLab, the docs you publish to your own DevLab improve.
This is the loop with the highest emotional payload, because it is the loop you are currently inside. You are reading documentation generated by the system the documentation describes. There is no separation between the message and the medium. The medium is the message.
The bootstrap paradox
You can't build HomeLab without GitLab, and you can't run GitLab without HomeLab. This is a real chicken-and-egg, and it deserves a real answer.
The answer is a one-shot bash bootstrap script that we never run again.
Here is the contract:
The bash script is intentionally awful. It is ~200 lines. It uses vagrant, docker, curl, jq, and that's it. It does not parameterise. It does not validate. It does not even check if the command it ran succeeded — because the next step calls curl against the result, and that is the check. It exists for one purpose: to bring DevLab into existence the first time on a new machine. Once HomeLab exists, the bash script is replaced. Forever.
This is the only piece of un-typed, un-tested code in the entire HomeLab story. We tolerate it because:
- It runs once per machine, not per cycle. Most developers will run it twice in their lifetime — once on their laptop, once on their desktop.
- It is small enough to read in one sitting. ~200 lines is a coffee, not a project.
- The handoff is deterministic: when bootstrap.sh exits, HomeLab takes the same DevLab and re-provisions it from scratch. The bash script's job is to provide a first GitLab, not a permanent one. After HomeLab runs, the bash-script-managed GitLab is destroyed and replaced by a HomeLab-managed GitLab on the same VM. The bash script is the seed; HomeLab is the tree.
There is one further subtlety: HomeLab's source code, while you are running dotnet build for the first time, has to come from somewhere. The bootstrap script clones it from a public mirror (GitHub, in our case) into a temporary directory. After the first homelab vos up, the local DevLab GitLab has the canonical copy, and git remote set-url origin https://gitlab.frenchexdev.lab/frenchexdev/HomeLab.git cuts the umbilical to GitHub. From that point forward, you push to your own GitLab. GitHub becomes a backup mirror, not the source of truth.
The wiring
There is no wiring in this part. This part is the target, not an implementation. Every subsequent part — Acts II through X — refers back to this target. When Part 31 authors the compose file, it authors the compose file for DevLab. When Part 39 configures GitLab, it configures DevLab's GitLab. When Part 40 sets up the Vagrant box registry, it sets up the registry that hosts DevLab's own boxes. There is no other lab. There is no other use case. There is no fictional anything.
This is liberating. Every chapter has a concrete answer to the question "why does this matter". Because if it doesn't work, my GitLab goes down. My CI goes down. My docs site goes down. My NuGet feed goes down. My backups go untested. The pile is back. I lose my Saturday again.
The test
The whole series is the test. Every chapter ends with a test. Every test runs against DevLab. Every test produces evidence that the chapter does what it claims. The aggregate of those tests is the assertion that DevLab works.
But there is one specific test we can write right now, because it captures the essence of the dogfood pivot:
[Fact]
public async Task devlab_can_build_and_publish_a_new_homelab()
{
// 1. Spin up DevLab from scratch (uses HomeLab itself)
using var devlab = await TestLab.NewAsync(name: "devlab-meta", topology: "single");
await devlab.UpAllAsync();
devlab.Service("gitlab").Should().BeHealthy();
devlab.Service("baget").Should().BeHealthy();
// 2. Push the current HomeLab source into DevLab's GitLab
var gitlab = devlab.GitLabClient();
await gitlab.CreateRepoAsync("frenchexdev/HomeLab");
await gitlab.PushFromLocalAsync("../../src/HomeLab", branch: "main");
// 3. Wait for the runner to build it
var pipeline = await gitlab.WaitForPipelineAsync("frenchexdev/HomeLab", branch: "main", timeoutMinutes: 15);
pipeline.Status.Should().Be("success");
// 4. Verify the new nupkg landed in baget
var baget = devlab.BagetClient();
var versions = await baget.GetVersionsAsync("FrenchExDev.Net.HomeLab");
versions.Should().NotBeEmpty();
// 5. Verify a fresh consumer can pull it
var consumer = await TempProject.NewAsync();
await consumer.AddPackageAsync("FrenchExDev.Net.HomeLab",
version: versions.Last(),
source: $"https://baget.{devlab.Domain}/v3/index.json");
await consumer.RestoreAsync();
consumer.Should().HaveSuccessfulRestore();
}[Fact]
public async Task devlab_can_build_and_publish_a_new_homelab()
{
// 1. Spin up DevLab from scratch (uses HomeLab itself)
using var devlab = await TestLab.NewAsync(name: "devlab-meta", topology: "single");
await devlab.UpAllAsync();
devlab.Service("gitlab").Should().BeHealthy();
devlab.Service("baget").Should().BeHealthy();
// 2. Push the current HomeLab source into DevLab's GitLab
var gitlab = devlab.GitLabClient();
await gitlab.CreateRepoAsync("frenchexdev/HomeLab");
await gitlab.PushFromLocalAsync("../../src/HomeLab", branch: "main");
// 3. Wait for the runner to build it
var pipeline = await gitlab.WaitForPipelineAsync("frenchexdev/HomeLab", branch: "main", timeoutMinutes: 15);
pipeline.Status.Should().Be("success");
// 4. Verify the new nupkg landed in baget
var baget = devlab.BagetClient();
var versions = await baget.GetVersionsAsync("FrenchExDev.Net.HomeLab");
versions.Should().NotBeEmpty();
// 5. Verify a fresh consumer can pull it
var consumer = await TempProject.NewAsync();
await consumer.AddPackageAsync("FrenchExDev.Net.HomeLab",
version: versions.Last(),
source: $"https://baget.{devlab.Domain}/v3/index.json");
await consumer.RestoreAsync();
consumer.Should().HaveSuccessfulRestore();
}That test is slow. It takes 15 minutes to run end to end. It runs once a night, not on every commit. But when it passes, it proves that the entire dogfood loop is closed: HomeLab can stand up DevLab, DevLab can build HomeLab, and the artifacts of that build are usable by a downstream consumer.
This is the test that, when it goes red, you do not ship. Not the unit tests. Not the architecture tests. Not the verb tests. This test — the test that proves the dogfood loop is intact. Because if the dogfood loop is broken, HomeLab is back to being a tool for fictional projects, and the entire premise of this series collapses.
What this gives you that bash doesn't
Bash gives you a setup.sh that worked on the author's laptop in 2022 and has not been run end to end since. It gives you a provision-gitlab.sh that hard-codes the version. It gives you a bootstrap-everything.sh that nobody admits to writing. It gives you a wiki page that says "this is broken, see Slack #ops".
The dogfood pivot, with five real loops backing five real workflows, gives you, for the same surface area:
- A real GitLab that hosts your real source
- A real CI that builds your real code
- A real package feed that publishes your real artifacts
- A real box registry that stores your real images
- A real backup framework that proves your real recovery
- A real docs site that hosts your real documentation
- One bash script that runs once per machine, not once per cycle
- A test that proves the whole loop is intact
The bargain is the boldest in the series. It costs the most upfront (you have to actually build DevLab, not pretend). It pays back the moment your real GitLab goes down on a Sunday morning and you bring it back up in twelve minutes from a backup, because the backup was tested last night by a HomeLab job you had nothing to do with.
Cross-links
- Part 01: The Problem
- Part 30: Topology Composition
- Part 36: The Bootstrap Paradox
- Part 37: Bringing DevLab Up
- Part 39: DevLab GitLab and Runners
- Part 40: DevLab — The Box Registry
- Part 41: DevLab — The NuGet Feed
- Part 42: DevLab — The Docs Site
- Part 51: Running Many Labs Side-By-Side
- Ops DSL Ecosystem — Part 26: The Vision — "the compiler does not sleep, the wiki does"