← Back to the notebook
Kubernetes4 Nov 20266 min read

Kubernetes on One Box, Part 1: Decide Before You Install

Pin compatible versions, choose Pod and Service CIDRs, and give the API server a stable name before kubeadm ever runs.

This series builds a three-node kubeadm cluster (one control plane, two workers) as VMs on a single mini PC, entirely with Ansible. By the end you have a cluster running Cilium that you’ve tested for cross-node networking, NetworkPolicy enforcement and worker drains.

  1. Decide before you install: versions, networks, the API endpoint (this post)
  2. Prepare the nodes: kernel, swap, containerd, kubeadm packages
  3. Bootstrap the cluster: kubeadm init, Cilium, worker joins
  4. Prove it works: connectivity, policy, drains, capacity

This first part installs nothing. kubeadm writes your versions, address ranges and API endpoint into certificates and cluster config, so changing them later is expensive. You decide them up front and record them as Ansible data that every later step reads.

The lab

One physical host (16 GB RAM, 1 TB SSD) runs Debian 13 with QEMU/KVM and libvirt. The three Kubernetes VMs sit on libvirt’s default NAT network:

           Admin Mac
               │  SSH (alias "homelab")
               ▼
   ┌───────────────── homelab-01 (Debian 13, KVM) ─────────────────┐
   │   virbr0  192.168.122.0/24  (libvirt NAT)                      │
   │                                                                │
   │   k8s-cp-01        k8s-worker-01        k8s-worker-02          │
   │   .10  2 vCPU      .11  2 vCPU          .12  4 vCPU            │
   │        3 GiB            4 GiB                5 GiB             │
   └────────────────────────────────────────────────────────────────┘

The guests aren’t reachable from the LAN directly. Everything (Ansible, SSH, kubectl) goes through the host as a jump box. homelab is an SSH alias on the Mac that points at the hypervisor.

Step 1: Pin versions that are known to work together

Component Version
Kubernetes (kubeadm, kubelet, kubectl) v1.36.5, APT package 1.36.5-1.1
containerd 2.3.6
Cilium 1.20.2

Choose them in this order:

  1. Start from the CNI. Cilium 1.20.2’s tested matrix covers Kubernetes 1.33–1.36. That rules out a newer 1.37 even if one exists.
  2. Pick the newest supported Kubernetes patch in that range. 1.36 is supported upstream until mid-2027.
  3. Pick a runtime line that provides CRI v1. containerd 2.3 is the current LTS line.

Every version is an exact pin. Never use latest.

Package policy

Kubernetes APT repositories on pkgs.k8s.io are per minor version:

https://pkgs.k8s.io/core:/stable:/v1.36/deb/

So apt upgrade can’t move you to 1.37. To stop it changing patch versions too, hold the packages:

sudo apt-mark hold kubeadm kubelet kubectl

The upgrade procedure, when you choose to do one:

  1. Read the version-skew policy and Cilium’s compatibility notes.
  2. Update the pins in inventory.
  3. Unhold the packages.
  4. Upgrade kubeadm first, then the control plane, then each worker, draining one at a time.
  5. Hold the packages again.

Cilium upgrades are separate, pinned Helm changes.

Step 2: Choose address ranges that can’t collide

Network CIDR
Home LAN 192.168.1.0/24
libvirt NAT (nodes) 192.168.122.0/24
Pods 10.244.0.0/16
Services 10.96.0.0/12 (cluster DNS at 10.96.0.10)

The Pod and Service ranges must not overlap anything the nodes or your admin machine can route to, and that includes VPNs. Check the route tables on both ends:

netstat -rn -f inet     # macOS
ip route                # the Linux host

Nothing should fall inside 10.244.0.0/16 or 10.96.0.0/12. Record the result in inventory as vpn_cidrs_verified. The bootstrap playbook in part 3 won’t run until it’s true.

Step 3: Give the API server a stable name

kubeadm writes controlPlaneEndpoint into every kubeconfig, every join and the API server certificate. If that’s a bare IP, adding a load balancer or a second control plane later means reissuing certificates. Use a name from day one, even with a single control plane:

Setting Value
API hostname k8s-api.home.arpa (.home.arpa is reserved for home networks, RFC 8375)
controlPlaneEndpoint k8s-api.home.arpa:6443
Initially resolves to 192.168.122.10 (k8s-cp-01)
Extra cert SAN k8s-api.home.arpa

How each side resolves it:

  • Nodes get a managed /etc/hosts line, 192.168.122.10 k8s-api.home.arpa. Part 2’s playbook adds it.

  • The admin Mac can’t route to 192.168.122.0/24, so it uses an SSH tunnel through the host:

    ssh -N -J kasi@homelab -L 6443:192.168.122.10:6443 kasi@192.168.122.10
    

    Map k8s-api.home.arpa to 127.0.0.1 in the Mac’s /etc/hosts and keep the kubeconfig server as https://k8s-api.home.arpa:6443. TLS still verifies, because the certificate carries that name.

