Build a 3-Node Kubernetes Cluster

Bootstrap a real K8s cluster on Proxmox VMs using kubeadm and Calico.

Introduction

30 Second Summary

When you use a managed Kubernetes service in the cloud, all the complex setup is hidden from you. Building a cluster from scratch on your own hardware is how you learn what is really going on under the hood.

In this project, you will build a 3-node Kubernetes cluster on your Proxmox server using three Ubuntu 24.04 VMs. You will go from three blank VMs to a fully working cluster with a live nginx web server you can access from your browser.

What You'll Build

A fully functional Kubernetes cluster running on three Ubuntu 24.04 VMs on your Proxmox server, with Calico CNI networking and a live nginx deployment accessible from your browser.

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

  • Three Ubuntu 24.04 VMs with static IPs and unique hostnames, running containerd as the container runtime on all nodes.
  • A Kubernetes control plane initialized with kubeadm, with two worker nodes joined to the cluster.
  • A live nginx Deployment exposed via a NodePort Service, accessible from your Mac's browser.
  • Scale your nginx deployment to 6 replicas and watch Kubernetes distribute pods across your worker nodes in the Secret Mission.

Are there any prerequisites?

This project picks up where the "Install Proxmox and Build a Linux VM" project left off. You need Proxmox VE installed on dedicated hardware with a working Ubuntu 24.04 VM template (VM ID 9000). You also need an SSH key pair configured on your Mac.

Clone Three VMs and Assign Static IPs

A Kubernetes cluster needs multiple machines working together. You are building three virtual machines: one control plane node that manages the cluster, and two worker nodes that run your applications.

You already have an Ubuntu 24.04 VM template on your Proxmox server from the previous project. In this step, you will clone it three times, give each VM a unique hostname, and lock in static IP addresses so your cluster network stays stable.

In this step, get ready to:
  • Clone three VMs from your Proxmox template.
  • Assign a static IP address to each VM using netplan.
  • Configure /etc/hosts and verify all nodes can reach each other.
Clone three VMs from your template

Each Kubernetes node runs on its own VM. You need three independent copies of your Ubuntu template, each with a unique VM ID and name.

How much RAM and CPU do I need?

Each VM needs at least 2 GB of RAM and 2 vCPUs. The control plane node requires 2 vCPUs as a hard requirement from kubeadm preflight checks.

Start with the control plane VM.

  • Open your Proxmox web UI in your Mac's browser.
  • In the left sidebar, find your VM template with ID 9000.
  • Right-click the template and select Clone.
  • In the clone dialog, set VM ID to 110.
  • Set Name to k8s-control.
  • Set Mode to Full Clone.
  • Click Clone.

What is a Full Clone?

A Full Clone creates a completely independent copy of the template's disk. This means each VM operates on its own storage and does not depend on the original template.

The alternative, a Linked Clone, shares storage with the template to save disk space. For a Kubernetes cluster, Full Clones are the better choice because each node needs to be fully independent.

A lock icon appears next to the VM while cloning is in progress. Wait for it to disappear before continuing.

Now clone the two worker VMs.

  • Right-click the 9000 template again and select Clone.
  • Set VM ID to 111, Name to k8s-worker-1, and Mode to Full Clone.
  • Click Clone and wait for the lock icon to disappear.
  • Repeat one more time: VM ID 112, Name k8s-worker-2, Mode Full Clone.
  • Click Clone and wait for the lock icon to disappear.

What about the hostname?

Proxmox uses the VM name you set during cloning as the hostname via cloud-init. When you name a clone k8s-control, cloud-init sets the hostname to k8s-control on first boot.

Now verify the hardware settings and start each VM.

  • Click on k8s-control (VM 110) in the left sidebar.
  • Select the Hardware tab and confirm the VM has at least 2 GB of memory and 2 CPU cores.
  • Click Start in the top right to boot the VM.
  • Repeat for k8s-worker-1 (VM 111) and k8s-worker-2 (VM 112). Verify hardware and start each one.

You should now see all three VMs running in the Proxmox sidebar with green status icons.

VM won't start?

Make sure the cloning process completed before clicking Start. If you see a lock icon next to the VM, the clone is still in progress.

Also check that your Proxmox host has enough free RAM. Three VMs at 2 GB each need at least 6 GB available beyond what the host itself uses.

Still stuck? Help me troubleshoot why my Proxmox VM won't start after cloning from a template.

Assign static IPs with netplan

Right now your VMs get IP addresses from your router's DHCP server. These addresses can change whenever the router decides to reassign them.

Kubernetes nodes need to find each other at fixed addresses. If an IP changes, the cluster breaks. You will replace the DHCP configuration with static IPs using netplan, Ubuntu's network configuration tool.

Pro tip: pick your IPs

Choose three IP addresses on your home network that are outside your router's DHCP range. Most routers assign DHCP addresses in a range like 192.168.1.100 to 192.168.1.254.

Picking addresses below that range (like 192.168.1.50, 192.168.1.51, 192.168.1.52) avoids conflicts. Check your router's admin page if you are unsure of the DHCP range.

You also need your router's gateway IP (usually 192.168.1.1 or 192.168.0.1). You can find this by running ip route inside any of your VMs and looking for the default via line.

