"The best lab is the one you can break and fix a hundred times without cost or consequence."
If you’ve ever wanted to spin up a full-fledged Kubernetes cluster for learning, testing, or demonstrating tooling—without touching the cloud or provisioning anything beyond your laptop—this guide is for you.
We'll be using Vagrant to automate the provisioning of virtual machines and Ansible to configure them into a fully functioning Kubernetes cluster. This setup gives you a reproducible, portable lab that mirrors real-world infrastructure—perfect for experiments, CI, or training.
Let’s dive into the setup.
Lab Architecture
We'll build a 3-node Kubernetes cluster:
- 1 master node (
k8s-master) - 2 worker nodes (
k8s-node-1,k8s-node-2)
Each VM is built on Ubuntu 22.04, with VirtualBox as the hypervisor. The master runs with 2 CPUs and 2 GB of RAM, while workers get 1 CPU and 1 GB each.
Here’s the directory structure:
kubernetes-cluster/ ├── Vagrantfile ├── config | └── containerd | └── config.toml └── k8s-setup/ ├── inventory.ini ├── k8s-prereq.yaml ├── k8s-master.yaml └── k8s-worker.yaml Step 1: Vagrantfile – Define Your Lab
Here’s the core configuration that provisions all three VMs:
# Vagrantfile for setting up the Kubernetes Lab # base box image for VMs IMAGE_NAME="bento/ubuntu-22.04" # number of worker node in the k8s cluster WORKER_NODES=2 # worker nodes resources NODE_CPUS=1 # cpu core count assigned to VM NODE_MEMORY=1024 # memory (in GB) # master node resources MASTER_CPUS=2 MASTER_MEMORY=2048 Vagrant.configure("2") do |config| # manages the /etc/hosts file on guest machines in multi-machine environments config.hostmanager.enabled = true config.hostmanager.manage_host = true # common configuration for the hypervisor config.vm.provider "virtualbox" do |vb| # fix for slow network speed issue vb.customize ["modifyvm", :id, "--natdnshostresolver1", "on"] vb.customize ["modifyvm", :id, "--natdnsproxy1", "on"] end ### Worker Node Configuration ### (1..WORKER_NODES).each do |i| config.vm.define "k8s-node-#{i}" do |node| node.vm.box = IMAGE_NAME node.vm.network "private_network", ip: "10.10.10.#{i + 100}" node.vm.hostname = "k8s-node-#{i}" node.vm.provision "shell", inline: "sudo timedatectl set-timezone Asia/Kolkata" node.vm.provider "virtualbox" do |vb| vb.name = "k8s-node-#{i}" vb.memory = NODE_MEMORY vb.cpus = NODE_CPUS end end end ### Master Node Configuraton ### config.vm.define "k8s-master" do |master| master.vm.box = IMAGE_NAME master.vm.network "private_network", ip: "10.10.10.100" master.vm.hostname = "k8s-master" master.vm.provider "virtualbox" do |vb| vb.name = "k8s-master" vb.memory = MASTER_MEMORY vb.cpus = MASTER_CPUS end # Provisioning Kubernetes Cluster: master.vm.provision "shell", inline: <<-SHELL sudo timedatectl set-timezone Asia/Kolkata echo -e "$(date '+%F %T') INFO: Installing Ansible on master node" # install ansible echo | sudo apt-add-repository ppa:ansible/ansible sudo apt update sudo apt install ansible -y sleep 15 echo -e "$(date '+%F %T') INFO: Running Ansible Playbook for setting up and configuring k8s pre-requisites" # configure master node in k8s cluster ansible-playbook -i /vagrant/k8s-setup/inventory.ini /vagrant/k8s-setup/k8s-prereq.yaml EXIT_CODE=$? if [ $EXIT_CODE -eq 0 ] then sleep 30 echo -e "$(date '+%F %T') INFO: Running Ansible Playbook for setting up and configuring k8s master node" ansible-playbook -i /vagrant/k8s-setup/inventory.ini /vagrant/k8s-setup/k8s-master.yaml EXIT_CODE=$? if [ $EXIT_CODE -eq 0 ] then sleep 30 echo -e "$(date '+%F %T') INFO: Running Ansible Playbook for setting up and configuring k8s worker node" # configure worker node in k8s cluster ansible-playbook -i /vagrant/k8s-setup/inventory.ini /vagrant/k8s-setup/k8s-worker.yaml EXIT_CODE=$? if [ $EXIT_CODE -eq 0 ] then sleep 30 echo -e "$(date '+%F %T') INFO: Setting up labels on the k8s nodes" # add lables to k8s nodes kubectl --kubeconfig /home/vagrant/.kube/config label node k8s-master node-role.kubernetes.io/master=master kubectl --kubeconfig /home/vagrant/.kube/config label node k8s-node-1 node-role.kubernetes.io/worker=worker kubectl --kubeconfig /home/vagrant/.kube/config label node k8s-node-2 node-role.kubernetes.io/worker=worker else echo -e "$(date '+%F %T') ERROR: Error occured while executing k8s node ansible-playbook" fi echo -e "$(date '+%F %T') INFO: Installing helm on master node" # Install helm curl --silent https://baltocdn.com/helm/signing.asc | gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null sudo apt-get install apt-transport-https --yes echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/helm.gpg] https://baltocdn.com/helm/stable/debian/ all main" | sudo tee /etc/apt/sources.list.d/helm-stable-debian.list sudo apt-get update sudo apt-get install helm else echo -e "$(date '+%F %T') ERROR: Error occured while executing k8s master ansible-playbook" fi else echo -e "$(date '+%F %T') ERROR: Error occured while executing k8s setup ansible-playbook" fi SHELL end end Notable features:
- Assigns static private IPs (10.10.10.x range)
- Installs Ansible on the master node
- Calls Ansible playbooks for provisioning the full cluster stack
- Automatically applies node labels and installs Helm
Step 2: Ansible Playbooks – Configure the Cluster
1. k8s-prereq.yaml: Prepare All Nodes
This playbook prepares all nodes (master + workers) by:
- Enabling required kernel modules and sysctl configs
- Installing containerd and Docker dependencies
- Setting up the Kubernetes repo and installing kubelet/kubeadm/kubectl
- Configuring each node’s IP for kubelet
- Adding
vagrantuser to thedockergroup
- name: "Setup pre-requisite for k8s cluster" hosts: cluster become: true become_method: sudo gather_facts: true vars: K8S_VERSION: "1.29" tasks: - name: "Setting up Configuration for IP Bridge" shell: | cat << EOF | sudo tee /etc/modules-load.d/k8s.conf overlay br_netfilter EOF sudo modprobe overlay sudo modprobe br_netfilter cat << EOF | sudo tee /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 sudo sysctl --system - name: "Install pre-requisite packages" apt: name: "{{ PACKAGES }}" state: present update_cache: yes vars: PACKAGES: - apt-transport-https - ca-certificates - curl - gnupg-agent - software-properties-common - name: "Add an apt signing key for container package" apt_key: url: https://download.docker.com/linux/ubuntu/gpg state: present - name: "Add apt repository for stable version of container package" apt_repository: repo: deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_lsb.codename }} stable state: present - name: "Install containerd service and dependencies" apt: name: "{{ PACKAGES }}" state: present update_cache: yes vars: PACKAGES: - docker-ce - docker-ce-cli - containerd.io - name: "containerd configuration setup" copy: src: "{{ item.src }}" dest: "{{ item.dest }}" with_items: - { src: /vagrant/config/containerd/config.toml, dest: /etc/containerd/config.toml } - name: "Reload systemd daemon" command: systemctl daemon-reload - name: "Enable and Start containerd service" service: name: containerd state: restarted enabled: yes - name: "Remove swapfile from '/etc/fstab'" mount: name: "{{ item }}" fstype: swap state: absent with_items: - swap - none - name: "Disable Swap" command: sudo swapoff -a when: ansible_swaptotal_mb > 0 - name: "Ensure apt keyrings directory exists" file: path: /etc/apt/keyrings state: directory - name: "Delete kubernetes keyrings if exists" file: path: /etc/apt/keyrings/kubernetes-apt-keyring.gpg state: absent - name: "Add kubernetes apt repository key" shell: > curl -fsSL https://pkgs.k8s.io/core:/stable:/v{{ K8S_VERSION }}/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg - name: "Add kubernetes repository to sources list" apt_repository: repo: deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v{{ K8S_VERSION }}/deb/ / state: present filename: kubernetes update_cache: yes - name: "Install kubernetes binaries" apt: name: - kubelet={{ K8S_VERSION }}.* - kubeadm={{ K8S_VERSION }}.* - kubectl={{ K8S_VERSION }}.* state: present update_cache: yes - name: "Mark hold on k8s binaries" shell: cmd: sudo apt-mark hold kubelet kubeadm kubectl - name: "Configure Node IP" lineinfile: path: /etc/default/kubelet line: KUBELET_EXTRA_ARGS=--node-ip={{ ansible_eth1.ipv4.address }} create: yes - name: "Restart kubelet service" service: name: kubelet state: restarted daemon_reload: yes enabled: yes - name: "Add vagrant user to docker group" user: name: vagrant groups: docker append: yes 2. k8s-master.yaml: Initialize the Master
This playbook:
- Runs
kubeadm init - Configures kubeconfig for the
vagrantuser - Installs Calico CNI
- Removes the default taint from the control-plane
- Outputs a
kubeadm joincommand to be used by the workers
# k8s-master.yaml - name: "Setup and Configure Kubernetes Master Node" hosts: master become: true become_method: sudo gather_facts: true vars: POD_NETWORK_CIDR: "10.10.0.0/16" K8S_MASTER_NODE_NAME: "k8s-master" K8S_MASTER_NODE_IP: "10.10.10.100" tasks: - name: "Initialize kubernetes cluster using kubeadm" command: kubeadm init --cri-socket /var/run/containerd/containerd.sock --apiserver-advertise-address="{{ K8S_MASTER_NODE_IP }}" --apiserver-cert-extra-sans="{{ K8S_MASTER_NODE_IP }}" --node-name {{ K8S_MASTER_NODE_NAME }} --pod-network-cidr={{ POD_NETWORK_CIDR }} - name: "Setup kubeconfig for 'vagrant' user" command: "{{ item }}" with_items: - mkdir -p /home/vagrant/.kube - cp -i /etc/kubernetes/admin.conf /home/vagrant/.kube/config - chown vagrant:vagrant /home/vagrant/.kube/config # Setup the container networking provider and the network policy engine - name: "Install calico pod network" become: false command: kubectl --kubeconfig /home/vagrant/.kube/config apply -f https://docs.projectcalico.org/manifests/calico.yaml - name: "Remove Taint from Master Node" become: false shell: cmd: kubectl --kubeconfig /home/vagrant/.kube/config taint nodes {{ K8S_MASTER_NODE_NAME }} node-role.kubernetes.io/control-plane:NoSchedule- - name: "Generate join command for nodes to join the k8s cluster" command: kubeadm token create --print-join-command register: join_command - name: "copy join command to local file" local_action: copy content="{{ join_command.stdout_lines[0] }}" dest="./join-command" The generated join command is written to a file locally. This is later copied to the worker nodes.
3. k8s-worker.yaml: Join Worker Nodes
This playbook:
- Copies the join command to each worker
- Appends the containerd socket to the join command
- Joins the node to the cluster using
kubeadm - Restarts the kubelet service after joining
# k8s-worker.yaml - name: "Setup and Configure Kubernetes Worker Node" hosts: worker become: true become_method: sudo gather_facts: true tasks: - name: "Copy the join command to server location" copy: src: join-command dest: /tmp/k8s-cluster-join-command.sh mode: 0755 - name: "Add cri-socket arg to kubeadm join command" command: sed -i 's|$| --cri-socket /var/run/containerd/containerd.sock|' /tmp/k8s-cluster-join-command.sh - name: "Join the Node to the k8s cluster" command: sh /tmp/k8s-cluster-join-command.sh notify: - Restart kubelet service handlers: - name: "Restart kubelet service" service: name: kubelet state: restarted Inventory File
Your inventory.ini should categorize the nodes like so:
[all] k8s-master ansible_ssh_host=10.10.10.100 k8s-node-1 ansible_ssh_host=10.10.10.101 k8s-node-2 ansible_ssh_host=10.10.10.102 [master] k8s-master ansible_ssh_host=10.10.10.100 [worker] k8s-node-1 ansible_ssh_host=10.10.10.101 k8s-node-2 ansible_ssh_host=10.10.10.102 [cluster] k8s-master ansible_ssh_host=10.10.10.100 k8s-node-1 ansible_ssh_host=10.10.10.101 k8s-node-2 ansible_ssh_host=10.10.10.102 [all:vars] ansible_ssh_user=vagrant ansible_ssh_pass=vagrant ansible_host_key_checking=false ansible_python_interpreter=/usr/bin/python3 ansible_ssh_common_args='-o StrictHostKeyChecking=no' Bootstrapping the Lab
Once everything is in place:
- Clone your repo or setup directory.
-
Run:
vagrant up Sit back and let Vagrant and Ansible do their magic. It’ll take a few minutes.
You’ll end up with a fully initialized Kubernetes cluster on your local machine. To access it:
vagrant ssh k8s-master kubectl get nodes Bonus: Helm Installed by Default
The setup script also installs Helm on the master node, making it easy to test out Helm charts and manage add-ons right away.
Tear Down When Done
To clean up your lab environment:
vagrant destroy -f Final Thoughts
"If you have a reasonably powerful laptop and prefer not to burn a hole in your wallet with cloud bills, this local Kubernetes lab setup is your perfect playground to learn, experiment, and break things safely."
This lab is ideal for:
- Practicing Kubernetes operations
- Developing Helm charts
- Running quick PoCs without needing cloud infra
It’s modular, portable, and fast to spin up—just like your favorite dev tools should be.
{ "author" : "Kartik Dudeja", "email" : "kartikdudeja21@gmail.com", "linkedin" : "https://linkedin.com/in/kartik-dudeja", "github" : "https://github.com/Kartikdudeja" }

Top comments (0)