Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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:

  1. A place to upload the dist/ directory after each build
  2. A search backend that the client-side JS can query
  3. A web server that serves the static files
  4. 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" };
    }
}

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" }
        };
    }
}

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),

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();

The pipeline is generated from typed C#. Renaming a job (publish-to-miniomirror-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:

  1. You edit a markdown file in content/blog/homelab-docker/
  2. You git push to gitlab.frenchexdev.lab/frenchexdev/StephaneErard_FrenchExDev_Cv
  3. GitLab triggers the pipeline
  4. The runner (running in DevLab) builds the static site
  5. The runner mirrors dist/ to s3://minio:9000/docs-bucket/
  6. The runner posts the search index to Meilisearch
  7. Traefik serves the new HTML at https://docs.frenchexdev.lab/...
  8. 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();
}

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.yml generated from GitLab.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.


⬇ Download