First, find the current DHCP address of k8s-control so you can SSH into it.

  • In the Proxmox web UI, click on k8s-control (VM 110) in the sidebar.
  • Click the Console tab to open the VM's terminal.
  • Log in with the username you configured in your template.
  • Find the current IP address by running:
ip addr show

Look for the inet line under your network interface (likely ens18). That address is the current DHCP IP.

  • Open Terminal on your Mac by pressing Cmd+Space and typing Terminal.
  • SSH into k8s-control by running:
ssh [[SSH_USER="your VM username"]]@[[CONTROL_DHCP="current DHCP IP of k8s-control"]]

Before editing the netplan file, you need to stop cloud-init from overwriting your network changes on reboot.

  • Disable cloud-init network management by running:
sudo bash -c 'echo "network: {config: disabled}" > /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg'

Why disable cloud-init networking?

Cloud-init manages the network configuration on first boot and can regenerate the netplan file on every reboot. If you edit the netplan file without disabling cloud-init networking, your static IP changes get overwritten the next time the VM restarts.

Now check the existing netplan configuration to confirm your interface name.

  • View the current netplan file by running:
cat /etc/netplan/50-cloud-init.yaml

You should see a YAML file with an interface name like ens18 and dhcp4: true. Note the interface name, as you will use it in the next step.

File not found?

The file might have a different name on your template. Run ls /etc/netplan/ to see what netplan files exist. Use whatever file you find (for example, 00-installer-config.yaml).

Help me find my netplan configuration file on Ubuntu 24.04.

  • Open the netplan file for editing by running:
sudo nano /etc/netplan/50-cloud-init.yaml
  • Delete all existing content in the file. In nano, hold Ctrl+K repeatedly to delete each line until the file is empty.
  • Paste the following configuration, replacing the IP address and gateway with your chosen values:
network:
  version: 2
  ethernets:
    ens18:
      dhcp4: false
      addresses:
        - [[CONTROL_IP="192.168.1.50"]]/24
      routes:
        - to: default
          via: [[GATEWAY_IP="192.168.1.1"]]
      nameservers:
        addresses:
          - 8.8.8.8
          - 8.8.4.4

What does this configuration do?

  • dhcp4: false turns off automatic address assignment. The VM will use only the address you specify.
  • addresses sets the static IP with a /24 subnet mask, meaning the first three octets define the network.
  • routes sets the default gateway, which is your router's IP address. All traffic leaving the local network goes through here.
  • nameservers points to Google's public DNS servers for domain name resolution.

YAML spacing matters

YAML files are very sensitive to indentation. Use exactly 2 spaces for each indentation level. Do not use tabs. If the indentation is off by even one space, netplan apply will fail.

  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
  • Apply the new network configuration by running:
sudo netplan apply

Your SSH connection will freeze

This is expected. You just changed the VM's IP address, so your current SSH session (connected to the old DHCP address) can no longer reach the VM. Close your terminal window.

  • Open a new Terminal tab on your Mac.
  • Reconnect using the new static IP by running:
ssh [[SSH_USER="your VM username"]]@[[CONTROL_IP="192.168.1.50"]]
  • Verify the hostname is correct by running:
hostname

You should see k8s-control in the output.

Can't connect with the new IP?

Use the Proxmox Console tab to log into the VM directly. Run ip addr show to check what IP the VM actually has. If the IP did not change, your netplan file may have a syntax error.

Run sudo netplan try instead of sudo netplan apply. This command tests the config for 120 seconds and automatically reverts if something goes wrong.

Also confirm the interface name in your YAML matches the actual interface. Run ip link to see the interface names on your VM.

Help me debug my netplan static IP configuration on Proxmox.

Now repeat the same process for the two worker VMs.

  • In the Proxmox Console for k8s-worker-1 (VM 111), find the DHCP IP with ip addr show.
  • SSH into k8s-worker-1 from your Mac using that DHCP IP.
  • Disable cloud-init networking with the same command you used before:
sudo bash -c 'echo "network: {config: disabled}" > /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg'
  • Edit the netplan file with sudo nano /etc/netplan/50-cloud-init.yaml.
  • Replace the content with the same YAML structure, but use worker 1's IP:
network:
  version: 2
  ethernets:
    ens18:
      dhcp4: false
      addresses:
        - [[WORKER1_IP="192.168.1.51"]]/24
      routes:
        - to: default
          via: [[GATEWAY_IP="192.168.1.1"]]
      nameservers:
        addresses:
          - 8.8.8.8
          - 8.8.4.4
  • Save and exit nano, then apply with sudo netplan apply.
  • Reconnect via SSH using the new static IP and verify the hostname shows k8s-worker-1.
  • Repeat the entire process for k8s-worker-2 (VM 112), using worker 2's IP:
network:
  version: 2
  ethernets:
    ens18:
      dhcp4: false
      addresses:
        - [[WORKER2_IP="192.168.1.52"]]/24
      routes:
        - to: default
          via: [[GATEWAY_IP="192.168.1.1"]]
      nameservers:
        addresses:
          - 8.8.8.8
          - 8.8.4.4
  • Save, apply with sudo netplan apply, reconnect via SSH, and verify the hostname shows k8s-worker-2.
Configure /etc/hosts and verify connectivity

