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 41: DevLab — The NuGet Feed HomeLab Hosts for Itself

"The second dogfood loop: a feed that hosts the package that builds the lab that hosts the feed."


Why

Part 06 listed five dogfood loops; this is loop #2. HomeLab needs a NuGet feed to publish its own packages — FrenchExDev.Net.HomeLab, FrenchExDev.Net.HomeLab.Cli, the Ops.Dsl sub-DSLs, and any plugin a team might write. The obvious option is nuget.org. The obvious objection is: nuget.org is a public feed, and HomeLab v0.x is not ready for public consumption. We want a private feed, ideally one we host ourselves, ideally one that runs inside DevLab so the dogfood story stays consistent.

The thesis of this part is: baget is a small, self-hosted NuGet server that runs as a DevLab compose service. CI publishes HomeLab packages to it. The next dotnet restore from any project on the same machine — including HomeLab itself — pulls from a feed HomeLab is hosting.


Why baget

There are a few options for a self-hosted NuGet feed:

Option Pros Cons
baget Tiny (one binary), .NET, MIT, supports v3 protocol, S3 storage backend Not actively maintained as of 2025
NuGet.Server First-party Microsoft project Older, less features
Sonatype Nexus Industrial-strength, multi-protocol Java, heavy, overkill
Verdaccio Lightweight npm, not NuGet
Cloudsmith / GitHub Packages Cloud, free for public Not self-hosted

baget wins on the trade-off curve we care about: small, .NET, supports the v3 protocol that modern dotnet clients expect, and supports an S3-compatible storage backend (which means it can use our MinIO).


The compose contributor

[Injectable(ServiceLifetime.Singleton)]
public sealed class BagetComposeContributor : IComposeFileContributor
{
    public string TargetVm => "platform";

    public void Contribute(ComposeFile compose)
    {
        compose.Services["baget"] = new ComposeService
        {
            Image = $"loicsharma/baget:{_config.Baget?.Version ?? "latest"}",
            Restart = "always",
            Hostname = "baget",
            Environment = new()
            {
                ["ApiKey"] = "",   // empty, see below
                ["ApiKey_File"] = "/run/secrets/baget_api_key",   // baget supports _File suffix for secrets
                ["Storage__Type"]                = "AwsS3",
                ["Storage__AccessKey_File"]      = "/run/secrets/minio_access_key",
                ["Storage__SecretKey_File"]      = "/run/secrets/minio_secret_key",
                ["Storage__Endpoint"]            = $"https://minio.{_config.Acme.Tld}",
                ["Storage__Bucket"]              = "nuget-packages",
                ["Storage__Region"]              = "us-east-1",
                ["Storage__ForcePathStyle"]      = "true",
                ["Database__Type"]               = "Sqlite",
                ["Database__ConnectionString"]   = "Data Source=/var/baget/baget.db",
                ["Search__Type"]                 = "Database",
                ["Mirror__Enabled"]              = "true",
                ["Mirror__PackageSource"]        = "https://api.nuget.org/v3/index.json",
                ["AllowPackageOverwrites"]       = "false",
                ["PackageDeletionBehavior"]      = "Unlist",
            },
            Volumes = new() { "baget_data:/var/baget" },
            Networks = new() { "platform" },
            Secrets = new() { "baget_api_key", "minio_access_key", "minio_secret_key" },
            HealthCheck = new ComposeHealthcheck
            {
                Test = new[] { "CMD", "curl", "-f", "http://localhost:80/v3/index.json" },
                Interval = "30s",
                Timeout = "5s",
                Retries = 3,
                StartPeriod = "20s"
            },
            DependsOn = new()
            {
                ["minio"] = new() { Condition = "service_healthy" }
            },
            Labels = new TraefikLabels()
                .Enable()
                .Router("baget", r => r
                    .Rule($"Host(`baget.{_config.Acme.Tld}`)")
                    .EntryPoints("websecure")
                    .Tls(certResolver: "default"))
                .Build()
        };

        compose.Volumes["baget_data"] ??= new ComposeVolume { Driver = "local" };
        compose.Secrets["baget_api_key"] ??= new ComposeSecret { File = "./secrets/baget_api_key" };
    }
}

The key configuration choices:

  • Storage: S3 against MinIO (the nuget-packages bucket from Part 38). baget streams uploads directly to MinIO; the local volume is just for the SQLite metadata DB.
  • Mirror: enabled against nuget.org. This means baget transparently proxies any package not in its local feed from upstream — so a dotnet restore against the baget URL works for every package, public or private. The first time you request Newtonsoft.Json, baget fetches it from nuget.org, caches it in MinIO, and serves it from local storage thereafter. This makes baget a both a private feed and a caching proxy, which is the most useful posture for a homelab.
  • Authentication: an API key sourced from the secret store. The key is required for upload (publish) but not for download (restore), which is the standard NuGet feed convention.
  • Overwrites: disabled. Once a version is published, it cannot be replaced. This matches nuget.org's policy and prevents the worst kinds of supply-chain footguns.

The CI flow that publishes HomeLab

