Build a Kubernetes LAN Lab

Build a three-node Kubernetes lab with bridged LAN access.

Introduction

30 Second Summary

A home lab can look healthy while its machines still cannot talk to each other. The real proof comes when a website opened on one device is served by a different machine.

In this project, you will use Vagrant to launch three Ubuntu virtual machines from one Vagrantfile. You will bootstrap a Kubernetes cluster that serves NGINX to another device on your trusted LAN.

What You'll Build

From another device on your LAN, you'll load the NGINX welcome page through worker1 while its Pod runs on worker2.

By the end of this project, you'll have:

  • A one-command lab launch that brings all three Ubuntu nodes online with visible bridged LAN addresses.
  • A visible network recovery as CoreDNS moves from Pending to Running after Flannel connects the cluster.
  • A cross-node website demo where opening worker1 on port 30080 displays the NGINX page from a Pod on worker2.
  • Secret Mission: Drain worker2 for maintenance while two replicas keep the site reachable through worker1.

Are there any prerequisites?

You'll need a Windows computer with at least 16 GB of RAM. Your host should already have VirtualBox plus Vagrant installed.

You'll also need hardware virtualization enabled. Connect another test device to the same trusted LAN as your Windows host.

Before We Start

Before any hands-on work, commit to building a disposable three-node Kubernetes LAN lab. Define why proving external LAN access through worker1 to a workload on worker2 matters to you.

Validate the Windows Host

Your Kubernetes lab depends on Vagrant launching three VirtualBox VMs. Bridged networking places those guests directly on your LAN.

A broken virtualization path would stop the lab before Kubernetes starts. An untrusted network would expose the bridged guests more broadly than this project needs.

In this step, you will verify the host through Windows PowerShell. You will also prepare a trusted LAN test path before creating any machine.

In this step, get ready to:
  • Verify the local virtualization toolchain.
  • Confirm hardware virtualization is available.
  • Prepare the empty lab folder on a trusted LAN.
Verify the virtualization path

VirtualBox uses hardware virtualization to run Linux guests on your Windows host. The Manager launch proves the application starts successfully.

  • Press the Windows key to open search.
  • Type Oracle VirtualBox Manager into the search field.
  • Press Enter to launch the application.

You should see Oracle VirtualBox Manager open without an installation error.

  • Press Ctrl+Shift+Esc to open Task Manager.
  • Select Performance from the left pane.
  • Select CPU.
  • Confirm the CPU details report hardware virtualization as available.

The CPU status confirms that VirtualBox can use the processor features required to run the lab VMs.

Virtualization path unavailable?

Changing firmware settings can feel risky. Use the instructions for your computer manufacturer without changing unrelated settings.

Help me diagnose why VirtualBox or hardware virtualization is unavailable on my Windows host.

Confirm the trusted LAN

A bridged guest appears on the network as another machine. Your external test device needs to share that network before it can test the worker address.

Use a Trusted Network

Bridged VMs are visible to other devices on the selected LAN. Public or untrusted networks create unnecessary exposure.

  • Use only a LAN you trust for this project.
  • Confirm your Windows host is connected to the trusted LAN you plan to use.
  • Connect your external test device or VM to the same trusted LAN.
  • Compare the active network path on both endpoints.
  • Move the external test device to the host's trusted LAN if the network paths differ.

You should now have both endpoints connected through the same trusted network path.

Create the lab folder and verify Vagrant

Vagrant uses the current folder as the boundary for a project. Starting with an empty folder keeps the upcoming VM configuration together.

  • Press Windows+E to open File Explorer.
  • Select Desktop in the left navigation pane.
  • Select New in the toolbar.
  • Select Folder.
  • Type k8s-vagrant-lab as the folder name.
  • Press Enter.
  • Double-click the k8s-vagrant-lab folder.
  • Confirm the folder contains no files.

You should see an empty k8s-vagrant-lab folder on your Desktop.

  • Select the File Explorer address bar.
  • Type powershell.exe.
  • Press Enter to open Windows PowerShell in the current folder.
  • Confirm the PowerShell prompt path ends with k8s-vagrant-lab.

Before you run the final check, consider whether Vagrant will show its command list or try to create a VM.

  • Verify Vagrant from the project folder by running this command:
vagrant

What Does This Check Prove?

The command asks Vagrant to print its command list. Seeing that list proves PowerShell can resolve Vagrant from the project folder.

You'll see Vagrant's command list in PowerShell. The prompt returns inside k8s-vagrant-lab without creating a VM.

Vagrant Command List Missing?