Your VMs now have static IPs, but they do not know each other's hostnames. The /etc/hosts file maps hostnames to IP addresses locally. Kubernetes components use hostnames to identify nodes, so every VM needs to resolve all three names.

  • SSH into k8s-control from your Mac.
  • Add all three node entries to /etc/hosts by running these commands:
echo "[[CONTROL_IP="192.168.1.50"]] k8s-control" | sudo tee -a /etc/hosts
echo "[[WORKER1_IP="192.168.1.51"]] k8s-worker-1" | sudo tee -a /etc/hosts
echo "[[WORKER2_IP="192.168.1.52"]] k8s-worker-2" | sudo tee -a /etc/hosts

What does /etc/hosts do?

The /etc/hosts file is a simple lookup table that your VM checks before querying a DNS server. When Kubernetes tries to reach k8s-worker-1, the system checks this file first and finds the matching IP address instantly.

  • Repeat the same three echo commands on k8s-worker-1 and k8s-worker-2. SSH into each worker and run the same block of commands.

Now test that all three VMs can reach each other by name.

  • From k8s-control, ping both workers by running:
ping -c 3 k8s-worker-1
ping -c 3 k8s-worker-2

You should see three successful replies from each worker, with 0% packet loss.

  • From k8s-worker-1, ping the control plane and the other worker by running:
ping -c 3 k8s-control
ping -c 3 k8s-worker-2
  • From k8s-worker-2, ping the control plane and the other worker by running:
ping -c 3 k8s-control
ping -c 3 k8s-worker-1

All pings should return with 0% packet loss. This confirms that every node can reach every other node by hostname.

Pings failing?

If you see Name or service not known, the /etc/hosts file on the VM you are pinging from is missing an entry. Run cat /etc/hosts to check the contents and make sure all three lines are present.

If you see Destination Host Unreachable, the static IP on the target VM may not have been applied correctly. Use the Proxmox Console to check the target VM's IP with ip addr show.

Help me troubleshoot why my VMs can't ping each other.

Your three VMs are cloned, named, locked to static IPs, and able to reach each other by hostname. Next up, you will prepare all three nodes for Kubernetes by disabling swap, loading kernel modules, and installing the container runtime.

Prepare All Nodes for Kubernetes

Your three VMs are up, each with a unique hostname and static IP. They can ping each other by name, and their addresses won't shift if your router restarts.

Kubernetes has strict system requirements that every node must meet before you install any cluster components. In this step, you'll disable swap, load kernel modules, tune network settings, and install a container runtime on all three VMs.

In this step, get ready to:
  • Disable swap on all nodes so Kubernetes can manage memory directly.
  • Load kernel modules and configure network forwarding settings.
  • Install and configure containerd as the container runtime.

Repeat every command on all three nodes

Every command in this step must run on k8s-control, k8s-worker-1, and k8s-worker-2.

SSH into one node and complete all three substeps below, then repeat on the other two.

Pro tip: open three terminal tabs

Open three separate Terminal tabs on your Mac, one SSH session per VM. This makes it easy to run the same commands across all nodes without disconnecting.

Disable swap on all nodes

Kubernetes manages memory allocation for containers directly. If swap is enabled, the Linux kernel might move container memory to disk, causing unpredictable latency. kubelet refuses to start if swap is detected.

  • Turn off swap and prevent it from re-enabling after a reboot by running these commands:
# Turn off swap immediately
sudo swapoff -a

# Comment out any swap entries so it stays off after reboot
sudo sed -i '/swap/ s/^/#/' /etc/fstab

What do these commands do?

  • sudo swapoff -a disables swap for the current session.
  • The sed command comments out any swap line in /etc/fstab so swap stays off when the VM reboots.
  • Confirm swap is disabled by running:
free -h

The Swap row should show 0B for total, used, and free.

Swap still showing a value?

  • Run sudo swapoff -a again and double check that you didn't miss the sudo prefix.
  • Open /etc/fstab with cat /etc/fstab and verify that any line containing the word swap starts with a #.
  • If there's no swap line at all, that's fine. Ubuntu 24.04 cloud images often don't have swap configured.

Still stuck? Ask for help.

Load kernel modules and configure sysctl

Kubernetes networking relies on two Linux kernel modules. overlay enables the overlay filesystem that containerd uses to layer container images. br_netfilter lets Linux process bridged network traffic through iptables, which is how Kubernetes routes packets between pods.

  • Create a configuration file that loads both modules automatically on boot, then load them for the current session by running these commands:
# Persist modules across reboots
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF

# Load modules for the current session
sudo modprobe overlay
sudo modprobe br_netfilter

What does this do?

  • The cat <<EOF block writes the two module names to /etc/modules-load.d/k8s.conf so Linux loads them every time the VM starts.
  • modprobe loads the modules right now without needing a reboot.
  • Verify both modules are loaded by running:
lsmod | grep br_netfilter
lsmod | grep overlay

Each command should return a line showing the module name, confirming it's loaded.

With the modules loaded, you also need to tell the kernel to forward network traffic between interfaces and to filter bridged traffic through iptables.

  • Create the sysctl configuration and apply it by running these commands:
# Configure required network settings for Kubernetes
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

# Apply the settings
sudo sysctl --system

