View code on GitHub

Infrastructure as Code at Home: Building a VirtualBox & Vagrant Lab

In this post I’ll walk through how I set up a fully reproducible home lab powered by VirtualBox and Vagrant—where every VM, network, and service is defined in code. You’ll see how I use shell-provisioners, synced folders, and private networking to spin up a two-node stack, and how treating my lab as code eliminates “it works on my machine” headaches once and for all.

Instructions to reproduce this lab are found in the GitHub reposeitory.


Architecture Overview

I defined two VMs (db and app) on a private network to mirror a simple staging environment:

Vagrant.configure("2") do |config|
  # Database VM
  config.vm.define "db" do |db|
    db.vm.hostname      = "db.vm"
    db.vm.network       = "private_network", ip: "192.168.33.10"
    db.vm.synced_folder "./db",      "/vagrant/db"
    db.vm.box           = "bento/ubuntu-22.04"
    db.vm.provision     "shell", path: "scripts/db-setup.sh"
  end

  # Application VM
  config.vm.define "app" do |app|
    app.vm.hostname      = "app.vm"
    app.vm.network       = "private_network", ip: "192.168.33.11"
    app.vm.synced_folder "./app",     "/vagrant/app"
    app.vm.synced_folder "./scripts", "/vagrant/scripts"
    app.vm.box           = "bento/ubuntu-22.04"
    app.vm.provision     "shell", path: "scripts/app-setup.sh"
  end
end
  • Pros: Identical OS setups, consistent IPs, private network enables seamless communication between host and VMs, and the NAT network adapter allows access to the internet from the VM’s
  • Cons: VM boot and setup takes time

Database Bootstrapping

I chose a minimal, SQL script which creates a ’test’ which the app will connect with

CREATE DATABASE IF NOT EXISTS todoapp;
CREATE USER 'todo' @'%' IDENTIFIED BY '123';
GRANT SELECT,
    INSERT,
    UPDATE,
    DELETE ON todoapp.* TO 'todo' @'%';
USE todoapp;
CREATE TABLE IF NOT EXISTS todos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(200),
    completed BOOLEAN
);

Critical Notes:

  • The user details are hard coded here for quick tinkering purposes, other approaches can be taken for extra security when required

Go Service & Connection Pooling

The core Go entrypoint supports both :memory: for quick demos and a real MySQL endpoint via DB_ADDR:

Connection-pool tuning and a “fail-fast” ping help avoid surprises under load:

func newDB(addr string, maxOpen, maxIdle int, idleTime string) (*sql.DB, error) {
    db, err := sql.Open("mysql", addr)
    if err != nil {
        return nil, err
    }
    db.SetMaxOpenConns(maxOpen)
    db.SetMaxIdleConns(maxIdle)

    dur, err := time.ParseDuration(idleTime)
    if err != nil {
        return nil, err
    }
    db.SetConnMaxIdleTime(dur)

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    if err := db.PingContext(ctx); err != nil {
        return nil, err
    }
    return db, nil
}

func envGetString(key, fallback string) string {
    if v, ok := os.LookupEnv(key); ok {
        return v
    }
    return fallback
}
  • Pool settings prevent “too many open files” and idle-timeout kill surprises.
  • 5 s context on ping surfaces mis-configuration immediately.

Provisioning Scripts

Two shell scripts (one for the app and one for the database) install the toolchain, boot MariaDB, applies the schema, then builds and launch the app:

#!/usr/bin/env bash
set -e

# Go toolchain
sudo add-apt-repository ppa:longsleep/golang-backports
apt-get update
apt-get install -y golang-go make

# MariaDB setup (settings the config to allow external connections)
apt-get install -y mariadb-server
cat <<EOF > /etc/mysql/mariadb.conf.d/99-vagrant.cnf
[mysqld]
bind-address = 0.0.0.0
EOF
service mysql restart

# DB bootstrap
mysql -u root < /vagrant/db/db_init.sql

# App build & run
cd /vagrant/app
mkdir -p bin logs
make build

export DB_ADDR="todo:123@tcp(192.168.33.10:3306)/todoapp"
nohup ./bin/todoapp > logs/app.log 2>&1 &
  • Pros: one file, idempotent, “works on first vagrant up
  • Cons: duplicate apt-get update, hard-coded IP/creds, brittle error handling

Some Conclusions

  1. VM vs. Container
    • VMs give full-OS fidelity but slow you down, containers could be a better option here
  2. Shell Scripts vs. Provisioning Frameworks
    • Bash is simple but brittle, provisioning frameworks such as Terraform would prove useful here
  3. Schema Rigor & Migrations
    • Minimal dev schema works, but adding constraints and a migrations tool earlier can catch bugs sooner—yet it adds upfront complexity.
  4. Secrets Management
    • Plain-text .envrc is fine for a lab, but a secrets manager is better

Back to all posts