A missing command list means Windows PowerShell cannot currently reach the Vagrant command.

  • Repair the existing Vagrant installation if PowerShell cannot find the command.
  • Return to the k8s-vagrant-lab folder through File Explorer.
  • Open Windows PowerShell from the address bar again.

Help me fix Vagrant not displaying its command list in Windows PowerShell.

That host gate is cleared. Next, you will turn the empty folder into three bridged Linux nodes.

Build Three Bridged Linux Nodes

Your Windows host now has a working Vagrant path into Oracle VirtualBox. That foundation lets this step create the three machines as one repeatable lab.

Kubernetes needs three Linux machines that can reach one another across your trusted LAN. Testing bridged networking now catches Wi-Fi isolation before cluster setup.

In this step, get ready to:
  • Define three Ubuntu virtual machines in one Vagrant environment.
  • Provision every node with the Kubernetes runtime prerequisites.
  • Prove worker1 is reachable from another device on your LAN.
Define the three virtual machines

The Vagrantfile describes all three machines in one place. Each machine receives a predictable hostname plus enough memory and CPU for its cluster role.

  • Create a plain-text file named Vagrantfile inside the k8s-vagrant-lab folder using your text editor.
  • Paste this VM definition into Vagrantfile:
NODES = {
  "control" => { memory: 3072, cpus: 2 },
  "worker1" => { memory: 2048, cpus: 2 },
  "worker2" => { memory: 2048, cpus: 2 }
}.freeze

Vagrant.configure("2") do |config|
  config.vm.box = "bento/ubuntu-24.04"
  config.vm.box_version = "202510.26.0"
  config.vm.network "public_network"
  config.vm.provision "shell", path: "provision-node.sh"

  NODES.each do |name, resources|
    config.vm.define name do |node|
      node.vm.hostname = name

      node.vm.provider "virtualbox" do |virtualbox|
        virtualbox.name = "k8s-#{name}"
        virtualbox.memory = resources[:memory]
        virtualbox.cpus = resources[:cpus]
      end
    end
  end
end

What does this file define?

  • The NODES map gives the control plane 3072 MB of memory. Each worker receives 2048 MB.
  • The bento/ubuntu-24.04 box supplies Ubuntu Server 24.04 to every machine.
  • The public_network setting adds a bridged adapter that receives an address through DHCP.
  • The shell provisioner runs provision-node.sh inside each guest.
  • The provider block assigns the visible VM names k8s-control, k8s-worker1, and k8s-worker2.
  • Save Vagrantfile.
  • Check that your editor shows the filename as Vagrantfile without a .txt extension.

Does the file have a .txt extension?

Rename the file so its complete name is Vagrantfile. Vagrant only discovers the configuration when that filename matches exactly.

Ask for help checking the filename.

Provision the Kubernetes prerequisites

The provision-node.sh script prepares each guest for Kubernetes v1.37.0. It installs containerd 2.2.1 plus CNI plugins v1.3.0.

  • Create a plain-text file named provision-node.sh inside the k8s-vagrant-lab folder using your text editor.
  • Paste this first section into provision-node.sh:
#!/usr/bin/env bash
set -euo pipefail

KUBERNETES_VERSION="v1.37.0"
KUBE_RELEASE_TEMPLATE_VERSION="v0.16.2"
CNI_PLUGINS_VERSION="v1.3.0"
CONTAINERD_PACKAGE_VERSION="2.2.1-0ubuntu1~24.04.3"
CURL_PACKAGE_VERSION="8.5.0-2ubuntu10.13"
CA_CERTIFICATES_PACKAGE_VERSION="20260601~24.04.1"

export DEBIAN_FRONTEND=noninteractive

DEFAULT_IF="$(ip route show default | awk '$1 == "default" {print $5; exit}')"
LAN_IP="$(ip -4 -o addr show scope global | awk -v default_if="$DEFAULT_IF" '$2 != default_if {split($4, address, "/"); print address[1]; exit}')"

if [[ -z "$LAN_IP" ]]; then
  echo "No bridged IPv4 address was found. Check the selected Wi-Fi adapter and DHCP access."
  exit 1
fi

case "$(dpkg --print-architecture)" in
  amd64) ARCH="amd64" ;;
  arm64) ARCH="arm64" ;;
  *)
    echo "This project supports amd64 and arm64 guests."
    exit 1
    ;;
esac

What does this section detect?

  • The version variables keep the Kubernetes binaries and system packages pinned to the project versions.
  • The DEFAULT_IF lookup identifies Vagrant's default management interface.
  • The LAN_IP lookup selects the other global IPv4 address supplied by the bridge.
  • The architecture check selects downloads that match an amd64 or arm64 guest.
  • Save provision-node.sh.
  • Confirm the current final line in the editor is esac.