What do these settings control?

  • net.bridge.bridge-nf-call-iptables and net.bridge.bridge-nf-call-ip6tables ensure that bridged traffic passes through iptables rules, which Kubernetes needs to route packets correctly between pods.
  • net.ipv4.ip_forward enables the kernel to forward packets between network interfaces, which is essential for pod-to-pod communication across nodes.
  • Confirm the settings are active by running:
sysctl net.bridge.bridge-nf-call-iptables net.bridge.bridge-nf-call-ip6tables net.ipv4.ip_forward

All three values should show = 1.

Seeing = 0 for one of the values?

  • Make sure br_netfilter is loaded. Run lsmod | grep br_netfilter. If you see no output, re-run sudo modprobe br_netfilter.
  • After loading the module, re-apply with sudo sysctl --system and check the values again.

Still seeing 0? Ask for help.

Install and configure containerd

Kubernetes doesn't run containers by itself. It delegates that job to a container runtime. containerd is the most widely used runtime for Kubernetes clusters.

  • Install containerd from the Ubuntu repository by running:
sudo apt update
sudo apt install -y containerd

Seeing apt errors?

  • Check your VM's internet connection by running ping -c 3 google.com. If the ping fails, verify that your VM's netplan gateway address matches your router's IP.
  • If you see a lock file error, another process may be using apt. Wait a minute and try again.

Need more help? Ask here.

containerd ships without a configuration file by default. You need to generate the default config, then change one critical setting so that containerd uses the same cgroup driver as Kubernetes.

  • Generate the default containerd configuration file by running:
# Create the config directory and generate the default config
sudo mkdir -p /etc/containerd
sudo containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
  • Enable the systemd cgroup driver by running:
# Switch containerd's cgroup driver from cgroupfs to systemd
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml

Why change the cgroup driver?

Cgroups control how Linux allocates CPU and memory to processes. Kubernetes uses the systemd cgroup driver by default, but containerd ships with cgroupfs. If the two don't match, the node becomes unstable because both try to manage resources independently.

  • Verify the change took effect by running:
grep 'SystemdCgroup' /etc/containerd/config.toml

You should see SystemdCgroup = true in the output.

  • Restart containerd so it picks up the new configuration, and enable it to start on boot, by running:
sudo systemctl restart containerd
sudo systemctl enable containerd
  • Confirm containerd is running by checking its status:
sudo systemctl status containerd

You should see active (running) in green text. Press q to exit the status view.

Containerd showing inactive or failed?

  • Run sudo journalctl -u containerd --no-pager -n 20 to see the recent logs. Look for error messages pointing to the config file.
  • The most common cause is a typo in /etc/containerd/config.toml. Regenerate it by re-running the containerd config default command, then re-run the sed command to set SystemdCgroup = true.

Still not working? Ask for help.

Don't forget the other two nodes

If you've only completed these steps on one VM, SSH into your remaining nodes and repeat all three substeps (disable swap, load kernel modules, install containerd). All three VMs must have identical configurations before you move on.

All three VMs now have swap disabled, the right kernel modules loaded, and containerd running with the correct cgroup driver. Next up, you'll install kubeadm, kubelet, and kubectl to bring Kubernetes itself onto every node.

Install kubeadm, kubelet, and kubectl

Your nodes are prepped. Swap is off, the kernel modules are loaded, and containerd is running on all three VMs. Now it's time to install the actual Kubernetes tools.

Three packages make up a Kubernetes node installation. kubeadm bootstraps the cluster. kubelet runs on every node to manage containers. kubectl is the command-line tool you use to talk to the cluster.

In this step, get ready to:
  • Add the Kubernetes v1.35 apt repository to all three nodes.
  • Install and pin kubeadm, kubelet, and kubectl.
  • Verify the installation on each node.

Run every command on all three VMs

Every command in this step must run on k8s-control, k8s-worker-1, and k8s-worker-2. SSH into each VM in a separate terminal tab so you can run the same commands on all three.

Add the Kubernetes apt repository

Kubernetes packages are not included in Ubuntu's default package repositories. You need to add the official Kubernetes apt repository before you can install anything.

  • Install the prerequisite packages by running this command:
sudo apt-get install -y apt-transport-https ca-certificates curl gpg
  • Download the Kubernetes v1.35 signing key and add the apt repository by running these commands:
curl -fsSL https://pkgs.k8s.io/core:/stable:/v1.35/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg

echo 'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.35/deb/ /' | sudo tee /etc/apt/sources.list.d/kubernetes.list

sudo apt-get update

What do these commands do?

  • The first command downloads the GPG signing key from pkgs.k8s.io and saves it to /etc/apt/keyrings/. This key lets apt verify that the packages it downloads are genuinely from the Kubernetes project.
  • The second command adds the Kubernetes v1.35 repository to your system's list of package sources.
  • The third command refreshes apt so it knows about the new repository and what packages are available.
  • Check the terminal output from apt-get update. You should see https://pkgs.k8s.io listed as one of the repositories being fetched.

Seeing a GPG or network error?

  • Make sure curl and gpg installed successfully in the prerequisite step. Re-run the first command if needed.
  • If you see a NO_PUBKEY error during apt-get update, the signing key did not download correctly. Re-run the curl command and check that the file exists at /etc/apt/keyrings/kubernetes-apt-keyring.gpg.
  • If the VM cannot reach the internet, check that your VM's network adapter is configured correctly in Proxmox and that you can ping 8.8.8.8.

