Spinning up Kubernetes on your own hardware is the cheapest way to learn the cluster internals that managed services like EKS or GKE hide — and for some workloads (data residency, GPUs, edge sites) bare metal is the only option. This guide walks through a production-shaped install of Kubernetes on bare-metal Fedora using kubeadm, fully updated for 2026.
If you followed an older version of this article, almost everything has changed: Dockershim was removed back in Kubernetes 1.24, the old packages.cloud.google.com apt/yum repos were retired in 2023, and you no longer hand-configure etcd, kube-apiserver and friends as separate systemd units. kubeadm now bootstraps the entire control plane for you.
What you'll build
- A control-plane node and one or more worker nodes, all running Fedora.
- containerd as the CRI container runtime — no Docker Engine.
- A pod network via a CNI plugin (Calico in this guide).
- The bare-metal extras a cloud would normally hand you: MetalLB for
LoadBalancerservices, ingress-nginx for HTTP routing, and a storage provisioner.
Run every command as root or with sudo.
Prerequisites
- 2 or more Fedora machines (physical or VM). Fedora 41/42 or newer is assumed; the steps are version-agnostic. One node is the control plane, the rest are workers.
- 2 GB+ RAM and 2 vCPUs on the control-plane node (the kubeadm minimum) — more for real workloads.
- A unique hostname, MAC address and
product_uuidper node; clones of the same VM image fail to join. - Full network connectivity between nodes, with a static IP or DHCP reservation for each.
- Sudo/root on every node.
Set a clear hostname on each box so kubectl get nodes stays readable:
# On the control plane
sudo hostnamectl set-hostname k8s-cp-1
# On each worker
sudo hostnamectl set-hostname k8s-worker-1
# Optional: make nodes resolvable without DNS (run on every node)
echo "192.168.1.10 k8s-cp-1" | sudo tee -a /etc/hosts
echo "192.168.1.11 k8s-worker-1" | sudo tee -a /etc/hostsStep 1: Prepare every node (swap, kernel modules, sysctl)
Run this section on all nodes, control plane and workers alike.
Disable swap
The kubelet still expects swap to be off, and kubeadm runs a preflight check that fails if it isn't. Kubernetes does have beta swap support (the NodeSwap feature), but the default behaviour does not use swap, so turning it off remains the reliable path:
# Turn swap off now
sudo swapoff -a
# Fedora enables zram-backed swap by default; remove it so swap stays off
sudo dnf remove -y zram-generator-defaults || true
# Comment any remaining swap entry so it stays off across reboots
sudo sed -i '/ swap / s/^/#/' /etc/fstabLoad kernel modules and set sysctls
Kubernetes networking needs the overlay and br_netfilter modules, plus a few sysctl values so bridged traffic is seen by iptables and the node can forward packets:
# Load the required modules now and on every boot
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
# Networking sysctls required by the kubelet and CNI
cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --systemStep 2: SELinux and the firewall
Fedora ships SELinux in enforcing mode and runs firewalld by default — both will block a fresh cluster if left untouched.
SELinux
For a lab, set SELinux to permissive so the kubelet and CNI can write where they need to. For production, keep it enforcing and add the right policies and labels instead of disabling it:
# Lab shortcut: set SELinux permissive (survives reboot)
sudo setenforce 0
sudo sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/configFirewall ports
Open the control-plane and worker ports in firewalld. Stopping firewalld entirely is the quickest lab option, but opening only the required ports is safer:
# --- On the control-plane node ---
sudo firewall-cmd --permanent --add-port=6443/tcp # Kubernetes API server
sudo firewall-cmd --permanent --add-port=2379-2380/tcp # etcd server/peer
sudo firewall-cmd --permanent --add-port=10250/tcp # kubelet API
sudo firewall-cmd --permanent --add-port=10257/tcp # kube-controller-manager
sudo firewall-cmd --permanent --add-port=10259/tcp # kube-scheduler
# --- On every worker node ---
sudo firewall-cmd --permanent --add-port=10250/tcp # kubelet API
sudo firewall-cmd --permanent --add-port=30000-32767/tcp # NodePort services
# --- Calico CNI (all nodes): BGP + VXLAN ---
sudo firewall-cmd --permanent --add-port=179/tcp
sudo firewall-cmd --permanent --add-port=4789/udp
sudo firewall-cmd --reloadStep 3: Install the containerd runtime
Dockershim was removed in Kubernetes 1.24, so the cluster talks to a CRI-compatible runtime directly. We use containerd (CRI-O is an equally good, Fedora-friendly alternative). Do not install Docker Engine as the runtime. Run this on every node:
sudo dnf install -y containerd
# Generate a default config and enable the systemd cgroup driver. On cgroup v2
# (Fedora's default) this must match the kubelet's cgroup driver.
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml >/dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl enable --now containerd
sudo systemctl restart containerdStep 4: Install kubeadm, kubelet and kubectl
Install the tools from the official pkgs.k8s.io community repository. The old packages.cloud.google.com / apt.kubernetes.io repos were retired in 2023 and no longer receive updates — do not use them.
The repo is pinned to a single minor version. Replace v1.34 below with the current stable minor at the time you install (check kubernetes.io for the latest):
# Add the pkgs.k8s.io repo (pinned to v1.34 — change to the current stable minor)
cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.34/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.34/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF
# Install, allowing the excluded packages just for this transaction
sudo dnf install -y kubelet kubeadm kubectl --disableexcludes=kubernetes
# Enable the kubelet; it will crash-loop until kubeadm runs (this is expected)
sudo systemctl enable --now kubeletStep 5: Initialize the control plane
Run kubeadm init only on the control-plane node. The --pod-network-cidr must match what your CNI expects; 192.168.0.0/16 is Calico's default. If your LAN already uses 192.168.x.x, pick a non-overlapping range and adjust the CNI config to match.
sudo kubeadm init --pod-network-cidr=192.168.0.0/16
# Configure kubectl for your normal (non-root) user
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
# Sanity check (the node stays NotReady until the CNI is installed)
kubectl get nodeskubeadm init prints a kubeadm join ... command containing a token and the CA hash — copy it, you'll need it for the workers. Join tokens expire after 24 hours; generate a fresh one later with kubeadm token create --print-join-command.
Step 6: Install a CNI (pod network)
Until a CNI is installed the nodes stay NotReady and CoreDNS is stuck Pending. We use Calico; Flannel (simpler) and Cilium (eBPF, more powerful) are popular alternatives — you only install one. Install Calico via its operator on the control-plane node, substituting the latest Calico release for the version shown:
# Install the Tigera operator (replace v3.29.0 with the latest Calico release)
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.0/manifests/tigera-operator.yaml
# Install Calico itself. Its default install uses 192.168.0.0/16,
# matching the --pod-network-cidr we passed to kubeadm.
kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.29.0/manifests/custom-resources.yaml
# Watch the nodes flip to Ready (Ctrl-C when done)
kubectl get nodes -wStep 7: Join the worker nodes
On each worker, run the kubeadm join command that kubeadm init printed. It looks like this (your token and hash will differ):
sudo kubeadm join 192.168.1.10:6443 \
--token abcdef.0123456789abcdef \
--discovery-token-ca-cert-hash sha256:<hash-from-kubeadm-init>Back on the control-plane node, confirm every worker shows up and reaches Ready:
kubectl get nodes -o wide
kubectl get pods -ARunning a single-node cluster?
By default the control plane is tainted so no normal workloads land on it. For an all-in-one lab box, remove the taint so pods can schedule there:
kubectl taint nodes --all node-role.kubernetes.io/control-plane-Bare-metal essentials: load balancing, ingress and storage
On a cloud, a Service of type LoadBalancer and a PersistentVolumeClaim just work. On bare metal there is no cloud controller to fulfil them, so you provide the pieces yourself.
MetalLB for LoadBalancer services
MetalLB hands out real IPs from a pool on your LAN to LoadBalancer services. Install it, then give it a range of free addresses on your network:
# Install MetalLB (replace v0.14.9 with the latest release)
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/v0.14.9/config/manifests/metallb-native.yaml
# Wait for the controller to be ready
kubectl wait --namespace metallb-system \
--for=condition=ready pod \
--selector=app=metallb \
--timeout=120sThen configure an address pool with IPs that are free on your LAN (and outside your DHCP range), plus an L2 advertisement so MetalLB answers ARP for them:
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: lan-pool
namespace: metallb-system
spec:
addresses:
- 192.168.1.240-192.168.1.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: lan-l2
namespace: metallb-system
spec:
ipAddressPools:
- lan-poolApply that with kubectl apply -f metallb-pool.yaml. Now any LoadBalancer service gets an IP from lan-pool.
Ingress and storage
Add ingress-nginx for host- and path-based HTTP routing, and a storage provisioner so PersistentVolumeClaims bind automatically. Rancher's local-path-provisioner is perfect for a single node; for replicated, node-failure-tolerant volumes use Longhorn or Rook/Ceph:
# ingress-nginx (replace controller-v1.12.0 with the latest release)
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.12.0/deploy/static/provider/baremetal/deploy.yaml
# With MetalLB present you can switch the controller to a LoadBalancer service:
kubectl patch svc ingress-nginx-controller -n ingress-nginx \
-p '{"spec": {"type": "LoadBalancer"}}'
# Storage: local-path-provisioner (replace v0.0.31 with the latest release)
kubectl apply -f https://raw.githubusercontent.com/rancher/local-path-provisioner/v0.0.31/deploy/local-path-storage.yaml
# Make it the cluster default StorageClass
kubectl patch storageclass local-path \
-p '{"metadata": {"annotations": {"storageclass.kubernetes.io/is-default-class": "true"}}}'Verify the cluster with a test workload
Deploy something real to prove networking, ingress and load balancing all work end to end:
kubectl create deployment hello --image=nginx
kubectl expose deployment hello --port=80 --type=LoadBalancer
kubectl get svc hello # note the EXTERNAL-IP MetalLB assigned
curl http://<external-ip>Troubleshooting tips
- Nodes stuck
NotReady: the CNI isn't healthy. Checkkubectl get pods -n kube-systemandkubectl get pods -n calico-system. kubeadm initpreflight fails on swap: re-runsudo swapoff -aand confirm/etc/fstaband zram are clean.- kubelet crash-looping before
init: that's normal — it settles oncekubeadm init(orjoin) configures it. Watch withjournalctl -u kubelet -f. - Cgroup driver mismatch: make sure
SystemdCgroup = truein containerd's config; it must match the kubelet, which usessystemdby default on Fedora's cgroup v2.
Where to go next
Once the cluster is up, containerise your apps and ship them onto it. If you're coming from Docker-only deployments, these walkthroughs help:
- Clustering Docker containers with Docker Swarm — how the simpler orchestrator compares.
- Deploy a Django project into a Docker container — build the image you'll run as a pod.
- Deploying a WordPress blog with Django using Docker containers — a multi-container example to lift onto Kubernetes.
Running Kubernetes on bare metal is rewarding but operationally heavy. If you'd rather hand off cluster setup, hardening and 24/7 operations — on-prem or in the cloud — MicroPyramid's cloud migration services team can help.
Frequently Asked Questions
Do I need to install Docker to run Kubernetes on Fedora?
No. Kubernetes removed Dockershim in version 1.24, so it talks to a CRI runtime directly. Install containerd (or CRI-O) instead. You can still build images with Docker or Podman elsewhere — the cluster just doesn't use Docker Engine to run them.
Why do I have to disable swap?
The kubelet's resource accounting assumes swap is off, and kubeadm runs a preflight check that fails if it's on. Kubernetes has beta swap support (the NodeSwap feature), but it isn't used by default, so disabling swap — including Fedora's default zram swap — remains the dependable choice.
Which CNI plugin should I choose?
For most bare-metal clusters Calico is a solid default: network policy support, mature, well documented. Pick Flannel if you want the simplest possible overlay, or Cilium if you need eBPF-based performance and observability. You install only one.
How do I get LoadBalancer services without a cloud provider?
Use MetalLB. It assigns IPs from a pool you define on your LAN to LoadBalancer services using either L2 (ARP) or BGP. Without it, LoadBalancer services stay Pending forever on bare metal.
Can I run a single-node Kubernetes cluster on one Fedora machine?
Yes. Follow the same steps but skip the worker join, then remove the control-plane taint with kubectl taint nodes --all node-role.kubernetes.io/control-plane- so workloads can schedule on it. It's great for labs; use multiple nodes for anything you can't afford to lose.
Which Kubernetes version should I install?
Install the current stable minor from the pkgs.k8s.io repo and pin it (the repo serves one minor at a time). Stay within one or two minors of the latest release so you keep receiving security patches, and read the release notes before upgrading, since each minor can remove deprecated APIs.