Does the first section look different?

Check that the version values retain their quotes. Check that the awk expressions retain their single quotes.

Ask for help comparing the first section.

  • Place your cursor on a new line after esac.
  • Paste this package and runtime section below it:
apt-get update
apt-get install -y \
  "ca-certificates=${CA_CERTIFICATES_PACKAGE_VERSION}" \
  "curl=${CURL_PACKAGE_VERSION}" \
  "containerd=${CONTAINERD_PACKAGE_VERSION}"

swapoff -a
sed -ri '/\sswap\s/s/^#?/#/' /etc/fstab

cat > /etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
EOF

modprobe overlay
modprobe br_netfilter

cat > /etc/sysctl.d/k8s.conf <<'EOF'
net.ipv4.ip_forward = 1
EOF

sysctl --system

mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl enable containerd
systemctl restart containerd

What does this section configure?

  • The package commands install the pinned certificates package plus curl and containerd.
  • The swap commands disable swap now. They also prevent it from returning after a reboot.
  • The overlay and br_netfilter modules prepare the kernel for container networking.
  • The forwarding setting allows IPv4 packets to travel between network interfaces.
  • The containerd configuration switches its runtime to SystemdCgroup=true.
  • Save provision-node.sh.
  • Confirm the current final line in the editor is systemctl restart containerd.

Are the backslashes missing?

Keep each backslash at the end of its package line. Those characters tell the shell that the command continues on the next line.

Ask for help checking the package section.

  • Place your cursor on a new line after systemctl restart containerd.
  • Paste this Kubernetes download section below it:
mkdir -p /opt/cni/bin
curl -L "https://github.com/containernetworking/plugins/releases/download/${CNI_PLUGINS_VERSION}/cni-plugins-linux-${ARCH}-${CNI_PLUGINS_VERSION}.tgz" \
  | tar -C /opt/cni/bin -xz

for binary in kubeadm kubelet kubectl; do
  curl -L --fail --show-error \
    "https://dl.k8s.io/release/${KUBERNETES_VERSION}/bin/linux/${ARCH}/${binary}" \
    -o "/usr/local/bin/${binary}"
  chmod 0755 "/usr/local/bin/${binary}"
done

curl -sSL \
  "https://raw.githubusercontent.com/kubernetes/release/${KUBE_RELEASE_TEMPLATE_VERSION}/cmd/krel/templates/latest/kubelet/kubelet.service" \
  | sed 's:/usr/bin:/usr/local/bin:g' \
  > /usr/lib/systemd/system/kubelet.service

mkdir -p /usr/lib/systemd/system/kubelet.service.d
curl -sSL \
  "https://raw.githubusercontent.com/kubernetes/release/${KUBE_RELEASE_TEMPLATE_VERSION}/cmd/krel/templates/latest/kubeadm/10-kubeadm.conf" \
  | sed 's:/usr/bin:/usr/local/bin:g' \
  > /usr/lib/systemd/system/kubelet.service.d/10-kubeadm.conf

What does this section install?

  • The first download extracts the CNI executables into /opt/cni/bin.
  • The loop downloads kubeadm, kubelet, and kubectl for the detected architecture.
  • The final downloads create the systemd service definition used to run kubelet.
  • Save provision-node.sh.
  • Confirm the current final line ends with 10-kubeadm.conf.

Do the download lines look broken?

Keep every URL inside its double quotes. Keep each continuation backslash at the end of its line.

Ask for help checking the downloads.

  • Place your cursor on a new line after the 10-kubeadm.conf line.
  • Paste this node-address and verification section below it:
printf 'KUBELET_EXTRA_ARGS=--node-ip=%s\n' "$LAN_IP" > /etc/default/kubelet
printf '%s\n' "$LAN_IP" > /var/tmp/k8s-lan-ip

systemctl daemon-reload
systemctl enable --now kubelet

printf '\n%s LAN_IP=%s\n' "$(hostname)" "$LAN_IP"
printf 'Kubernetes=%s containerd=%s CNI-plugins=%s\n' \
  "$KUBERNETES_VERSION" "$CONTAINERD_PACKAGE_VERSION" "$CNI_PLUGINS_VERSION"

What does the final section preserve?

  • The KUBELET_EXTRA_ARGS value makes kubelet register the bridged address as its node IP.
  • The /var/tmp/k8s-lan-ip file preserves that address for control-plane setup.
  • The systemd commands enable kubelet now. They also enable it after a reboot.
  • The final output prints each hostname plus its LAN_IP and installed versions.
  • Save provision-node.sh.
  • Confirm the final line in the editor contains $CNI_PLUGINS_VERSION.