The DevLab CI pipeline (generated in Part 42 using GitLab.Ci.Yaml) has a publish stage that pushes the HomeLab nupkg to baget:

new JobBuilder("publish-homelab")
    .WithStage("publish")
    .WithImage("mcr.microsoft.com/dotnet/sdk:10.0")
    .WithRules(r => r.OnDefaultBranch().Or().OnTag(@"v\d+\.\d+\.\d+"))
    .WithVariable("BAGET_URL", $"https://baget.{config.Acme.Tld}/v3/index.json")
    .WithScript(
        "dotnet pack src/HomeLab.Cli -c Release -o ./out",
        "dotnet pack src/HomeLab     -c Release -o ./out",
        "for nupkg in ./out/*.nupkg; do",
        "  dotnet nuget push \"$nupkg\" --source $BAGET_URL --api-key $BAGET_API_KEY",
        "done")
    .WithSecret("BAGET_API_KEY", source: "ci-variable")

The runner has BAGET_API_KEY injected as a CI variable from the GitLab API (which homelab gitlab configure set up). The CI image is the standard .NET SDK image. The push goes to https://baget.frenchexdev.lab/v3/index.json, which Traefik routes to the baget container, which streams the upload to MinIO.


The consumer side

Any project on the same network can consume packages from baget by adding a NuGet source:

$ dotnet nuget add source https://baget.frenchexdev.lab/v3/index.json --name devlab

Or by creating a nuget.config next to Directory.Packages.props:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <clear />
    <add key="devlab" value="https://baget.frenchexdev.lab/v3/index.json" />
  </packageSources>
</configuration>

Note the <clear /> — this removes the default nuget.org source, so all package fetches go through baget. baget's mirror feature transparently fetches anything missing from upstream. The user does not notice; the dev experience is identical to using nuget.org directly.


The dogfood payoff

After CI publishes FrenchExDev.Net.HomeLab.1.0.5.nupkg, any HomeLab consumer can pull it:

$ dotnet add package FrenchExDev.Net.HomeLab --version 1.0.5
  Determining projects to restore...
  GET https://baget.frenchexdev.lab/v3-flatcontainer/frenchexdev.net.homelab/index.json
  GET https://baget.frenchexdev.lab/v3-flatcontainer/frenchexdev.net.homelab/1.0.5/frenchexdev.net.homelab.1.0.5.nupkg
  Restored project.

The package the consumer just downloaded was built and published by the runner that runs in the lab that was provisioned by HomeLab. The dependency graph forms a cycle. The cycle works.


The test

[Fact]
[Trait("category", "integration")]
public async Task baget_serves_v3_index_after_startup()
{
    using var devlab = await TestLab.NewAsync(name: "baget-test", topology: "single");
    await devlab.UpAllAsync();
    devlab.Service("baget").Should().BeHealthy();

    var index = await devlab.HttpGetJson($"https://baget.{devlab.Domain}/v3/index.json");
    index["version"].ToString().Should().Be("3.0.0");
    index["resources"].Should().NotBeEmpty();
}

[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task ci_pushes_homelab_nupkg_then_dotnet_restore_pulls_it()
{
    using var devlab = await TestLab.NewAsync(name: "baget-loop", topology: "single");
    await devlab.UpAllAsync();

    // 1. Push a tiny test package to baget
    var pushResult = await devlab.RunOnHost("dotnet", "nuget", "push",
        "fixtures/test.1.0.0.nupkg",
        "--source", $"https://baget.{devlab.Domain}/v3/index.json",
        "--api-key", await devlab.GetSecretAsync("BAGET_API_KEY"));
    pushResult.ExitCode.Should().Be(0);

    // 2. Create a temp project and restore from the local feed
    using var consumer = await TempProject.NewAsync();
    await consumer.AddSourceAsync($"https://baget.{devlab.Domain}/v3/index.json", apiKey: null);
    var addResult = await consumer.RunAsync("dotnet", "add", "package", "Test", "--version", "1.0.0");
    addResult.ExitCode.Should().Be(0);
    consumer.PackageReferences.Should().Contain(p => p.Name == "Test" && p.Version == "1.0.0");
}

The second test is dogfood loop #2 in test form. If it passes, the loop is intact.


What this gives you that bash doesn't

A bash script that "publishes nuget packages" is dotnet pack followed by dotnet nuget push to nuget.org with an API key in plain text. There is no private feed. There is no caching proxy. There is no test that proves the package is consumable.

A self-hosted baget feed inside DevLab gives you, for the same surface area:

  • A private NuGet feed for your own packages
  • A caching proxy for nuget.org so subsequent restores are faster and offline-friendly
  • MinIO-backed storage sharing infrastructure with everything else
  • CI integration via the typed GitLab.Ci.Yaml pipeline
  • Tests that prove publish + restore round-trips

The bargain pays back the first time you push HomeLab v1.0.0 to your own feed, and a colleague on the same LAN runs dotnet add package FrenchExDev.Net.HomeLab and gets it from a feed that did not exist before HomeLab provisioned it.


⬇ Download