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 36: The Bootstrap Paradox — Stage 0 Bash, Then HomeLab Forever

"There are exactly two ways to break a circular dependency: an external seed, or a previous version. We use the first one once."


Why

The dogfood pivot from Part 06 creates a real chicken-and-egg problem: HomeLab is supposed to be the tool that provisions DevLab, but DevLab is supposed to host the source code that builds HomeLab. To compile HomeLab the first time, you need to clone its source from GitLab. To clone its source from GitLab, you need a running GitLab. To run GitLab, you need HomeLab. To get HomeLab, you need a running GitLab. Loop.

There are two ways to break this loop:

  1. An external seed: clone HomeLab's source from a public mirror (GitHub) the first time, build the first binary, then use it to provision DevLab and migrate the canonical source there. After that, the GitHub mirror is just a backup.
  2. A previous version: ship HomeLab as a binary the user can dotnet tool install, so they never need to compile from source on a fresh machine.

Both work. Both have trade-offs. Option 1 is simpler when HomeLab is unstable and frequently rebuilt from source (which is exactly the early-development scenario this series targets). Option 2 is better once the tool is mature.

The thesis of this part is: a 200-line bash bootstrap script handles option 1. It is the only piece of un-typed, un-tested, un-versioned code in the entire HomeLab story. We tolerate it because it runs once per machine and is replaced by HomeLab itself afterwards.


The script

bootstrap.sh is short enough to read in one sitting. Here is the structure:

#!/usr/bin/env bash
set -euo pipefail

# bootstrap.sh — produce just enough GitLab to compile the first HomeLab binary.
# This script runs ONCE per machine. After it succeeds, HomeLab takes over.

# 1. Required tools
require() {
    command -v "$1" >/dev/null 2>&1 || { echo "missing: $1"; exit 1; }
}
require git
require vagrant
require curl
require jq
require dotnet

# 2. Configuration (env vars with sensible defaults)
ACME_NAME="${ACME_NAME:-frenchexdev}"
ACME_TLD="${ACME_TLD:-lab}"
GITLAB_HOST="${GITLAB_HOST:-gitlab.${ACME_NAME}.${ACME_TLD}}"
SUBNET="${SUBNET:-192.168.56}"
WORKDIR="${WORKDIR:-$HOME/.homelab/bootstrap}"
SOURCE_REPO="${SOURCE_REPO:-https://github.com/frenchexdev/homelab.git}"

mkdir -p "$WORKDIR"
cd "$WORKDIR"

# 3. Spin up a minimal Alpine VM with raw vagrant
cat > Vagrantfile <<EOF
Vagrant.configure("2") do |config|
  config.vm.box = "generic/alpine321"
  config.vm.network "private_network", ip: "${SUBNET}.10"
  config.vm.provider "virtualbox" do |v|
    v.cpus = 4
    v.memory = 8192
  end
  config.vm.provision "shell", inline: <<-SHELL
    sed -i 's|^#\\(.*community\\)|\\1|' /etc/apk/repositories
    apk update
    apk add --no-cache docker docker-cli-compose curl
    rc-update add docker default
    service docker start
  SHELL
end
EOF
vagrant up

# 4. Add the GITLAB_HOST to /etc/hosts so we can curl it
sudo sh -c "echo '${SUBNET}.10 ${GITLAB_HOST}' >> /etc/hosts"

# 5. SSH in and run a minimal docker-compose for GitLab
vagrant ssh -c "cat > /home/vagrant/compose.yml" <<COMPOSE
services:
  gitlab:
    image: gitlab/gitlab-ce:16.11.0-ce.0
    hostname: ${GITLAB_HOST}
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        external_url 'http://${GITLAB_HOST}'
        gitlab_rails['initial_root_password'] = 'changeme-bootstrap'
    ports:
      - "80:80"
      - "22:22"
    volumes:
      - gitlab_config:/etc/gitlab
      - gitlab_logs:/var/log/gitlab
      - gitlab_data:/var/opt/gitlab
volumes:
  gitlab_config: {}
  gitlab_logs: {}
  gitlab_data: {}
COMPOSE
vagrant ssh -c "cd /home/vagrant && sudo docker compose up -d"

# 6. Wait for GitLab to be ready (this takes ~5 minutes on first start)
echo "Waiting for GitLab to come up (this takes ~5 minutes)..."
for i in $(seq 1 60); do
    if curl -fs "http://${GITLAB_HOST}/api/v4/version" >/dev/null 2>&1; then
        echo "GitLab is up."
        break
    fi
    sleep 10
done

# 7. Create a personal access token via root login (using the seeded password)
TOKEN=$(curl -fs -X POST "http://${GITLAB_HOST}/oauth/token" \
    -d "grant_type=password" \
    -d "username=root" \
    -d "password=changeme-bootstrap" \
    | jq -r '.access_token')

# 8. Create the FrenchExDev/homelab project
curl -fs -X POST "http://${GITLAB_HOST}/api/v4/projects" \
    -H "Authorization: Bearer ${TOKEN}" \
    -d "name=homelab" \
    -d "namespace_id=1" \
    -d "visibility=private"

# 9. Clone HomeLab from the public mirror and push to the local GitLab
git clone "${SOURCE_REPO}" homelab-source
cd homelab-source
git remote add devlab "http://root:changeme-bootstrap@${GITLAB_HOST}/root/homelab.git"
git push -u devlab main
cd ..

# 10. Build the first HomeLab binary
cd homelab-source
dotnet build src/HomeLab.Cli -c Release
HOMELAB_BIN="$(pwd)/src/HomeLab.Cli/bin/Release/net10.0/homelab"