Is the final output section incomplete?

Check that both printf commands include their complete quoted format strings. Check that the final line contains all three version variables.

Ask for help checking the final section.

✔️ Awesome, I've got everything!

Good progress. Your Vagrantfile and provision-node.sh files now describe the complete three-node lab.

ⓧ I'd like to double check the full code

Compare both saved files with these complete versions.

NODES = {
  "control" => { memory: 3072, cpus: 2 },
  "worker1" => { memory: 2048, cpus: 2 },
  "worker2" => { memory: 2048, cpus: 2 }
}.freeze

Vagrant.configure("2") do |config|
  config.vm.box = "bento/ubuntu-24.04"
  config.vm.box_version = "202510.26.0"
  config.vm.network "public_network"
  config.vm.provision "shell", path: "provision-node.sh"

  NODES.each do |name, resources|
    config.vm.define name do |node|
      node.vm.hostname = name

      node.vm.provider "virtualbox" do |virtualbox|
        virtualbox.name = "k8s-#{name}"
        virtualbox.memory = resources[:memory]
        virtualbox.cpus = resources[:cpus]
      end
    end
  end
end
#!/usr/bin/env bash
set -euo pipefail

KUBERNETES_VERSION="v1.37.0"
KUBE_RELEASE_TEMPLATE_VERSION="v0.16.2"
CNI_PLUGINS_VERSION="v1.3.0"
CONTAINERD_PACKAGE_VERSION="2.2.1-0ubuntu1~24.04.3"
CURL_PACKAGE_VERSION="8.5.0-2ubuntu10.13"
CA_CERTIFICATES_PACKAGE_VERSION="20260601~24.04.1"

export DEBIAN_FRONTEND=noninteractive

DEFAULT_IF="$(ip route show default | awk '$1 == "default" {print $5; exit}')"
LAN_IP="$(ip -4 -o addr show scope global | awk -v default_if="$DEFAULT_IF" '$2 != default_if {split($4, address, "/"); print address[1]; exit}')"

if [[ -z "$LAN_IP" ]]; then
  echo "No bridged IPv4 address was found. Check the selected Wi-Fi adapter and DHCP access."
  exit 1
fi

case "$(dpkg --print-architecture)" in
  amd64) ARCH="amd64" ;;
  arm64) ARCH="arm64" ;;
  *)
    echo "This project supports amd64 and arm64 guests."
    exit 1
    ;;
esac

apt-get update
apt-get install -y \
  "ca-certificates=${CA_CERTIFICATES_PACKAGE_VERSION}" \
  "curl=${CURL_PACKAGE_VERSION}" \
  "containerd=${CONTAINERD_PACKAGE_VERSION}"

swapoff -a
sed -ri '/\sswap\s/s/^#?/#/' /etc/fstab

cat > /etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
EOF

modprobe overlay
modprobe br_netfilter

cat > /etc/sysctl.d/k8s.conf <<'EOF'
net.ipv4.ip_forward = 1
EOF

sysctl --system

mkdir -p /etc/containerd
containerd config default > /etc/containerd/config.toml
sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
systemctl enable containerd
systemctl restart containerd

mkdir -p /opt/cni/bin
curl -L "https://github.com/containernetworking/plugins/releases/download/${CNI_PLUGINS_VERSION}/cni-plugins-linux-${ARCH}-${CNI_PLUGINS_VERSION}.tgz" \
  | tar -C /opt/cni/bin -xz

for binary in kubeadm kubelet kubectl; do
  curl -L --fail --show-error \
    "https://dl.k8s.io/release/${KUBERNETES_VERSION}/bin/linux/${ARCH}/${binary}" \
    -o "/usr/local/bin/${binary}"
  chmod 0755 "/usr/local/bin/${binary}"
done

curl -sSL \
  "https://raw.githubusercontent.com/kubernetes/release/${KUBE_RELEASE_TEMPLATE_VERSION}/cmd/krel/templates/latest/kubelet/kubelet.service" \
  | sed 's:/usr/bin:/usr/local/bin:g' \
  > /usr/lib/systemd/system/kubelet.service

mkdir -p /usr/lib/systemd/system/kubelet.service.d
curl -sSL \
  "https://raw.githubusercontent.com/kubernetes/release/${KUBE_RELEASE_TEMPLATE_VERSION}/cmd/krel/templates/latest/kubeadm/10-kubeadm.conf" \
  | sed 's:/usr/bin:/usr/local/bin:g' \
  > /usr/lib/systemd/system/kubelet.service.d/10-kubeadm.conf