Help me troubleshoot this GPG or network error when adding the Kubernetes apt repository on Ubuntu 24.04.

Install and pin the Kubernetes packages

With the repository added, apt now knows where to find Kubernetes packages. You can install all three tools in one command.

  • Install kubelet, kubeadm, and kubectl by running:
sudo apt-get install -y kubelet kubeadm kubectl
  • You should see the three packages install successfully. The exact version will be the latest patch in the 1.35 series.
  • Pin all three packages to prevent accidental upgrades by running:
sudo apt-mark hold kubelet kubeadm kubectl

Why hold these packages?

All three Kubernetes components on a node must run the same version. If apt silently upgrades one of them during a routine sudo apt upgrade, you can end up with a version mismatch that breaks your cluster.

Holding the packages means you control exactly when and how Kubernetes gets upgraded.

  • Enable the kubelet service so it starts automatically on boot by running:
sudo systemctl enable kubelet
  • You should see a message confirming the service has been enabled. The kubelet will crash-loop until the cluster is initialized in a later step. That is expected.

Getting 'package not found'?

  • Run cat /etc/apt/sources.list.d/kubernetes.list and check that the URL contains v1.35. A typo in the version number means apt is looking in the wrong repository.
  • Make sure you ran sudo apt-get update after adding the repository. Without the update, apt does not know the new packages exist.

Help me troubleshoot a 'package not found' error when installing kubeadm, kubelet, and kubectl on Ubuntu 24.04.

Verify the installation

Before moving on, confirm that all three tools installed correctly on each VM.

  • Check the installed version of each tool by running these commands:
kubeadm version
kubelet --version
kubectl version --client

What should I see?

All three commands should report a 1.35.x version number. The exact patch version (like 1.35.3) depends on what the repository had at install time.

The important thing is that all three tools show the same version. Mismatched versions between kubeadm, kubelet, and kubectl can cause problems during cluster setup.

Seeing 'command not found'?

  • The install command may have failed silently. Run sudo apt-get install -y kubelet kubeadm kubectl again and look for error messages in the output.
  • If the version numbers don't match across the three commands, remove and reinstall all three packages from the same repository to ensure consistency.

Help me troubleshoot a 'command not found' error after installing Kubernetes packages on Ubuntu 24.04.

Make sure you have completed all three substeps on all three VMs: k8s-control, k8s-worker-1, and k8s-worker-2. Every node needs these packages installed before you can build the cluster.

All three nodes now have the same Kubernetes tools at the same version. Next up, you will run kubeadm init on the control plane node and bring your cluster to life.

Initialize the Kubernetes Control Plane

Your three VMs now have kubeadm, kubelet, and kubectl installed. Now it is time to turn one of those VMs into the brain of your cluster.

In this step, you run kubeadm init on the control plane node to set up the API server, etcd, the scheduler, and the controller manager. Once it finishes, you will have a working Kubernetes cluster with a single node.

In this step, get ready to:
  • Run kubeadm init to bootstrap the control plane.
  • Configure kubectl to manage your cluster without sudo.
  • Verify the control plane components are running.
Run kubeadm init on the control plane node

The kubeadm init command does all the heavy lifting. It pulls container images, generates TLS certificates, and produces a kubeadm join command that your worker nodes will use in the next step.

  • SSH into your control plane node (k8s-control).
  • Initialize the control plane by running this command (this takes a few minutes to complete):
sudo kubeadm init --pod-network-cidr=10.244.0.0/16 --control-plane-endpoint="[[CONTROL_PLANE_IP=\"your control plane static IP\"]]:6443"

What do these flags do?

  • --pod-network-cidr=10.244.0.0/16 tells Kubernetes which IP range to assign to pods. You will configure Calico to use this same range in the next step.
  • --control-plane-endpoint sets the address that worker nodes use to reach the API server. The :6443 is the default API server port.

When it completes successfully, you will see Your Kubernetes control plane has initialized successfully! in the output.

Save the kubeadm join command

At the end of the output, you will see a kubeadm join command with a token and certificate hash. Copy this entire command and save it somewhere safe. You will need it in the next step to join your worker nodes to the cluster.

The join token expires after 24 hours. If you come back later and need a new one, run this command on the control plane node:

kubeadm token create --print-join-command

Seeing an error during kubeadm init?

  • If you see [ERROR NumCPU], your VM needs at least 2 vCPUs. Shut down the VM in Proxmox, increase the CPU count to 2, and try again.
  • If you see [ERROR Swap], swap is still enabled. Run sudo swapoff -a and check that the swap line in /etc/fstab is commented out.
  • If you see [ERROR CRI], containerd may not be running. Run sudo systemctl restart containerd and try again.
  • Help me debug my kubeadm init error.
Configure kubectl for your user

kubectl needs access to the cluster's admin credentials to send commands to the API server. The following commands copy the admin kubeconfig file to your home directory so you can run kubectl without sudo.

  • Set up kubectl access by running these commands on the control plane node:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

