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

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

The CLI verb runs BoxPublishRequestHandler from Part 29. The handler:

  1. Reads the registry token from ISecretStore
  2. Opens the .box file as a stream
  3. POSTs it to https://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhost/1.0.5/virtualbox with the token in the X-Registry-Token header
  4. The registry validates the token, streams the box into MinIO, and updates the catalog metadata
  5. The handler publishes a VagrantBoxPublished event

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

Closing the loop

To prove the loop closes, run the next homelab vos init && homelab vos up:

  1. vos init writes a Vagrantfile that references frenchexdev/alpine-3.21-dockerhost version 1.0.5
  2. vos up calls vagrant up
  3. Vagrant checks the local box cache; the new version is not there
  4. Vagrant fetches the catalog from https://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhost
  5. Vagrant downloads 1.0.5 from https://registry.frenchexdev.lab/frenchexdev/alpine-3.21-dockerhost/1.0.5/virtualbox.box
  6. The download streams from MinIO via the registry container
  7. Vagrant unpacks the box, boots the VM
  8. 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);

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

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 add consumes 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.


⬇ Download