printf 'KUBELET_EXTRA_ARGS=--node-ip=%s\n' "$LAN_IP" > /etc/default/kubelet
printf '%s\n' "$LAN_IP" > /var/tmp/k8s-lan-ip

systemctl daemon-reload
systemctl enable --now kubelet

printf '\n%s LAN_IP=%s\n' "$(hostname)" "$LAN_IP"
printf 'Kubernetes=%s containerd=%s CNI-plugins=%s\n' \
  "$KUBERNETES_VERSION" "$CONTAINERD_PACKAGE_VERSION" "$CNI_PLUGINS_VERSION"
Launch the lab and test the bridge

Use a trusted LAN

A bridged VM appears on the LAN as another machine. Use the trusted network you confirmed earlier because other devices on that LAN can reach the guests.

The first launch can take several minutes while Vagrant downloads the Ubuntu box and provisions three machines. Long stretches of package output are expected during this run.

  • Start all three machines by running this command:
  • Select the Windows Wi-Fi adapter whenever Vagrant asks which interface to bridge.
vagrant up

What does this command create?

  • Vagrant starts the control, worker1, and worker2 machine definitions.
  • Each guest keeps Vagrant's NAT path for management access.
  • Each guest also receives a bridged adapter for LAN traffic.
  • The shell provisioner prepares the runtime and prints the bridged address when each node finishes.

When provisioning finishes, you should see one LAN_IP= line for each hostname. You should also see the pinned Kubernetes, containerd, and CNI plugin versions.

That is the heavy lift done. Your three VMs are running with their Kubernetes prerequisites installed.

  • Record control's printed address here: control LAN IPv4 address.
  • Record worker1's printed address here: worker1 LAN IPv4 address.
  • Record worker2's printed address here: worker2 LAN IPv4 address.
  • Confirm that each node prints Kubernetes v1.37.0 plus containerd 2.2.1-0ubuntu1~24.04.3 and CNI plugins v1.3.0.

Did vagrant up stop early?

Check the first failure shown in PowerShell. A missing bridged address usually means the wrong host adapter was selected or DHCP did not provide an address.

Ask for help with the failed launch.

Share the first error with the NextWork community if the failure depends on your Windows networking or virtualization setup.

Before you test the bridge, do you expect worker1 to answer a request sent from a separate device on the same LAN?

  • Switch back to the external test device or VM from earlier.
  • Test worker1's bridged address by running this command:
ping [[WORKER1_LAN_IP="worker1 LAN IPv4 address"]]

What does this test prove?

Replies prove that traffic can cross the physical LAN to worker1's bridged adapter. This confirms the underlay path before Kubernetes adds its own Pod network.

You should see replies from worker1's recorded address without complete packet loss. A continuing Linux ping can be stopped after several replies.

  • Stop a continuing ping by pressing Ctrl+C.

No replies from worker1?

  • Stop before starting Kubernetes setup.
  • Confirm that the external test device remains on the same trusted LAN as the Windows host.
  • Move both hosts to a LAN without wireless client isolation if the current network blocks peer traffic.
  • Use wired Ethernet if Wi-Fi bridging cannot pass traffic between the guest and the external device.

Ask for help diagnosing the bridge.

Share your host adapter and LAN setup with the NextWork community if the problem is specific to your access point.

Your three-node Linux lab is live on the LAN. Next up, you will initialize the control plane and observe the networking gap that a CNI must solve.

Bootstrap the Control Plane

The three bridged Ubuntu nodes can reach your LAN. Your next job is to turn control into the Kubernetes v1.37.0 control plane.

Pod traffic still has no Container Network Interface (CNI). Initializing now lets you observe that missing layer through the node status and CoreDNS status before you install networking.

In this step, get ready to:
  • Bootstrap control through Vagrant.
  • Configure kubectl for the regular vagrant user.
  • Inspect cluster readiness before a CNI exists.
Connect to the control node

Vagrant's NAT adapter keeps a management route to each VM. That route lets you enter control while Kubernetes uses the bridged address for cluster traffic.

  • Connect to control from the PowerShell session in your k8s-vagrant-lab folder by running this command:
vagrant ssh control

What does this command do?

Vagrant targets the VM with the hostname control. It opens a Linux shell through the existing management connection.

You will see a Linux shell for control. Commands from this point run inside that VM.

Can't Reach Control?

  • Confirm the k8s-control VM is running in Oracle VirtualBox Manager.
  • Return to Windows PowerShell in the k8s-vagrant-lab folder before retrying the connection.

Help me diagnose the connection.

Initialize the Kubernetes control plane

The kubeadm bootstrap process creates the control-plane components against the containerd runtime. The API server must advertise the bridged address so both workers can reach it across your LAN.