When private DNS or a load balancer arrives later, only the name-to-address mapping changes. The certificates and kubeconfigs stay the same.

Step 4: Write down the traffic the cluster needs

These should only be allowed between nodes:

Port Proto Purpose
6443 TCP Kubernetes API
2379–2380 TCP etcd (control-plane peers and the API server only)
10250 TCP kubelet API
10256 TCP kube-proxy health
4240 TCP Cilium health checks
8472 UDP Cilium VXLAN overlay
— ICMP Cilium health checks

Administrative access stays private: SSH to the host from the LAN, the API over the SSH tunnel, and Node Exporter (9100) only from the node network. Nothing is exposed to the internet.

Step 5: Record it all as Ansible data

Two files hold the decisions. The first is the inventory of the three guests, inventory/k8s-nodes.yml:

all:
  children:
    k8s_cluster:
      hosts:
        k8s-cp-01:     { ansible_host: 192.168.122.10, node_ip: 192.168.122.10, node_role: control_plane, node_memory_mb: 3072 }
        k8s-worker-01: { ansible_host: 192.168.122.11, node_ip: 192.168.122.11, node_role: worker,        node_memory_mb: 4096 }
        k8s-worker-02: { ansible_host: 192.168.122.12, node_ip: 192.168.122.12, node_role: worker,        node_memory_mb: 5120 }
      vars:
        ansible_user: kasi
        ansible_become: true
        ansible_ssh_common_args: '-o ProxyJump=kasi@homelab'

It contains only the guests, so a node playbook can never touch the hypervisor. ansible.cfg keeps host_key_checking = True.

The second is the cluster settings, inventory/group_vars/k8s_cluster.yml (excerpt):

kubernetes_version: 'v1.36.5'
kubernetes_package_version: '1.36.5-1.1'
kubernetes_apt_repository: 'https://pkgs.k8s.io/core:/stable:/v1.36/deb/'
kubernetes_apt_key_url: 'https://pkgs.k8s.io/core:/stable:/v1.36/deb/Release.key'
kubernetes_package_hold: true

containerd_version: '2.3.6'
containerd_release: 'containerd-2.3.6-linux-amd64.tar.gz'
containerd_download_url: 'https://github.com/containerd/containerd/releases/download/v2.3.6/containerd-2.3.6-linux-amd64.tar.gz'
containerd_sha256: 'df806bb9b86653ebc94822a7076642ff3c3614324dbc1b511bdf75ac1669b762'
containerd_cri_socket: unix:///run/containerd/containerd.sock

cilium_version: '1.20.2'
cilium_chart: oci://quay.io/cilium/charts/cilium

kubernetes_api_hostname: k8s-api.home.arpa
kubernetes_control_plane_endpoint: "{{ kubernetes_api_hostname }}:6443"
kubernetes_api_initial_address: 192.168.122.10
kubernetes_api_port: 6443
kubernetes_api_extra_sans: ["{{ kubernetes_api_hostname }}"]

pod_cidr: 10.244.0.0/16
service_cidr: 10.96.0.0/12
cluster_dns_service_ip: 10.96.0.10
vpn_cidrs_verified: true   # set only after the route check in step 2

Step 6: Template the kubeadm configuration

templates/kubeadm-config.yaml.j2 turns that data into kubeadm’s config file. It uses the v1beta4 API, where kubeletExtraArgs is a list of name/value pairs:

apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: "{{ node_ip }}"
  bindPort: {{ kubernetes_api_port }}
nodeRegistration:
  criSocket: "{{ containerd_cri_socket }}"
  kubeletExtraArgs:
    - name: node-ip
      value: "{{ node_ip }}"
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: "{{ kubernetes_version }}"
controlPlaneEndpoint: "{{ kubernetes_control_plane_endpoint }}"
apiServer:
  certSANs:
{% for san in kubernetes_api_extra_sans %}
    - "{{ san }}"
{% endfor %}
networking:
  podSubnet: "{{ pod_cidr }}"
  serviceSubnet: "{{ service_cidr }}"
  dnsDomain: cluster.local

Setting node-ip explicitly makes kubelet advertise its address on the node network, rather than whatever interface it happens to pick.

Step 7: Render and validate before you need it

Preview the rendered file for the control plane without connecting to it. Run this from the Ansible directory:

ansible-inventory -i inventory/k8s-nodes.yml --graph
ansible -i inventory/k8s-nodes.yml k8s-cp-01 -m debug \
  -a "msg={{ lookup('template', 'templates/kubeadm-config.yaml.j2') }}"

Once kubeadm is installed (after part 2), kubeadm can check the file strictly:

sudo kubeadm config validate --config /etc/kubernetes/kubeadm-config.yaml

What you have now

  • One file of facts that every playbook reads: versions, checksums, CIDRs, the endpoint and ports.
  • A kubeadm template that turns those facts into cluster config.
  • An API name that will survive whatever you put in front of it later.

Next, in part 2, one idempotent playbook gets the three VMs ready for kubeadm.

— END OF NOTE —Explore more notes ↗