Part 37: Bringing DevLab Up — Single-VM and Multi-VM in One Sequence
"Idempotence is what
vagrant upshould have always been."
Why
We have spent thirty-six parts designing HomeLab. This part is the payoff: the actual sequence of commands a user runs to bring DevLab up from nothing, end to end. Both topologies. Same commands, different config.
The thesis of this part is: the single-VM and multi-VM sequences are the same eight commands. The only difference is one field in config-homelab.yaml. The pipeline does the work; the user types the verbs.
The eight commands
# 0. Initial setup (once per machine)
$ homelab init --name devlab --acme-name frenchexdev --acme-tld lab
$ cd devlab
$ vim config-homelab.yaml # set topology, plugins, secrets references
# 1. Build the box
$ homelab packer init
$ homelab packer build
# 2. Register the box (locally and to the registry)
$ homelab box add --local
# 3. Generate the Vagrantfile + config-vos.yaml + boot the VMs
$ homelab vos init
$ homelab vos up
# 4. DNS + TLS
$ homelab dns add gitlab.frenchexdev.lab 192.168.56.10 # or 56.11 for multi
$ homelab tls init --provider native
$ homelab tls install
$ homelab tls trust # adds the CA to the OS trust store
# 5. Compose stacks
$ homelab compose init
$ homelab compose deploy
# 6. GitLab post-install
$ homelab gitlab configure
$ homelab gitlab runner register
# 7. Verify
$ homelab gitlab status
$ homelab verify# 0. Initial setup (once per machine)
$ homelab init --name devlab --acme-name frenchexdev --acme-tld lab
$ cd devlab
$ vim config-homelab.yaml # set topology, plugins, secrets references
# 1. Build the box
$ homelab packer init
$ homelab packer build
# 2. Register the box (locally and to the registry)
$ homelab box add --local
# 3. Generate the Vagrantfile + config-vos.yaml + boot the VMs
$ homelab vos init
$ homelab vos up
# 4. DNS + TLS
$ homelab dns add gitlab.frenchexdev.lab 192.168.56.10 # or 56.11 for multi
$ homelab tls init --provider native
$ homelab tls install
$ homelab tls trust # adds the CA to the OS trust store
# 5. Compose stacks
$ homelab compose init
$ homelab compose deploy
# 6. GitLab post-install
$ homelab gitlab configure
$ homelab gitlab runner register
# 7. Verify
$ homelab gitlab status
$ homelab verifyThat's it. Eight verbs, two of them in groups (packer init && packer build, vos init && vos up, compose init && compose deploy, gitlab configure && runner register). The user does not edit any generated file.
Single-VM vs multi-VM: the diff
The two sequences are byte-for-byte identical except for the config field:
# config-homelab.yaml — single-VM
topology: single
# config-homelab.yaml — multi-VM
topology: multi# config-homelab.yaml — single-VM
topology: single
# config-homelab.yaml — multi-VM
topology: multiThe single-VM run produces:
- One Vagrantfile entry
- One compose file (
compose.main.yaml) with all 14 services - One Traefik config
- One DNS entry pointing at
192.168.56.10
The multi-VM run produces:
- Four Vagrantfile entries (
gateway,platform,data,obs) - Four compose files (
compose.gateway.yaml,compose.platform.yaml,compose.data.yaml,compose.obs.yaml), each with the relevant subset of services - One Traefik config (the same one — Traefik runs on
gatewayand routes to backends on the other VMs by IP) - Multiple DNS entries —
gitlab.frenchexdev.lab→ 192.168.56.10 (gateway),data.frenchexdev.lab→ 192.168.56.12, etc.
homelab compose deploy walks the compose files in dependency order and runs docker compose up -d on each VM via the engine wrapper. The dependency order comes from the Ops.Dsl Plan stage's DAG.
homelab packer init
- Loads
config-homelab.yaml - Runs every
IPackerBundleContributorin order - Writes
packer/variables.pkr.hcl,packer/sources.pkr.hcl,packer/build.pkr.hcl,packer/http/answers,packer/scripts/install-docker.sh, … - Publishes
PackerBundleGeneratedevent
homelab packer build
- Calls
IPackerClient.BuildAsync("./packer") - Packer runs the build (15–30 minutes on first run, faster with cache)
- Output:
packer/output-vagrant/devlab-dockerhost-virtualbox.box - Publishes
PackerBuildStartedandPackerBuildCompletedevents
homelab box add --local
- Calls
vagrant box add devlab/alpine-3.21-dockerhost packer/output-vagrant/devlab-dockerhost-virtualbox.box - Publishes
VagrantBoxAdded
homelab vos init
- Loads
config-homelab.yaml - Calls
IVosConfigGenerator.Generate(config) - Writes
Vagrantfile(the fixed one),config-vos.yaml(the data),schemas/vos-config.schema.json
homelab vos up
- Calls
IVosOrchestrator.UpAllAsync(), which callsIVosBackend.UpAsync()per machine - Vagrant boots the VM(s) using the box from step 2
- Publishes
VosUpStartedper VM andVosUpCompletedper VM
homelab dns add / homelab tls init / homelab tls install / homelab tls trust
- Each is a small CLI verb that delegates to the corresponding handler
- DNS entries are created via
IDnsProvider - Cert is generated via
ITlsCertificateProvider, written todata/certs/ - The Vagrant synced folder mounts
data/certs/into/etc/ssl/devlabon the VM tls trustenrols the CA into the host OS trust store
homelab compose init
- Loads the resolved config + the topology resolver's machine list
- Runs every
IComposeFileContributorfor each VM - Writes one
compose.<vm-role>.yamlper VM
homelab compose deploy
- Walks the per-VM compose files
- For each, sets
DOCKER_HOST=tcp://<vm-ip>:2375(or 2376 with mTLS) - Calls
IDockerComposeClient.UpAsync(file, projectName) - Publishes
ComposeStackDeployedper stack - Waits for healthchecks (which means waiting up to 10 minutes for GitLab to start)
homelab gitlab configure
- Calls the GitLab REST API (via the typed
IGitLabApi) to:- Wait for
/api/v4/version - Reset the root password (using the seed from the secret store)
- Create the
frenchexdevnamespace - Create the
homelabrepo - Create the runner registration token
- Wait for
- Publishes
GitLabConfigured
homelab gitlab runner register
- Reads the registration token from the secret store
- Calls the GitLab API to register a runner
- Updates the runner config in the platform VM via
IDockerClient.ExecAsync - Publishes
GitLabRunnerRegistered
homelab verify
- Runs the
Verifystage of the pipeline - Walks every
OpsObservability.HealthCheckdeclared by the contributors - Reports pass/fail per check
- Exit 0 if all pass, exit 1 if any fail
Idempotence
Every verb is idempotent. Running homelab packer build twice does not produce two boxes; it skips if the box is already up to date. Running homelab vos up twice does not boot two VMs; it sees they are already up and prints "no-op". Running homelab compose deploy twice does not duplicate containers; docker-compose v2 reconciles the desired state.
This matters because the typical user runs the eight commands two or three times in their lifetime — once on a fresh machine, then again after a config change, then again after a HomeLab upgrade. Each run is additive: only the changes propagate, the rest is left alone.
The pipeline enforces idempotence at the stage level. Each stage's RunAsync is expected to be idempotent for the same input context. The architecture test asserts:
[Fact]
public async Task running_the_pipeline_twice_with_the_same_input_produces_the_same_output()
{
var sp = TestServices.WithEverythingFaked();
var pipeline = sp.GetRequiredService<IHomeLabPipeline>();
var request = new VosUpRequest(MachineName: null, ConfigPath: TestConfigPath);
var first = await pipeline.RunAsync(request, default);
var second = await pipeline.RunAsync(request, default);
first.IsSuccess.Should().BeTrue();
second.IsSuccess.Should().BeTrue();
second.Value.Artifacts.Should().BeEquivalentTo(first.Value.Artifacts);
}[Fact]
public async Task running_the_pipeline_twice_with_the_same_input_produces_the_same_output()
{
var sp = TestServices.WithEverythingFaked();
var pipeline = sp.GetRequiredService<IHomeLabPipeline>();
var request = new VosUpRequest(MachineName: null, ConfigPath: TestConfigPath);
var first = await pipeline.RunAsync(request, default);
var second = await pipeline.RunAsync(request, default);
first.IsSuccess.Should().BeTrue();
second.IsSuccess.Should().BeTrue();
second.Value.Artifacts.Should().BeEquivalentTo(first.Value.Artifacts);
}The test
public sealed class DevLabBringUpE2ETests
{
[Fact]
[Trait("category", "e2e")]
public async Task single_vm_topology_brings_up_devlab_end_to_end()
{
using var lab = await TestLab.NewAsync(name: "e2e-single", topology: "single");
await lab.WriteConfig(StandardSingleConfig());
await lab.Cli("packer", "init").AssertExitZero();
await lab.Cli("packer", "build").AssertExitZero(); // ~20 min on cold cache
await lab.Cli("box", "add", "--local").AssertExitZero();
await lab.Cli("vos", "init").AssertExitZero();
await lab.Cli("vos", "up").AssertExitZero(); // ~3 min
await lab.Cli("dns", "add", "gitlab.frenchexdev.lab", "192.168.56.10").AssertExitZero();
await lab.Cli("tls", "init", "--provider", "native").AssertExitZero();
await lab.Cli("tls", "install").AssertExitZero();
await lab.Cli("compose", "init").AssertExitZero();
await lab.Cli("compose", "deploy").AssertExitZero(); // ~10 min for GitLab
await lab.Cli("gitlab", "configure").AssertExitZero();
await lab.Cli("gitlab", "runner", "register").AssertExitZero();
var verify = await lab.Cli("verify");
verify.ExitCode.Should().Be(0);
verify.StdOut.Should().Contain("✓ gitlab healthy");
verify.StdOut.Should().Contain("✓ postgres healthy");
verify.StdOut.Should().Contain("✓ minio healthy");
verify.StdOut.Should().Contain("✓ traefik healthy");
verify.StdOut.Should().Contain("✓ baget healthy");
}
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task multi_vm_topology_brings_up_devlab_end_to_end()
{
using var lab = await TestLab.NewAsync(name: "e2e-multi", topology: "multi");
// ... same eight commands, just with topology: multi
// ... ~45 minutes total because four VMs boot in sequence
}
}public sealed class DevLabBringUpE2ETests
{
[Fact]
[Trait("category", "e2e")]
public async Task single_vm_topology_brings_up_devlab_end_to_end()
{
using var lab = await TestLab.NewAsync(name: "e2e-single", topology: "single");
await lab.WriteConfig(StandardSingleConfig());
await lab.Cli("packer", "init").AssertExitZero();
await lab.Cli("packer", "build").AssertExitZero(); // ~20 min on cold cache
await lab.Cli("box", "add", "--local").AssertExitZero();
await lab.Cli("vos", "init").AssertExitZero();
await lab.Cli("vos", "up").AssertExitZero(); // ~3 min
await lab.Cli("dns", "add", "gitlab.frenchexdev.lab", "192.168.56.10").AssertExitZero();
await lab.Cli("tls", "init", "--provider", "native").AssertExitZero();
await lab.Cli("tls", "install").AssertExitZero();
await lab.Cli("compose", "init").AssertExitZero();
await lab.Cli("compose", "deploy").AssertExitZero(); // ~10 min for GitLab
await lab.Cli("gitlab", "configure").AssertExitZero();
await lab.Cli("gitlab", "runner", "register").AssertExitZero();
var verify = await lab.Cli("verify");
verify.ExitCode.Should().Be(0);
verify.StdOut.Should().Contain("✓ gitlab healthy");
verify.StdOut.Should().Contain("✓ postgres healthy");
verify.StdOut.Should().Contain("✓ minio healthy");
verify.StdOut.Should().Contain("✓ traefik healthy");
verify.StdOut.Should().Contain("✓ baget healthy");
}
[Fact]
[Trait("category", "e2e")]
[Trait("category", "slow")]
public async Task multi_vm_topology_brings_up_devlab_end_to_end()
{
using var lab = await TestLab.NewAsync(name: "e2e-multi", topology: "multi");
// ... same eight commands, just with topology: multi
// ... ~45 minutes total because four VMs boot in sequence
}
}The single-VM test runs nightly. The multi-VM test runs weekly. Both are gated behind the slow trait.
What this gives you that bash doesn't
A bash script that brings up a homelab is the README on every personal-infrastructure repo on GitHub. It is a list of commands the user is expected to copy-paste into a terminal, in order, hoping each one succeeds. There is no idempotence. There is no observability. There is no event log. If step 7 fails, the user has to know which earlier step is partially complete.
Eight typed CLI verbs backed by a six-stage pipeline give you, for the same surface area:
- Eight commands for both topologies, same shape
- Idempotence at every stage
- Event publication at every meaningful step
- Per-stage failure clarity (the event log tells you exactly which stage failed)
- The same commands in CI as on a developer's laptop
- A
verifyverb that closes the loop with health checks
The bargain is the entire payoff of HomeLab: the user goes from zero to a working DevLab in eight commands, regardless of whether they want one VM or four.