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" };
}
}[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-packagesbucket 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 adotnet restoreagainst the baget URL works for every package, public or private. The first time you requestNewtonsoft.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")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$ dotnet nuget add source https://baget.frenchexdev.lab/v3/index.json --name devlabOr 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><?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.$ 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");
}[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.Yamlpipeline - 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.