What do these commands do?

  • mkdir -p $HOME/.kube creates the .kube directory in your home folder if it does not already exist.
  • sudo cp copies the admin kubeconfig file that kubeadm generated into your .kube directory.
  • sudo chown changes ownership of the config file to your user so kubectl can read it without sudo.

You will verify this worked in the next substep by running kubectl commands against your cluster.

Verify the control plane is running
  • Check your cluster nodes by running:
kubectl get nodes

You should see your control plane node (k8s-control) listed with a status of NotReady. This is expected because you have not installed a network plugin yet.

  • Check the control plane pods by running:
kubectl get pods -n kube-system

You should see pods for etcd, kube-apiserver, kube-scheduler, and kube-controller-manager all in a Running state.

Why does the node show NotReady?

Kubernetes requires a CNI (Container Network Interface) plugin to manage pod networking. Without one, the node cannot route traffic between pods. You will install Calico in the next step, which changes the status to Ready.

Seeing a connection refused error?

  • If kubectl get nodes returns The connection to the server was refused, make sure you ran all three kubeconfig setup commands from the previous substep.
  • If you see a permissions error, double-check that the chown command ran successfully. Run ls -la $HOME/.kube/config to check that your user owns the file.
  • Help me fix my kubectl connection issue.

Your control plane is up and running. Next up, you will install Calico to give your pods a network and join both worker nodes to the cluster.

Install Calico and Join the Worker Nodes

In the previous step, you initialized the Kubernetes control plane with kubeadm. The API server, scheduler, and controller manager are all running. But if you checked kubectl get nodes, you noticed the control plane shows a status of NotReady.

The missing piece is a network plugin. Kubernetes delegates all pod-to-pod networking to a plugin called a CNI (Container Network Interface). Without one, pods have no way to communicate with each other. In this step, you will install Calico as your CNI and then bring both worker nodes into the cluster.

In this step, get ready to:
  • Install Calico using the Tigera Operator to provide pod networking.
  • Join both worker nodes to the cluster with the kubeadm join command.
  • Verify all three nodes show a Ready status.
Install the Tigera Operator and Calico

Calico uses the Tigera Operator to manage its lifecycle on your cluster. Instead of applying one massive YAML file, the operator watches for a custom resource that describes your desired Calico configuration and builds everything for you.

  • Make sure you are SSH'd into k8s-control (your control plane node). All commands in this substep run on the control plane.
  • Install the Tigera Operator CRDs and the operator itself by running these two commands:
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.31.4/manifests/operator-crds.yaml
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.31.4/manifests/tigera-operator.yaml

What do these commands do?

The first command installs Custom Resource Definitions (CRDs) that teach Kubernetes about Calico's configuration objects. The second command deploys the Tigera Operator itself as a Deployment in the tigera-operator namespace.

Why kubectl create instead of kubectl apply?

The CRD bundle for Calico is very large. kubectl apply stores the entire manifest as an annotation, which can exceed Kubernetes request size limits. kubectl create avoids this problem by sending the resource directly.

You should see output listing the created resources, including namespaces, service accounts, cluster roles, and the operator deployment.

Now you need to download and configure the Calico custom resources file. This file tells the operator how to set up Calico's networking.

  • Download the custom resources file by running:
curl -O https://raw.githubusercontent.com/projectcalico/calico/v3.31.4/manifests/custom-resources.yaml

The default custom resources file uses 192.168.0.0/16 as the pod network CIDR. Your home network almost certainly uses 192.168.x.x addresses, so this default would create routing conflicts. You need to change it to match the 10.244.0.0/16 CIDR you specified during kubeadm init.

  • Update the pod CIDR in the custom resources file by running:
sed -i 's/192.168.0.0\/16/10.244.0.0\/16/' custom-resources.yaml

What does this sed command do?

This finds every occurrence of 192.168.0.0/16 in the file and replaces it with 10.244.0.0/16. The backslashes before the forward slashes are escape characters so sed does not confuse the slashes in the IP addresses with its own delimiters.

  • Verify the change took effect by running:
grep cidr custom-resources.yaml

You should see 10.244.0.0/16 in the output instead of 192.168.0.0/16.

Still seeing 192.168.0.0/16?

Make sure you included the backslashes before the forward slashes in the sed command. Without them, sed interprets the slashes as delimiters and the command silently fails.

Help me fix my sed command for editing the Calico custom resources file.

  • Apply the custom resources to install Calico by running:
kubectl create -f custom-resources.yaml

You should see output confirming the creation of resources like installation.operator.tigera.io/default created.

  • Watch the Calico pods come up by running:
watch kubectl get pods -n calico-system

Wait until you see calico-node, calico-typha, and calico-kube-controllers pods all showing Running in the STATUS column. This can take 2-3 minutes. Press Ctrl+C to exit the watch once all pods are running.

Pods stuck in Pending or CrashLoopBackOff?

If pods stay in Pending, your VMs may not have enough resources. Each VM needs at least 2 GB RAM and 2 vCPUs.

If you see CrashLoopBackOff, check the pod logs with kubectl logs -n calico-system <pod-name> for error details.

Help me troubleshoot my Calico pods that are not reaching Running status.

Join both worker nodes to the cluster

With Calico providing networking, your cluster is ready to accept worker nodes. Each worker runs the kubeadm join command you saved from Step 4. This command registers the worker with the control plane and starts the kubelet.

  • Open a new terminal window on your Mac.
  • SSH into k8s-worker-1 by running:
