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