# The base VM image — like a Docker image but for full virtual machines BOX_IMAGE = "bento/ubuntu-25.04" BOX_VERSION = "202510.26.0"
These are Ruby constants — uppercase names that never change at runtime. Splitting them out at the top means a version bump is a one-line edit, not a search-and-replace through the whole file.
ubuntu-25.04 suffix pins us to Plucky Puffin — the same
OS across every developer's machine.
vagrant up pulls whatever is "latest" that day — which
can silently change between runs and cause hard-to-debug inconsistencies. Pinning guarantees
a reproducible build.
NODES = [ { name: "k8s-master", ip: "192.168.56.10", cpus: 3, memory: 3072 }, { name: "k8s-worker-1", ip: "192.168.56.11", cpus: 3, memory: 3072 }, { name: "k8s-worker-2", ip: "192.168.56.12", cpus: 3, memory: 3072 }, ]
A Ruby array of hashes — one entry per VM. Instead of copy-pasting the same
config.vm.define block three times, we loop over this array later. Adding a
fourth node in the future is a single line here.
kubeadm init --node-name.
.10 / .11 / .12
assignment is logical but arbitrary.
kubeadm init. We use 3 to give ArgoCD and the Online Boutique
microservices enough headroom alongside the control plane components.
# Ruby heredoc — a multi-line string stored in a global variable ($) # <<~SHELL strips leading whitespace so indentation stays clean $bootstrap = <<~SHELL set -euo pipefail ... SHELL
$bootstrap is a Ruby global variable holding a heredoc — a multi-line string
that is the shell script Vagrant runs on each VM. The ~ in <<~SHELL
strips leading indentation so the script looks clean in the file.
cmd | grep foo returns 0 even if cmd crashed.
swapoff -a
sed -i '/\bswap\b/d' /etc/fstab
Kubernetes requires swap to be completely off. This is a hard enforced check —
the kubelet reads /proc/swaps at startup and refuses to run if any swap is active.
swap from /etc/fstab. Without this, swap comes back the next time
the VM reboots since fstab is read at boot.
# Write to modules-load.d so these load automatically on every reboot cat <<EOF > /etc/modules-load.d/k8s.conf overlay br_netfilter EOF # Load them right now without needing a reboot modprobe overlay modprobe br_netfilter
These two kernel modules are prerequisites for container networking and storage. Without them, both containerd and Calico fail silently or refuse to start.
/etc/modules-load.d/k8s.conf ensures
the modules persist across reboots. The modprobe commands load them immediately
for this session — otherwise we'd need to reboot first.
cat <<EOF > /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sysctl --system > /dev/null # apply all rules in /etc/sysctl.d/ immediately
Sysctl parameters are kernel tuning knobs. These three are required by every Kubernetes CNI plugin — including Calico. Without them, the cluster comes up but pod networking silently breaks in subtle ways.
eth1 cannot reach the internet through eth0.
Kubernetes would be completely isolated.
Vagrant.configure("2") do |config| config.vm.box = BOX_IMAGE config.vm.box_version = BOX_VERSION # Ansible SSH's in directly — no shared folder needed config.vm.synced_folder ".", "/vagrant", disabled: true end
"2" is the Vagrant configuration
API version. Always leave it as "2"; version 1 is legacy and deprecated.
/vagrant. We disable this because Ansible
SSH's into the VMs directly — it doesn't use a shared folder. Disabling it also removes
a VirtualBox Guest Additions dependency that can cause boot errors on new box versions.
NODES.each do |node| config.vm.define node[:name] do |machine| machine.vm.hostname = node[:name] machine.vm.network "private_network", ip: node[:ip] end end
Ruby's .each iterates over the NODES array. For each entry, vm.define
registers a named VM with Vagrant — three iterations, three VMs.
vagrant up k8s-master or
vagrant ssh k8s-worker-1.
kubeadm init --node-name.
192.168.56.x host-only network. Every Vagrant VM already has a NAT interface
(eth0) for internet access — this adds eth1 which all nodes share with each other and with
your host. Kubernetes binds to this interface, not the NAT one.
machine.vm.provider "virtualbox" do |vb| vb.name = node[:name] # label shown in VirtualBox GUI vb.cpus = node[:cpus] vb.memory = node[:memory] vb.gui = false # headless — no VM window pops up # Suppress noisy serial port output during boot vb.customize ["modifyvm", :id, "--uart1", "0x3F8", "4"] vb.customize ["modifyvm", :id, "--uartmode1", "file", File::NULL] end
vagrant up — three windows on top of your
workspace.
File::NULL (which is /dev/null on Mac/Linux and
NUL on Windows — Ruby handles the difference). Without this, Ubuntu 25.04
floods your terminal with UART/serial console messages during boot.
machine.vm.provision "shell", inline: $bootstrap
This hands the $bootstrap script to Vagrant's built-in shell provisioner,
which SSH's into the VM and runs it as root.
vagrant up.
If you destroy and recreate the VM it runs again. You can also force it manually with
vagrant provision.
inline: embeds the script directly in
the Vagrantfile (no extra file needed). file: would reference a separate
.sh file on disk. Inline is fine here since the script is short.
Your Laptop │ ├── eth0 / Wi-Fi ──→ Internet (Vagrant NAT, one per VM) │ └── vboxnet0: 192.168.56.0/24 ← host-only network (all VMs share this) │ ├── k8s-master 192.168.56.10 (API server, etcd, control plane) ├── k8s-worker-1 192.168.56.11 (runs workload pods) └── k8s-worker-2 192.168.56.12 (runs workload pods) MetalLB IP pool 192.168.56.200 – 192.168.56.250 ← reserved for Phase 02 (LoadBalancer Services will get IPs from this range)
Each VM has two network interfaces: a NAT interface (eth0) managed by VirtualBox for
outbound internet traffic, and a host-only interface (eth1) on 192.168.56.0/24
for all cluster communication. Kubernetes binds to eth1.
192.168.56.x is intentionally left free for this.