ssh [[WORKER1_IP="your worker-1 IP address (e.g. 192.168.1.51)"]]
  • Run the kubeadm join command you saved from Step 4. It looks like this:
sudo kubeadm join your-control-plane-ip:6443 --token your-token-here --discovery-token-ca-cert-hash sha256:your-hash-here

What does kubeadm join do?

The worker node contacts the API server using the token to prove it is authorized to join. The control plane issues the worker a certificate, and the kubelet starts running on the worker. From this point on, the control plane can schedule pods onto this node.

After a few moments, you should see a success message:

This node has joined the cluster:
* Certificate signing request was sent to apiserver and a response was received.
* The Kubelet was informed of the new secure connection details.

Run 'kubectl get nodes' on the control-plane to see this node join the cluster.

Pro tip: token expired?

Join tokens expire after 24 hours. If you took a break between steps, the token from Step 4 may have expired. Run this on the control plane node to generate a new join command:

sudo kubeadm token create --print-join-command
  • Repeat the same process for k8s-worker-2. Open another terminal window, SSH into the second worker, and run the same kubeadm join command.
ssh [[WORKER2_IP="your worker-2 IP address (e.g. 192.168.1.52)"]]
  • Run the same kubeadm join command on this node.
  • Wait for the same success message confirming this node has joined the cluster.

Seeing a connection refused error?

Make sure the control plane node's IP address in the join command matches the static IP you configured for k8s-control in Step 1.

Verify the worker can reach the control plane by running ping k8s-control from the worker node. If this fails, check your /etc/hosts entries and network configuration.

Help me troubleshoot my kubeadm join command that is failing.

Verify all nodes are Ready
  • Switch back to your terminal session on k8s-control (the control plane node).
  • Check the status of all nodes by running:
kubectl get nodes

You should see all three nodes listed with a STATUS of Ready:

NAME            STATUS   ROLES           AGE     VERSION
k8s-control     Ready    control-plane   10m     v1.35.x
k8s-worker-1    Ready    <none>          2m      v1.35.x
k8s-worker-2    Ready    <none>          1m      v1.35.x

What does Ready mean?

A Ready status means the kubelet on each node is running, the node can communicate with the control plane, and the CNI plugin (Calico) is providing networking. If a node still shows NotReady, Calico may still be starting up on that node. Wait a minute and run the command again.

Node stuck on NotReady?

Check that Calico pods are running on all nodes by running kubectl get pods -n calico-system -o wide. You should see a calico-node pod on each of your three nodes.

If a worker node does not have a calico-node pod, check the kubelet logs on that worker with sudo journalctl -u kubelet --no-pager -n 50 for error messages.

Help me figure out why my node is stuck on NotReady.

Your cluster is fully operational. All three nodes are healthy and connected through Calico. Next, you will deploy an nginx web server to prove your cluster can run real workloads and serve traffic to your Mac's browser.

Deploy a Test Workload

Your cluster is fully operational. All three nodes are showing Ready status and Calico is handling pod networking across the cluster.

Time to put it to the test. You will deploy an nginx web server and access it from your Mac's browser to prove that traffic flows end to end.

In this step, get ready to:
  • Deploy two nginx pods using a Kubernetes Deployment.
  • Expose the pods with a NodePort Service.
  • Access the nginx welcome page from your Mac's browser.
Create an nginx Deployment

A Deployment tells Kubernetes how many copies of a container to run and keeps them healthy. If a pod crashes, the Deployment automatically creates a replacement.

  • On your control plane node (k8s-control), deploy two nginx pods by running this command:
kubectl create deployment nginx --image=nginx --replicas=2

What does this command do?

  • nginx is the name you are giving this Deployment.
  • --image=nginx tells Kubernetes to pull the official nginx container image from Docker Hub.
  • --replicas=2 creates two identical pods running nginx. Kubernetes distributes them across your worker nodes.
  • Check that your pods are running by running this command:
kubectl get pods -o wide

You should see two nginx pods with a status of Running. The NODE column shows which worker node each pod landed on.

Pods stuck in Pending or ContainerCreating?

  • Run kubectl describe pod <pod-name> and check the Events section at the bottom for error details.
  • Pending usually means no worker node has enough resources. Confirm both workers are showing Ready in kubectl get nodes.
  • ContainerCreating means the node is pulling the nginx image. Wait a minute and run kubectl get pods -o wide again.
  • My nginx pods won't start. Help me troubleshoot based on the describe output I'm seeing.
Expose the Deployment with a NodePort Service

Your nginx pods are running, but they are only reachable from inside the cluster. Each pod has an internal IP that your Mac cannot access directly.

A Service gives your pods a stable entry point. A NodePort Service opens a port on every node in the cluster and forwards traffic to the pods behind it.

  • Expose your nginx Deployment by running this command:
kubectl expose deployment nginx --type=NodePort --port=80

What does this command do?

  • --type=NodePort tells Kubernetes to assign a random port in the 30000-32767 range on every node.
  • --port=80 is the port the nginx container listens on inside the pod.
  • Traffic hitting any node on the assigned NodePort gets forwarded to port 80 on one of the nginx pods.
  • Find the assigned NodePort by running this command:
