Part 42: DevLab — The Docs Site That Documents HomeLab
"You are reading documentation generated by the system the documentation describes. There is no separation between the message and the medium."
Why
This is dogfood loop #5: the docs site that documents HomeLab is hosted by HomeLab. Specifically, this very blog (the StephaneErard_FrenchExDev_Cv repo) is built by the GitLab CI pipeline running in DevLab, the artifacts land in MinIO, Meilisearch indexes them, and Traefik serves them at https://docs.frenchexdev.lab over HTTPS using the wildcard cert.
The thesis of this part is: the docs site is a static-file consumer of MinIO with a Meilisearch search backend, served by Traefik. The CI pipeline that builds it is generated by GitLab.Ci.Yaml from typed C#. The full loop closes: every commit to the docs repo triggers a CI job that publishes new HTML to the same MinIO bucket Traefik serves from, with no manual step.
The shape of the docs site
The blog itself is a static site (the This Website, Static blog explains how it is built). It consists of:
- ~150 markdown files in
content/blog/ - A small Node.js builder that walks the markdown, renders HTML, and emits a
dist/directory - A couple of Mermaid diagrams per post (rendered at build time)
- A search index file consumed by client-side JavaScript
To host it inside DevLab, we need:
- A place to upload the
dist/directory after each build - A search backend that the client-side JS can query
- A web server that serves the static files
- CI that runs the build and uploads the artifacts
We already have all of those: MinIO for storage, Meilisearch for search, Traefik for serving, GitLab CI for the build.
The Meilisearch contributor
Meilisearch is the only new service that this part adds:
[Injectable(ServiceLifetime.Singleton)]
public sealed class MeilisearchComposeContributor : IComposeFileContributor
{
public string TargetVm => "data";
public void Contribute(ComposeFile compose)
{
compose.Services["meilisearch"] = new ComposeService
{
Image = "getmeili/meilisearch:v1.10",
Restart = "always",
Hostname = "meilisearch",
Environment = new()
{
["MEILI_ENV"] = "production",
["MEILI_MASTER_KEY_FILE"] = "/run/secrets/meilisearch_master_key",
["MEILI_NO_ANALYTICS"] = "true",
["MEILI_HTTP_PAYLOAD_SIZE_LIMIT"] = "100MB"
},
Volumes = new() { "meilisearch_data:/meili_data" },
Networks = new() { "data-net", "platform" },
Secrets = new() { "meilisearch_master_key" },
HealthCheck = new ComposeHealthcheck
{
Test = new[] { "CMD", "wget", "--spider", "-q", "http://localhost:7700/health" },
Interval = "30s",
Timeout = "5s",
Retries = 3
}
};
compose.Volumes["meilisearch_data"] ??= new ComposeVolume { Driver = "local" };
compose.Secrets["meilisearch_master_key"] ??= new ComposeSecret { File = "./secrets/meilisearch_master_key" };
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class MeilisearchComposeContributor : IComposeFileContributor
{
public string TargetVm => "data";
public void Contribute(ComposeFile compose)
{
compose.Services["meilisearch"] = new ComposeService
{
Image = "getmeili/meilisearch:v1.10",
Restart = "always",
Hostname = "meilisearch",
Environment = new()
{
["MEILI_ENV"] = "production",
["MEILI_MASTER_KEY_FILE"] = "/run/secrets/meilisearch_master_key",
["MEILI_NO_ANALYTICS"] = "true",
["MEILI_HTTP_PAYLOAD_SIZE_LIMIT"] = "100MB"
},
Volumes = new() { "meilisearch_data:/meili_data" },
Networks = new() { "data-net", "platform" },
Secrets = new() { "meilisearch_master_key" },
HealthCheck = new ComposeHealthcheck
{
Test = new[] { "CMD", "wget", "--spider", "-q", "http://localhost:7700/health" },
Interval = "30s",
Timeout = "5s",
Retries = 3
}
};
compose.Volumes["meilisearch_data"] ??= new ComposeVolume { Driver = "local" };
compose.Secrets["meilisearch_master_key"] ??= new ComposeSecret { File = "./secrets/meilisearch_master_key" };
}
}Meilisearch is small (~50 MB image, ~100 MB RAM), fast (millisecond search latency), and HTTP-native. The client-side JS hits it directly via a public read-only API key.
The static-docs serving contributor
The actual serving is done by Traefik against a MinIO public bucket. We add a small Traefik contributor for the docs route:
[Injectable(ServiceLifetime.Singleton)]
public sealed class StaticDocsTraefikContributor : ITraefikContributor
{
private readonly HomeLabConfig _config;
public void Contribute(TraefikDynamicConfig dyn)
{
var docsHost = $"docs.{_config.Acme.Tld}";
dyn.Http.Routers["docs"] = new TraefikRouter
{
Rule = $"Host(`{docsHost}`)",
Service = "docs-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "compression", "rewrite-docs" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
// Service points at MinIO; the rewrite middleware translates requests
dyn.Http.Services["docs-svc"] = new TraefikService
{
LoadBalancer = new TraefikLoadBalancer
{
Servers = new[] { new TraefikServer { Url = "http://minio:9000" } },
PassHostHeader = false // we override the host header in the rewrite middleware
}
};
dyn.Http.Middlewares["compression"] ??= new TraefikMiddleware
{
Compress = new TraefikCompress { ExcludedContentTypes = new[] { "text/event-stream" } }
};
dyn.Http.Middlewares["rewrite-docs"] ??= new TraefikMiddleware
{
// Rewrite /foo/bar → /docs-bucket/foo/bar so MinIO serves the right object
AddPrefix = new TraefikAddPrefix { Prefix = "/docs-bucket" }
};
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class StaticDocsTraefikContributor : ITraefikContributor
{
private readonly HomeLabConfig _config;
public void Contribute(TraefikDynamicConfig dyn)
{
var docsHost = $"docs.{_config.Acme.Tld}";
dyn.Http.Routers["docs"] = new TraefikRouter
{
Rule = $"Host(`{docsHost}`)",
Service = "docs-svc",
EntryPoints = new[] { "websecure" },
Middlewares = new[] { "security-headers", "compression", "rewrite-docs" },
Tls = new TraefikRouterTls { CertResolver = "default" }
};
// Service points at MinIO; the rewrite middleware translates requests
dyn.Http.Services["docs-svc"] = new TraefikService
{
LoadBalancer = new TraefikLoadBalancer
{
Servers = new[] { new TraefikServer { Url = "http://minio:9000" } },
PassHostHeader = false // we override the host header in the rewrite middleware
}
};
dyn.Http.Middlewares["compression"] ??= new TraefikMiddleware
{
Compress = new TraefikCompress { ExcludedContentTypes = new[] { "text/event-stream" } }
};
dyn.Http.Middlewares["rewrite-docs"] ??= new TraefikMiddleware
{
// Rewrite /foo/bar → /docs-bucket/foo/bar so MinIO serves the right object
AddPrefix = new TraefikAddPrefix { Prefix = "/docs-bucket" }
};
}
}The middleware chain is straightforward: security headers + compression + a path rewrite that prepends the MinIO bucket name. MinIO serves objects at http://minio:9000/<bucket>/<key>, so a request to https://docs.frenchexdev.lab/blog/homelab-docker/01-the-problem.html gets rewritten to http://minio:9000/docs-bucket/blog/homelab-docker/01-the-problem.html, which MinIO happily serves because the bucket is set to public read.
The MinIO bucket layout from Part 38 gains a new entry:
new MinioBucket("docs-bucket", LifecycleDays: null, PublicRead: true),new MinioBucket("docs-bucket", LifecycleDays: null, PublicRead: true),The CI pipeline (typed via GitLab.Ci.Yaml)
The docs repo's .gitlab-ci.yml is generated from typed C# using FrenchExDev.Net.GitLab.Ci.Yaml:
public static GitLabCiPipeline DocsCi(HomeLabConfig config) => new GitLabCiPipelineBuilder()
.WithStages("build", "test", "publish", "deploy")
.WithVariable("MINIO_ENDPOINT", $"https://minio.{config.Acme.Tld}")
.WithVariable("MINIO_BUCKET", "docs-bucket")
.WithVariable("MEILI_ENDPOINT", $"https://meilisearch.{config.Acme.Tld}")
.WithJob(new JobBuilder("build-docs")
.WithStage("build")
.WithImage("node:22-alpine")
.WithScript(
"apk add --no-cache python3 make g++",
"npm ci",
"node tools/build.js")
.WithArtifact("dist/", expireIn: TimeSpan.FromDays(7)))
.WithJob(new JobBuilder("test-html")
.WithStage("test")
.WithImage("node:22-alpine")
.WithScript(
"npm ci",
"node tools/check-links.js dist/")
.WithDependency("build-docs"))
.WithJob(new JobBuilder("publish-to-minio")
.WithStage("publish")
.WithImage("minio/mc:latest")
.WithScript(
"mc alias set devlab $MINIO_ENDPOINT $MINIO_ACCESS_KEY $MINIO_SECRET_KEY",
"mc mirror --overwrite --remove dist/ devlab/$MINIO_BUCKET/")
.WithSecret("MINIO_ACCESS_KEY", source: "ci-variable")
.WithSecret("MINIO_SECRET_KEY", source: "ci-variable")
.WithDependency("build-docs")
.WithRules(r => r.OnDefaultBranch()))
.WithJob(new JobBuilder("update-meilisearch-index")
.WithStage("deploy")
.WithImage("curlimages/curl:latest")
.WithScript(
"curl -X POST $MEILI_ENDPOINT/indexes/blog/documents " +
"-H \"Authorization: Bearer $MEILI_ADMIN_KEY\" " +
"-H 'Content-Type: application/json' " +
"--data-binary @dist/search-index.json")
.WithSecret("MEILI_ADMIN_KEY", source: "ci-variable")
.WithDependency("publish-to-minio")
.WithRules(r => r.OnDefaultBranch()))
.Build();public static GitLabCiPipeline DocsCi(HomeLabConfig config) => new GitLabCiPipelineBuilder()
.WithStages("build", "test", "publish", "deploy")
.WithVariable("MINIO_ENDPOINT", $"https://minio.{config.Acme.Tld}")
.WithVariable("MINIO_BUCKET", "docs-bucket")
.WithVariable("MEILI_ENDPOINT", $"https://meilisearch.{config.Acme.Tld}")
.WithJob(new JobBuilder("build-docs")
.WithStage("build")
.WithImage("node:22-alpine")
.WithScript(
"apk add --no-cache python3 make g++",
"npm ci",
"node tools/build.js")
.WithArtifact("dist/", expireIn: TimeSpan.FromDays(7)))
.WithJob(new JobBuilder("test-html")
.WithStage("test")
.WithImage("node:22-alpine")
.WithScript(
"npm ci",
"node tools/check-links.js dist/")
.WithDependency("build-docs"))
.WithJob(new JobBuilder("publish-to-minio")
.WithStage("publish")
.WithImage("minio/mc:latest")
.WithScript(
"mc alias set devlab $MINIO_ENDPOINT $MINIO_ACCESS_KEY $MINIO_SECRET_KEY",
"mc mirror --overwrite --remove dist/ devlab/$MINIO_BUCKET/")
.WithSecret("MINIO_ACCESS_KEY", source: "ci-variable")
.WithSecret("MINIO_SECRET_KEY", source: "ci-variable")
.WithDependency("build-docs")
.WithRules(r => r.OnDefaultBranch()))
.WithJob(new JobBuilder("update-meilisearch-index")
.WithStage("deploy")
.WithImage("curlimages/curl:latest")
.WithScript(
"curl -X POST $MEILI_ENDPOINT/indexes/blog/documents " +
"-H \"Authorization: Bearer $MEILI_ADMIN_KEY\" " +
"-H 'Content-Type: application/json' " +
"--data-binary @dist/search-index.json")
.WithSecret("MEILI_ADMIN_KEY", source: "ci-variable")
.WithDependency("publish-to-minio")
.WithRules(r => r.OnDefaultBranch()))
.Build();The pipeline is generated from typed C#. Renaming a job (publish-to-minio → mirror-to-minio) is a refactor in the C# code, not a YAML edit. Adding a new stage is a method call. The output is committed to the docs repo as .gitlab-ci.yml.
homelab gitlab configure-docs-ci writes the file the first time and sets the CI variables (MINIO_ACCESS_KEY, MINIO_SECRET_KEY, MEILI_ADMIN_KEY) via the GitLab API.
The dogfood loop
This is the loop:
- You edit a markdown file in
content/blog/homelab-docker/ - You
git pushtogitlab.frenchexdev.lab/frenchexdev/StephaneErard_FrenchExDev_Cv - GitLab triggers the pipeline
- The runner (running in DevLab) builds the static site
- The runner mirrors
dist/tos3://minio:9000/docs-bucket/ - The runner posts the search index to Meilisearch
- Traefik serves the new HTML at
https://docs.frenchexdev.lab/... - You refresh your browser and read the new content — through DevLab
Every link in the chain runs in DevLab. Every secret comes from the secret store. Every artifact lives in MinIO. Every search query hits Meilisearch. The docs are hosted by the system they document.
The test
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task docs_pipeline_publishes_html_to_minio_and_indexes_meilisearch()
{
using var devlab = await TestLab.NewAsync(name: "docs-loop", topology: "single");
await devlab.UpAllAsync();
// 1. Push the docs source to the local GitLab
var gitlab = devlab.GitLabClient();
await gitlab.CreateRepoAsync("frenchexdev/docs");
await gitlab.PushFromLocalAsync("../docs-fixture", branch: "main");
// 2. Wait for the pipeline to finish
var pipeline = await gitlab.WaitForPipelineAsync("frenchexdev/docs", branch: "main", timeoutMinutes: 10);
pipeline.Status.Should().Be("success");
// 3. Verify the docs are reachable via Traefik
var index = await devlab.HttpGetString($"https://docs.{devlab.Domain}/index.html");
index.Should().Contain("<title>"); // ungated content check
// 4. Verify Meilisearch has indexed
var search = await devlab.HttpPostJson(
$"https://meilisearch.{devlab.Domain}/indexes/blog/search",
new { q = "topology" });
search["hits"].Should().NotBeEmpty();
}[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task docs_pipeline_publishes_html_to_minio_and_indexes_meilisearch()
{
using var devlab = await TestLab.NewAsync(name: "docs-loop", topology: "single");
await devlab.UpAllAsync();
// 1. Push the docs source to the local GitLab
var gitlab = devlab.GitLabClient();
await gitlab.CreateRepoAsync("frenchexdev/docs");
await gitlab.PushFromLocalAsync("../docs-fixture", branch: "main");
// 2. Wait for the pipeline to finish
var pipeline = await gitlab.WaitForPipelineAsync("frenchexdev/docs", branch: "main", timeoutMinutes: 10);
pipeline.Status.Should().Be("success");
// 3. Verify the docs are reachable via Traefik
var index = await devlab.HttpGetString($"https://docs.{devlab.Domain}/index.html");
index.Should().Contain("<title>"); // ungated content check
// 4. Verify Meilisearch has indexed
var search = await devlab.HttpPostJson(
$"https://meilisearch.{devlab.Domain}/indexes/blog/search",
new { q = "topology" });
search["hits"].Should().NotBeEmpty();
}What this gives you that bash doesn't
A bash script that "deploys docs" is rsync plus a hand-rolled search-index regeneration plus a manual cache flush. It works once. It is regenerated from memory each time.
A typed CI pipeline + MinIO + Meilisearch + Traefik chain inside DevLab gives you, for the same surface area:
- A typed
.gitlab-ci.ymlgenerated fromGitLab.Ci.Yaml - MinIO-backed static hosting with a Traefik path rewrite
- Meilisearch index updates triggered by the same pipeline
- The full dogfood loop — docs hosted by the system they document
- An E2E test that proves the loop closes
The bargain pays back the first time you push a doc fix and watch it appear at https://docs.frenchexdev.lab/... thirty seconds later, served by infrastructure HomeLab provisioned.
End of Act VI
DevLab is up. The bootstrap is dead. GitLab hosts HomeLab's source. Runners build HomeLab on every push. baget hosts the resulting nupkg. The Vagrant registry hosts the resulting boxes. The docs site (this very blog) is served from MinIO via Traefik, indexed by Meilisearch, with TLS provided by HomeLab's wildcard cert. Every commit closes one of the five dogfood loops.
Act VII drops back into operational depth: the secrets store that all of those _FILE-suffixed environment variables actually read from, the observability stack that watches every service, the backup framework that proves the data is recoverable, multi-host scheduling for users who want to spread VMs across machines, cost tracking for the (real, electrical) bill, and GPU passthrough for the subset of users who care.