Control-plane initialization is the slowest part of this step. A busy terminal means Kubernetes is still preparing the node.

  • Initialize the control plane with its saved LAN address by running these commands:
LAN_IP=$(cat /var/tmp/k8s-lan-ip)
sudo kubeadm init --apiserver-advertise-address="$LAN_IP" --pod-network-cidr=10.244.0.0/16 --cri-socket=unix:///var/run/containerd/containerd.sock

What Does This Bootstrap Configure?

  • The LAN_IP variable reads the bridged IPv4 address saved during provisioning.
  • The --apiserver-advertise-address flag makes the Kubernetes API available through that bridged address.
  • The --pod-network-cidr=10.244.0.0/16 setting reserves the Pod address range that Flannel will use.
  • The --cri-socket=unix:///var/run/containerd/containerd.sock setting connects Kubernetes to the installed CRI runtime.

The printed worker join command contains temporary cluster access material. Keeping it in a private note prevents accidental sharing.

  • Copy the complete worker join command printed at the end into a temporary private note.
  • Keep the note available for worker1 and worker2.

That is the core bootstrap complete. The control node now hosts the API server that the workers will join.

Did Initialization Stop?

  • Check that the saved address in /var/tmp/k8s-lan-ip matches the bridged address you recorded for control.
  • Confirm the container runtime is still running before retrying the bootstrap.

Help me troubleshoot the bootstrap.

Configure kubectl and inspect the network gap

The admin configuration gives kubectl permission to query the new API server. Copying it to $HOME/.kube/config makes those credentials available to the regular vagrant user.

  • Configure kubectl for the regular user by running these commands:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

What Does This Configuration Do?

  • The $HOME/.kube directory becomes the regular user's Kubernetes configuration location.
  • The copy places the cluster administrator configuration at $HOME/.kube/config.
  • The ownership change lets the regular user read the copied configuration without elevated permissions.

Before you run these checks, do you think cluster DNS can start without a Pod network? Your prediction gives you something concrete to compare with the result.

  • Inspect the node and system Pod states by running these commands:
kubectl get nodes
kubectl get pods --all-namespaces

What Do These Checks Prove?

  • The first check asks the API server for every registered node and its readiness state.
  • The second check inspects system workloads across every namespace.

You will see control listed as NotReady. The CoreDNS Pods remain Pending.

This is the intended networking gap. The control plane exists. Pods cannot become healthy across nodes until a CNI provides their network.

Can't Query the Cluster?

  • Confirm that $HOME/.kube/config exists under the regular user's home directory.
  • Check that the regular user owns the copied configuration file.

Help me restore kubectl access.

You have reached the designed checkpoint with a running control plane and a visible Pod-network gap. Next, you will add Flannel and bring both workers into the cluster.

Add Flannel and Join the Workers

Your control plane is initialized, but Kubernetes still has no route for Pod traffic. That missing network keeps control in the NotReady state.

Flannel v0.28.9 supplies the missing CNI network. Its VXLAN overlay carries Pod traffic between the bridged VMs.

Once that network exists, CoreDNS can start. Both workers can then join the cluster as usable nodes.

In this step, get ready to:
  • Install the pinned Flannel network on control.
  • Join both worker nodes through separate Vagrant SSH sessions.
  • Confirm the three-node cluster is healthy.
Install Flannel on control

A CNI plugin gives Kubernetes a network for Pod communication. The pinned Flannel manifest uses the same 10.244.0.0/16 range chosen during control-plane initialization.

  • Install Flannel v0.28.9 from the control session by running:
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/v0.28.9/Documentation/kube-flannel.yml

What Does This Command Do?

  • The kubectl apply command creates the Flannel resources from the pinned manifest.
  • Flannel configures a VXLAN overlay for traffic between Pods on different nodes.
  • Each eligible node receives a Flannel Pod that manages its part of the network.

Before you check, predict whether CoreDNS can leave Pending now that the Pod network exists.

  • Check the system Pods from control by running:
kubectl get pods --all-namespaces

What Should You See Now?

  • Look for a Flannel Pod associated with control moving to Running.
  • CoreDNS should move from Pending to Running once the network becomes available.

Flannel Pod Not Running?

  • Confirm the apply command completed without a download error.
  • Check that control still has internet access to retrieve the manifest.
  • Check that the cluster uses the 10.244.0.0/16 Pod network range.

Help me diagnose why Flannel is not running on my control node.

Join both worker nodes

Each worker uses the saved join command to discover control. The command also registers that worker with the cluster.