kubectl get svc nginx

You should see a table with your nginx Service listed. Look at the PORT(S) column. It shows something like 80:31234/TCP. The number after the colon (e.g. 31234) is your NodePort.

Pro tip: note your NodePort

Write down or copy the NodePort number. You need it in the next substep to access nginx from your browser.

Don't see the Service?

  • Make sure you typed the Deployment name nginx exactly. The name in the expose command must match the Deployment name from the create command.
  • Run kubectl get deployments to confirm the nginx Deployment exists before trying to expose it.
  • My kubectl expose command is failing. Help me troubleshoot.
Access nginx from your Mac

The NodePort Service opens a port on every node in your cluster. That means you can reach nginx by hitting any node's IP address on that port, including your worker nodes.

  • Open a web browser on your Mac.
  • In the address bar, type http:// followed by one of your worker node IPs, a colon, and the NodePort number you noted.
  • For example, if your worker 1 IP is 192.168.1.51 and the NodePort is 31234, navigate to http://192.168.1.51:31234.
  • Press Enter.

You should see the default nginx welcome page with the heading "Welcome to nginx!" in your browser.

What just happened?

Traffic flowed from your Mac's browser to the worker node's IP on the NodePort. Kubernetes then forwarded that traffic through the Service to one of your nginx pods. The pod served the welcome page back to your browser.

This works on any node's IP because the NodePort Service opens the same port on every node in the cluster. Even if the nginx pod is not running on the node you targeted, Kubernetes routes the traffic to the correct pod.

Can't reach the nginx page?

  • Double check you are using http:// and not https://. There is no TLS configured, so HTTPS will not work.
  • Confirm the NodePort number by running kubectl get svc nginx again. Make sure you are using the number after the colon in the PORT(S) column.
  • Make sure your Mac is on the same network as your Proxmox VMs. If you are on Wi-Fi and the VMs are on a different subnet, the request cannot reach them.
  • Run kubectl get pods -o wide to confirm the nginx pods are still in Running status.
  • I can't reach the nginx welcome page from my browser. Help me figure out what's wrong.

Secret mission

Scale and Observe Pod Distribution

Your cluster is running 2 nginx pods. Scale it to 6 and watch Kubernetes spread pods across your worker nodes. Then break things on purpose to see self-healing in action.

Before you go

Clean Up Your Resources

This project runs entirely on your own Proxmox hardware, so there are no ongoing cloud costs. However, the three VMs consume around 6 GB of RAM and 6 vCPUs on your host while running. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • k8s-control VM (ID 110) running the Kubernetes control plane.
  • k8s-worker-1 VM (ID 111) running as a Kubernetes worker node.
  • k8s-worker-2 VM (ID 112) running as a Kubernetes worker node.
  • Kubernetes cluster components (kubeadm, kubelet, kubectl) installed on all three VMs.
  • containerd container runtime installed on all three VMs.
  • Calico CNI and Tigera Operator running on the cluster.
  • nginx Deployment and NodePort Service running on the cluster.
  • Static IP configurations and /etc/hosts entries on each VM.

Keep everything running

No action needed. Choose this if you are still actively building on your cluster or plan to use it for the next project soon.

  • Your three VMs will keep running on your Proxmox host and the Kubernetes cluster will stay active.
  • The VMs use approximately 6 GB of RAM and 6 vCPUs combined. Make sure your host has enough headroom for anything else you want to run.
  • You can access your cluster anytime by SSHing into k8s-control and running kubectl commands.

Pause - I'll come back to this later

Shut down the VMs to free up RAM and CPU on your Proxmox host. Your cluster configuration and data will be preserved on disk.

  • In the Proxmox web UI, select each VM (k8s-control, k8s-worker-1, k8s-worker-2) and click Shutdown.
  • When you are ready to resume, start k8s-control first, then the two worker nodes. The Kubernetes cluster will recover automatically once all three VMs are back online.
  • After restarting, SSH into k8s-control and run kubectl get nodes to verify all three nodes show Ready status.

Delete - I don't want to use this again

Remove all three VMs from your Proxmox host to completely free up resources. Your original VM template (ID 9000) will not be affected.

  • In the Proxmox web UI, shut down each VM first if it is still running.
  • Select k8s-control (VM 110), click More, then Remove. Confirm the deletion.
  • Repeat for k8s-worker-1 (VM 111) and k8s-worker-2 (VM 112).
  • Your VM template (ID 9000) is still available. You can clone new VMs from it anytime you want to rebuild the cluster.

Mission accomplished

That's a Wrap!

You just built a fully functional Kubernetes cluster from scratch on your own hardware. Three VMs, a control plane, two workers, real pod networking, and a live web server accessible from your browser.

Here's what you learned:

  • Bootstrapped a Kubernetes control plane with kubeadm on Proxmox VMs, configured containerd as the container runtime, and joined two worker nodes to form a 3-node cluster.
  • Installed Calico CNI via the Tigera Operator to provide pod-to-pod networking across all nodes.
  • Deployed an nginx web server as a Kubernetes Deployment and exposed it with a NodePort Service, accessible from your Mac's browser.
  • Scaled the deployment to 6 replicas and observed how Kubernetes distributes and self-heals pods across worker nodes.

Ready to quiz yourself?