Part 40: DevLab — The Box Registry HomeLab Hosts for Itself
"The third dogfood loop: the registry that hosts the boxes that build the labs that host the registry."
Why
Part 29 introduced the Vagrant.Registry library — a small ASP.NET Core service that hosts .box files and serves the Vagrant catalog JSON. This part is about deploying it inside DevLab itself. The deployment is the first dogfood loop to close: the registry that DevLab depends on for VM images is hosted by DevLab.
The thesis of this part is: Vagrant.Registry runs as a DevLab compose service. HomeLab provisions DevLab with the bootstrap box, then publishes a new box to the registry running inside DevLab. The next time HomeLab provisions a VM, it pulls the box from the registry HomeLab just provisioned. Loop closed.
The compose contributor
[Injectable(ServiceLifetime.Singleton)]
public sealed class VagrantRegistryComposeContributor : IComposeFileContributor
{
public string TargetVm => "platform"; // platform VM in multi/ha, only VM in single
public void Contribute(ComposeFile compose)
{
compose.Services["vagrant-registry"] = new ComposeService
{
Image = $"frenchexdev/vagrant-registry:{_config.VagrantRegistry?.Version ?? "1.0.0"}",
Restart = "always",
Hostname = "vagrant-registry",
Environment = new()
{
["MINIO_ENDPOINT"] = $"https://minio.{_config.Acme.Tld}",
["MINIO_BUCKET"] = "vagrant-boxes",
["MINIO_ACCESS_KEY_FILE"] = "/run/secrets/minio_access_key",
["MINIO_SECRET_KEY_FILE"] = "/run/secrets/minio_secret_key",
["REGISTRY_TOKEN_FILE"] = "/run/secrets/vagrant_registry_token",
["REGISTRY_BASE_URL"] = $"https://registry.{_config.Acme.Tld}",
["ASPNETCORE_URLS"] = "http://+:8080"
},
Volumes = new() { }, // stateless, all storage in MinIO
Networks = new() { "platform" },
Secrets = new() { "minio_access_key", "minio_secret_key", "vagrant_registry_token" },
HealthCheck = new ComposeHealthcheck
{
Test = new[] { "CMD", "curl", "-f", "http://localhost:8080/healthz" },
Interval = "30s",
Timeout = "5s",
Retries = 3
},
DependsOn = new()
{
["minio"] = new() { Condition = "service_healthy" }
},
Labels = new TraefikLabels()
.Enable()
.Router("vagrant-registry", r => r
.Rule($"Host(`registry.{_config.Acme.Tld}`)")
.EntryPoints("websecure")
.Tls(certResolver: "default"))
.Build()
};
compose.Secrets["vagrant_registry_token"] ??= new ComposeSecret
{
File = "./secrets/vagrant_registry_token"
};
}
}[Injectable(ServiceLifetime.Singleton)]
public sealed class VagrantRegistryComposeContributor : IComposeFileContributor
{
public string TargetVm => "platform"; // platform VM in multi/ha, only VM in single
public void Contribute(ComposeFile compose)
{
compose.Services["vagrant-registry"] = new ComposeService
{
Image = $"frenchexdev/vagrant-registry:{_config.VagrantRegistry?.Version ?? "1.0.0"}",
Restart = "always",
Hostname = "vagrant-registry",
Environment = new()
{
["MINIO_ENDPOINT"] = $"https://minio.{_config.Acme.Tld}",
["MINIO_BUCKET"] = "vagrant-boxes",
["MINIO_ACCESS_KEY_FILE"] = "/run/secrets/minio_access_key",
["MINIO_SECRET_KEY_FILE"] = "/run/secrets/minio_secret_key",
["REGISTRY_TOKEN_FILE"] = "/run/secrets/vagrant_registry_token",
["REGISTRY_BASE_URL"] = $"https://registry.{_config.Acme.Tld}",
["ASPNETCORE_URLS"] = "http://+:8080"
},
Volumes = new() { }, // stateless, all storage in MinIO
Networks = new() { "platform" },
Secrets = new() { "minio_access_key", "minio_secret_key", "vagrant_registry_token" },
HealthCheck = new ComposeHealthcheck
{
Test = new[] { "CMD", "curl", "-f", "http://localhost:8080/healthz" },
Interval = "30s",
Timeout = "5s",
Retries = 3
},
DependsOn = new()
{
["minio"] = new() { Condition = "service_healthy" }
},
Labels = new TraefikLabels()
.Enable()
.Router("vagrant-registry", r => r
.Rule($"Host(`registry.{_config.Acme.Tld}`)")
.EntryPoints("websecure")
.Tls(certResolver: "default"))
.Build()
};
compose.Secrets["vagrant_registry_token"] ??= new ComposeSecret
{
File = "./secrets/vagrant_registry_token"
};
}
}The registry container is stateless. It reads boxes from MinIO and serves them on port 8080, behind Traefik on https://registry.frenchexdev.lab. The MinIO bucket vagrant-boxes was created by the MinIO init sidecar from Part 38.
The publish flow inside DevLab
After DevLab is up, HomeLab can publish a box to it:
# Build a new box
$ homelab packer build
# Publish it to the registry inside DevLab
$ homelab box publish \
--name frenchexdev/alpine-3.21-dockerhost \
--version 1.0.5 \
--box-file ./packer/output-vagrant/devlab-dockerhost-virtualbox.box \
--registry https://registry.frenchexdev.lab \
--provider virtualbox# Build a new box
$ homelab packer build
# Publish it to the registry inside DevLab
$ homelab box publish \
--name frenchexdev/alpine-3.21-dockerhost \
--version 1.0.5 \
--box-file ./packer/output-vagrant/devlab-dockerhost-virtualbox.box \
--registry https://registry.frenchexdev.lab \
--provider virtualboxThe CLI verb runs BoxPublishRequestHandler from Part 29. The handler:
- Reads the registry token from
ISecretStore - Opens the
.boxfile as a stream - POSTs it to
https://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhost/1.0.5/virtualboxwith the token in theX-Registry-Tokenheader - The registry validates the token, streams the box into MinIO, and updates the catalog metadata
- The handler publishes a
VagrantBoxPublishedevent
After the publish succeeds, the catalog at https://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhost shows the new version. Vagrant's vagrant box add command can fetch it directly:
$ vagrant box add frenchexdev/alpine-3.21-dockerhost \
--box-version 1.0.5 \
https://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhost$ vagrant box add frenchexdev/alpine-3.21-dockerhost \
--box-version 1.0.5 \
https://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhostClosing the loop
To prove the loop closes, run the next homelab vos init && homelab vos up:
vos initwrites aVagrantfilethat referencesfrenchexdev/alpine-3.21-dockerhostversion1.0.5vos upcallsvagrant up- Vagrant checks the local box cache; the new version is not there
- Vagrant fetches the catalog from
https://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhost - Vagrant downloads
1.0.5fromhttps://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhost/1.0.5/virtualbox.box - The download streams from MinIO via the registry container
- Vagrant unpacks the box, boots the VM
- The VM is running an image that was built, published, and downloaded entirely through DevLab's own infrastructure
The loop is closed. The registry that hosts the boxes is hosted by the lab the boxes provision.
Manifest signing (optional)
For an extra security tier, the registry can sign each box with a CA-signed certificate, and Vagrant clients can verify the signature. The registry stores the signature alongside the box in MinIO, and the catalog JSON includes a signature_url field.
public sealed record CatalogProvider(
string Name,
string Url,
string Checksum,
string ChecksumType,
string? SignatureUrl);public sealed record CatalogProvider(
string Name,
string Url,
string Checksum,
string ChecksumType,
string? SignatureUrl);Vagrant.Registry can verify signatures on upload (rejecting boxes signed by an unknown CA) and serve the signature alongside the box. Verification on the client side is up to the consumer — Vagrant does not natively check signatures, but a wrapper script around vagrant box add can.
The test
[Fact]
[Trait("category", "integration")]
public async Task box_publish_against_running_devlab_appears_in_catalog()
{
using var devlab = await TestLab.NewAsync(name: "publish-test", topology: "single");
await devlab.UpAllAsync();
devlab.Service("vagrant-registry").Should().BeHealthy();
await using var box = TestData.SmallBoxStream();
var publishResult = await devlab.Cli("box", "publish",
"--name", "test/alpine",
"--version", "1.0.0",
"--box-file", "fixtures/test-box.box",
"--provider", "virtualbox");
publishResult.ExitCode.Should().Be(0);
var catalog = await devlab.HttpGetJson($"https://registry.{devlab.Domain}/test/alpine");
catalog["versions"].Should().HaveCount(1);
catalog["versions"][0]["version"].ToString().Should().Be("1.0.0");
}
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task vagrant_can_consume_a_box_published_by_homelab()
{
using var devlab = await TestLab.NewAsync(name: "loop-test", topology: "single");
await devlab.UpAllAsync();
// 1. Publish a small test box
await devlab.Cli("box", "publish",
"--name", "test/alpine",
"--version", "1.0.0",
"--box-file", "fixtures/test-box.box",
"--provider", "virtualbox");
// 2. Have raw vagrant fetch it from the local registry
var addResult = await devlab.RunOnHost("vagrant", "box", "add",
"--name", "test/alpine",
"--box-version", "1.0.0",
$"https://registry.{devlab.Domain}/test/alpine");
addResult.ExitCode.Should().Be(0);
var listResult = await devlab.RunOnHost("vagrant", "box", "list");
listResult.StdOut.Should().Contain("test/alpine (virtualbox, 1.0.0)");
}[Fact]
[Trait("category", "integration")]
public async Task box_publish_against_running_devlab_appears_in_catalog()
{
using var devlab = await TestLab.NewAsync(name: "publish-test", topology: "single");
await devlab.UpAllAsync();
devlab.Service("vagrant-registry").Should().BeHealthy();
await using var box = TestData.SmallBoxStream();
var publishResult = await devlab.Cli("box", "publish",
"--name", "test/alpine",
"--version", "1.0.0",
"--box-file", "fixtures/test-box.box",
"--provider", "virtualbox");
publishResult.ExitCode.Should().Be(0);
var catalog = await devlab.HttpGetJson($"https://registry.{devlab.Domain}/test/alpine");
catalog["versions"].Should().HaveCount(1);
catalog["versions"][0]["version"].ToString().Should().Be("1.0.0");
}
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task vagrant_can_consume_a_box_published_by_homelab()
{
using var devlab = await TestLab.NewAsync(name: "loop-test", topology: "single");
await devlab.UpAllAsync();
// 1. Publish a small test box
await devlab.Cli("box", "publish",
"--name", "test/alpine",
"--version", "1.0.0",
"--box-file", "fixtures/test-box.box",
"--provider", "virtualbox");
// 2. Have raw vagrant fetch it from the local registry
var addResult = await devlab.RunOnHost("vagrant", "box", "add",
"--name", "test/alpine",
"--box-version", "1.0.0",
$"https://registry.{devlab.Domain}/test/alpine");
addResult.ExitCode.Should().Be(0);
var listResult = await devlab.RunOnHost("vagrant", "box", "list");
listResult.StdOut.Should().Contain("test/alpine (virtualbox, 1.0.0)");
}The second test is the one that proves the loop closes. It runs against a real DevLab, publishes a real box, and uses the real vagrant box add to consume it. If it passes, dogfood loop #3 is intact.
What this gives you that bash doesn't
A bash script that "publishes a Vagrant box" is scp to a fileserver and a manual JSON edit. There is no catalog, no signing, no auth, no test that proves Vagrant can consume it.
A typed registry hosted inside the lab gives you, for the same surface area:
- A real Vagrant catalog that
vagrant box addconsumes natively - MinIO-backed storage so boxes share infrastructure with the rest of DevLab
- Token-authenticated uploads with the token from the secret store
- Optional signature verification for extra security tiers
- An E2E test that proves the dogfood loop closes
The bargain pays back the first time you publish a box and watch a colleague's vagrant up pull it from a registry that did not exist before HomeLab provisioned DevLab.