The saved command contains a bootstrap credential. Keep it inside these trusted lab VMs.

  • Leave the control SSH session running.
  • Use the search box on the Windows taskbar to find Windows PowerShell.
  • Select Windows PowerShell to open a new window.
  • Use PowerShell to return to the k8s-vagrant-lab folder from earlier.
  • Connect to worker1 by running:
vagrant ssh worker1

What Does This Command Do?

Vagrant reads the existing Vagrantfile and opens a shell on worker1. Your original control session stays active.

  • Paste the complete worker join command saved in the previous step into the worker1 shell.
  • Press Enter to join worker1 to the cluster.

You should see confirmation that worker1 joined the cluster.

  • Use the search box on the Windows taskbar to find Windows PowerShell again.
  • Select Windows PowerShell to open another window.
  • Use PowerShell to return to the k8s-vagrant-lab folder from earlier.
  • Connect to worker2 by running:
vagrant ssh worker2

What Does This Command Do?

This opens a separate shell on worker2. The control and worker1 sessions remain available.

  • Paste the same complete worker join command into the worker2 shell.
  • Press Enter to join worker2 to the cluster.

You should see confirmation that worker2 joined the cluster.

Worker Failed to Join?

  • Confirm you pasted the complete command into the worker shell.
  • Check that the worker still has its bridged LAN connection.
  • Confirm the worker can reach control through the trusted LAN.

Help me troubleshoot a worker that cannot join my kubeadm cluster.

Verify the cluster health

Node readiness now depends on the workers completing their join. It also depends on Flannel starting across all three nodes.

Before you run these checks, predict whether all three nodes can now become Ready.

  • Return to the control session from earlier.
  • Check node readiness plus system Pod health by running these commands:
kubectl get nodes
kubectl get pods --all-namespaces

What Should You See?

  • The first command should list control, worker1, and worker2 as Ready.
  • The second command should show CoreDNS as Running.
  • You should also see a Running Flannel Pod for every node.

Cluster Still Becoming Ready?

  • Check the worker shells for a failed join command.
  • Check the system Pod list for a Flannel Pod that has not reached Running.
  • Confirm each worker still has its bridged LAN address from provisioning.

Help me diagnose why one of my Kubernetes nodes remains NotReady after installing Flannel.

That network gap is closed. Your control plane and both workers can now exchange Pod traffic through Flannel.

Your three-node cluster is ready. Next, you will send traffic into worker1 and prove that Kubernetes can forward it to a Pod on worker2.

Prove Cross-Host Workload Access

Your three-node Kubernetes cluster is Ready. That status proves each node can participate in the cluster.

Now you need a stronger test of the network path. A request will enter worker1 through a NodePort Service. Flannel will carry the request to an NGINX Pod on worker2.

In this step, get ready to:
  • Define a Deployment that runs one web Pod on worker2.
  • Apply a NodePort Service that accepts traffic on port 30080.
  • Load the NGINX welcome page through worker1 from the external LAN device.
Create the worker2 workload

A Deployment keeps the web workload running. Its placement rule fixes the Pod on worker2 so the final request must cross between nodes.

  • Create a plain-text file named web.yaml inside the k8s-vagrant-lab folder using your Windows file editor.
  • Paste this Deployment definition into web.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: lan-web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: lan-web
  template:
    metadata:
      labels:
        app: lan-web
    spec:
      nodeSelector:
        kubernetes.io/hostname: worker2
      containers:
        - name: nginx
          image: nginx:1.30.5-alpine
          ports:
            - name: http
              containerPort: 80

What does this definition do?

  • The Deployment manages one copy of the web workload.
  • The app: lan-web label connects the Deployment selector to its Pod template.
  • The nodeSelector places the Pod on worker2.
  • The nginx:1.30.5-alpine image serves the welcome page.
  • Container port 80 receives the web traffic inside the Pod.

Vagrant shares your Windows project folder with each guest at /vagrant. The file you save on Windows is therefore available inside control.

  • Save web.yaml.
  • Switch back to the control SSH session from earlier.
  • Apply the Deployment from the shared folder by running this command:
kubectl apply -f /vagrant/web.yaml

What does this command do?

The kubectl command reads web.yaml through the shared /vagrant path. Kubernetes then creates the lan-web Deployment.

Before you check, do you expect the new Pod to be ready immediately or to pass through an earlier startup state?

  • Check the web Pod by running this command:
kubectl get pods

What should I see?

You should see a Pod whose name starts with lan-web. Its status reaches Running once the image starts.

That is the workload half of your network proof complete. The nodeSelector keeps this Pod on worker2.