echo
echo "================================================================="
echo "Bootstrap complete."
echo
echo "Next steps:"
echo "  1. Add HomeLab to your PATH:"
echo "       export PATH=\"\$PATH:$(dirname "$HOMELAB_BIN")\""
echo
echo "  2. Initialize a HomeLab project from this bootstrap state:"
echo "       homelab init --from-bootstrap --workdir ${WORKDIR}"
echo
echo "  3. From now on, use 'homelab' for everything."
echo "     This bootstrap script will not be needed again."
echo "================================================================="

That is the entire bootstrap. ~200 lines once you account for the heredocs and the comments. The script:

  1. Checks prerequisites (git, vagrant, curl, jq, dotnet)
  2. Reads a few env vars
  3. Creates a minimal Vagrantfile
  4. Boots one VM
  5. Installs Docker on it
  6. Adds a hosts entry
  7. SSHes in and runs a minimal docker-compose up -d gitlab
  8. Waits for GitLab to be ready
  9. Creates an OAuth token
  10. Creates the project
  11. Pushes the source from the public mirror
  12. Builds HomeLab

After that, HomeLab takes over.


The handoff: homelab init --from-bootstrap

The user runs:

$ homelab init --from-bootstrap --workdir ~/.homelab/bootstrap
✓ detected bootstrap state at ~/.homelab/bootstrap
✓ found GitLab at http://gitlab.frenchexdev.lab
✓ project created at /Users/me/devlab
  - config-homelab.yaml
  - .vscode/settings.json
  - schemas/
✓ ready: run `homelab vos init && homelab vos up` to upgrade the bootstrap into a managed lab

What --from-bootstrap does:

  1. Reads the bootstrap state (which VM IP, which GitLab URL, which token)
  2. Generates a config-homelab.yaml that matches the bootstrap's choices (so the next vos up reuses the same VM)
  3. Sets the topology to single (the bootstrap created one VM)
  4. Imports the GitLab token into the secret store
  5. Stops the bootstrap-managed compose stack so HomeLab can take over cleanly

The next homelab vos up runs against the same VM, but now the compose file is generated by HomeLab's contributors, the GitLab is configured by HomeLab's gitlab.rb generator, the cert is generated by HomeLab's TLS provider, and everything is under HomeLab's control. The bootstrap script never runs again.


The Saga

The bootstrap is almost a saga (a long-running, compensable transaction). Steps 3 through 12 each can fail; some of them have natural compensation steps (e.g. if step 9 fails, step 10's local clone has to be wiped). For HomeLab v1, we keep the bootstrap as a bash script — sagas are too much machinery for one-shot bootstrap. But the handoff (the part where HomeLab adopts the bootstrap state) is implemented as a Saga:

[Saga]
public sealed class BootstrapHandoffSaga
{
    [SagaStep(Order = 1, Compensation = nameof(RestoreBootstrapMarker))]
    public async Task<Result> ImportTokensAsync(BootstrapHandoffContext ctx, CancellationToken ct) { /* ... */ }

    [SagaStep(Order = 2, Compensation = nameof(RemoveProject))]
    public async Task<Result> CreateLocalProjectAsync(BootstrapHandoffContext ctx, CancellationToken ct) { /* ... */ }

    [SagaStep(Order = 3, Compensation = nameof(StartBootstrapCompose))]
    public async Task<Result> StopBootstrapComposeAsync(BootstrapHandoffContext ctx, CancellationToken ct) { /* ... */ }

    [SagaStep(Order = 4, Compensation = nameof(RemoveHomeLabConfig))]
    public async Task<Result> WriteHomeLabConfigAsync(BootstrapHandoffContext ctx, CancellationToken ct) { /* ... */ }

    public async Task<Result> RestoreBootstrapMarker(BootstrapHandoffContext ctx, CancellationToken ct) { /* ... */ }
    public async Task<Result> RemoveProject(BootstrapHandoffContext ctx, CancellationToken ct) { /* ... */ }
    public async Task<Result> StartBootstrapCompose(BootstrapHandoffContext ctx, CancellationToken ct) { /* ... */ }
    public async Task<Result> RemoveHomeLabConfig(BootstrapHandoffContext ctx, CancellationToken ct) { /* ... */ }
}

The saga ensures that if the handoff fails partway, the bootstrap state is restored intact. The user can fix the issue and re-run homelab init --from-bootstrap.


The diagram

Diagram
The bash bootstrap is one-shot; the handoff is a compensable saga — if token import or compose stop fails partway, the pre-bootstrap state is restored intact.

What this gives you that bash doesn't

The bootstrap is bash. The point is that it is the only bash, and that it is one-shot. After the handoff, every subsequent change to DevLab goes through HomeLab.

Compare with the alternative — a project that ships with a permanent setup.sh and an update.sh and a fix-everything.sh. Every script accumulates edge cases. Every script drifts. Every script is the median piece of evidence in Part 01's pile.

Stage-0 bootstrap with a typed handoff saga gives you, for the same surface area:

  • One bash script, ~200 lines, runs once per machine
  • A typed handoff saga that adopts the bootstrap state into HomeLab
  • Compensable failure at every saga step
  • A clear cutover — the bash script is dead after homelab init --from-bootstrap succeeds
  • A migration path to a future "ship as binary" model where the bash is not needed at all

The bargain is the smallest acceptable bash footprint to break the chicken-and-egg, with everything else owned by HomeLab.


⬇ Download