Phase 01 · Infrastructure & Cluster
Vagrantfile — deep dive
vagrant/Vagrantfile  ·  81 lines  ·  Ruby DSL
A Vagrantfile is a Ruby script that tells Vagrant exactly what VMs to create, how to network them, and what to run inside them. Every parameter below has a specific reason — nothing is default filler. This page walks through the file top to bottom and explains the "why" behind each decision.
01 Box Variables
# 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.

bento/ubuntu-25.04 Bento is HashiCorp's official box project. Their images are minimal, well-tested, and updated regularly. The ubuntu-25.04 suffix pins us to Plucky Puffin — the same OS across every developer's machine.
202510.26.0 Without a version pin, 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.
02 Node Definitions
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.

  • name — becomes the VM's Linux hostname AND the Kubernetes node name. These must match what we pass to kubeadm init --node-name.
  • 192.168.56.x — VirtualBox's default host-only subnet. All three VMs share it and your host machine can reach them directly. The .10 / .11 / .12 assignment is logical but arbitrary.
  • cpus: 3 — Kubernetes requires at least 2 CPUs to run kubeadm init. We use 3 to give ArgoCD and the Online Boutique microservices enough headroom alongside the control plane components.
  • memory: 3072 — 3 GB RAM per node (9 GB total across the cluster). Increased from 2 GB after Phase 03 introduced 11 microservices + ArgoCD, which pushed memory pressure too high on 2 GB nodes.
03 Bootstrap Script — Shell Safety & Structure
# 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.

set -e Exit immediately if any command returns a non-zero status. Without this, a failed command is silently skipped and later commands run against a broken system.
set -u Treat any reference to an undefined variable as an error. Catches typos in variable names that would otherwise silently expand to an empty string.
set -o pipefail If any command in a pipeline fails, the whole pipeline fails. Without this, cmd | grep foo returns 0 even if cmd crashed.
Why run before Ansible? This script sets kernel-level prerequisites that must exist before Ansible even SSH's in. Kernel modules and sysctl can't be applied by Ansible mid-play without a reboot otherwise.
04 Disable Swap
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.

  • swapoff -a — disables all active swap partitions and swap files immediately, in memory, right now.
  • sed -i '/\bswap\b/d' /etc/fstab — removes any line containing the word swap from /etc/fstab. Without this, swap comes back the next time the VM reboots since fstab is read at boot.
Why does K8s care about swap? Kubernetes makes scheduling decisions based on memory requests and limits. If the OS is silently swapping a container's pages to disk, those guarantees break — a container that "has" 512 MB might actually be mostly on disk, causing unpredictable latency spikes.
05 Kernel Modules
# 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.

overlay The OverlayFS filesystem driver. Containerd uses it to layer container images on top of each other (base image → app layer → writable container layer). Without it, containerd cannot create or run containers.
br_netfilter Makes the Linux kernel pass bridged (pod-to-pod) traffic through iptables/netfilter. Without it, Calico cannot apply its network policies to inter-pod traffic — pods can't talk to each other or to services.
Two-step load: Writing to /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.
06 Sysctl Network Settings
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.

  • bridge-nf-call-iptables = 1 — tells the kernel to run iptables rules on IPv4 traffic crossing a Linux bridge. Required so Calico's iptables rules apply to pod-to-pod traffic, not just host traffic.
  • bridge-nf-call-ip6tables = 1 — same as above but for IPv6 bridged traffic. Required even if you don't use IPv6, because some CNI components check for it at startup.
  • ip_forward = 1 — enables IP packet forwarding between network interfaces. Without this, a pod on eth1 cannot reach the internet through eth0. Kubernetes would be completely isolated.
07 Vagrant.configure Block
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
  • Vagrant.configure("2") — the "2" is the Vagrant configuration API version. Always leave it as "2"; version 1 is legacy and deprecated.
  • config.vm.box / box_version — global defaults applied to every VM defined in this file. Each individual VM inherits these and can override them if needed.
  • synced_folder disabled: true — Vagrant's default behaviour is to mount your project folder into every VM at /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.
08 Node Loop & vm.define
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.

  • config.vm.define node[:name] — gives each VM a unique name within Vagrant. You can target individual VMs with vagrant up k8s-master or vagrant ssh k8s-worker-1.
  • machine.vm.hostname — sets the Linux hostname inside the VM (what appears in the shell prompt). Kubernetes reads this as the node name, so it must match what Ansible passes to kubeadm init --node-name.
  • network "private_network" — adds a second NIC to the VM on the 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.
09 VirtualBox Provider Settings
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
  • vb.name — the label in the VirtualBox GUI (the graphical manager application). Without it, VMs appear as random UUIDs in the list.
  • vb.cpus / vb.memory — read from the NODES hash. VirtualBox uses these to size the actual VM hypervisor allocation.
  • vb.gui = false — runs the VM headless. Without this, VirtualBox opens a GUI window for each VM when you run vagrant up — three windows on top of your workspace.
  • vb.customize UART lines — disables the virtual serial port and redirects its output to 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.
10 Shell Provisioner
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.

  • When does it run? — automatically on the first vagrant up. If you destroy and recreate the VM it runs again. You can also force it manually with vagrant provision.
  • inline: vs file: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.
  • Why not just use Ansible for this? — Ansible runs after the VM is up and SSH is ready. The bootstrap sets kernel-level state (swap, modules, sysctl) that needs to exist before anything else runs. Shell provisioners are the right tool for that layer.
11 Network Topology
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.

Why reserve .200–.250 for MetalLB? MetalLB (Phase 02) needs a pool of real IP addresses to assign to LoadBalancer-type Services. They must be on the same subnet as the nodes so that ARP announcements reach your host machine's routing table. The upper end of 192.168.56.x is intentionally left free for this.