Pod not reaching Running?

  • Confirm that the image in web.yaml is nginx:1.30.5-alpine.
  • Confirm that the hostname under nodeSelector is worker2.
  • Confirm that worker2 still reports Ready using the node check from the previous step.

Help me diagnose why the lan-web Pod is not reaching Running on worker2.

Expose the Pod with a NodePort

A NodePort opens the same port on every node. The Service can accept the external request on worker1 while the Pod stays on worker2.

  • Switch back to web.yaml in your Windows file editor.
  • Append this Service definition after the Deployment:
---
apiVersion: v1
kind: Service
metadata:
  name: lan-web
spec:
  type: NodePort
  selector:
    app: lan-web
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
      nodePort: 30080

How does the Service route traffic?

  • The app: lan-web selector targets the Pod with the matching label.
  • The TCP protocol carries the HTTP connection.
  • The Service listens on port 80 inside the cluster.
  • The targetPort sends the connection to container port 80.
  • The nodePort exposes port 30080 on every node.
  • Save web.yaml.
  • Switch back to the control SSH session.
  • Apply the complete workload file by running this command:
kubectl apply -f /vagrant/web.yaml

What changed in the cluster?

Kubernetes keeps the existing lan-web Deployment aligned with the file. It also creates the lan-web NodePort Service.

The command output confirms that Kubernetes accepted both resource definitions. Port 30080 is now the external entry point.

Service definition rejected?

  • Confirm that the separator above the Service is exactly ---.
  • Confirm that nodePort: 30080 is nested under the Service port entry.
  • Confirm that every indentation level in web.yaml uses spaces.

Help me fix the lan-web Deployment and NodePort Service YAML.

✔️ Awesome, I've got everything!

Your saved web.yaml now contains the Deployment and Service definitions.

ⓧ I'd like to double check the full code

  • Compare web.yaml with this complete version:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: lan-web
spec:
  replicas: 1
  selector:
    matchLabels:
      app: lan-web
  template:
    metadata:
      labels:
        app: lan-web
    spec:
      nodeSelector:
        kubernetes.io/hostname: worker2
      containers:
        - name: nginx
          image: nginx:1.30.5-alpine
          ports:
            - name: http
              containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: lan-web
spec:
  type: NodePort
  selector:
    app: lan-web
  ports:
    - name: http
      protocol: TCP
      port: 80
      targetPort: 80
      nodePort: 30080

How is the file organized?

The first document defines the single NGINX Pod on worker2. The second document exposes that workload through port 30080 on every node.

Test the cross-host route

Worker1's bridged LAN address is the entry point for the final test. Flannel's VXLAN overlay carries the request to the ready endpoint on worker2.

Before you load the URL, do you expect worker1 to serve the page even though the only web Pod is fixed on worker2?

  • Visit http://<worker1-lan-ip>:30080 in a web browser on the external LAN device from earlier.

You'll see the NGINX welcome page. You have now proved that an external request can enter worker1 before reaching the workload on worker2.

What did this prove?

  • The bridged network delivered the external request to worker1.
  • The NodePort accepted the connection on port 30080.
  • The Service selected the ready lan-web endpoint on worker2.
  • The Flannel overlay carried the traffic between the two worker nodes.

NGINX page not loading?

  • Use the recorded worker1 bridged LAN address in place of <worker1-lan-ip>.
  • Confirm that the external device is still connected to the same trusted LAN.
  • Confirm that the lan-web Pod remains Running using the Pod check from earlier.

Help me troubleshoot why the worker1 NodePort cannot reach the NGINX Pod on worker2.

Secret mission

Secret Mission: Keep the Site Up During Worker Maintenance

Replace the single pinned replica with two schedulable replicas. Drain worker2 while the existing NGINX address keeps serving the site.

Clean Up Your Resources

Bridged networking exposes each guest to the LAN as another machine.

  • Keep the VMs only on the trusted LAN you validated.

Nice Work!

Nice Work!

You did it! You built a three-node Kubernetes lab with bridged LAN access. Your external NGINX page stayed reachable through worker maintenance.

You've learned how to:

  • Provision a repeatable three-VM lab from one Vagrantfile using Vagrant on VirtualBox. Give every node a bridged LAN address while preserving Vagrant management access.
  • Expose the CNI dependency through a NotReady control plane with Pending CoreDNS Pods. Restore all three nodes to Ready with Flannel v0.28.9.
  • Prove cross-node service routing by loading the NGINX welcome page from another LAN device through worker1 on NodePort 30080. The responding Pod ran on worker2.
  • Secret Mission: Keep the website reachable during a drain of worker2 by rescheduling both replicas onto worker1. Return worker2 to scheduling service after maintenance.

Ready to quiz yourself?