Install Kubernetes on Bare-Metal Fedora with kubeadm

Blog / Server Management · January 2, 2021 · Updated June 10, 2026 · 9 min read
Install Kubernetes on Bare-Metal Fedora with kubeadm

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 LoadBalancer services, 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_uuid per 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/hosts

Step 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/fstab

Load 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 --system

Step 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/config

Firewall 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 --reload

Step 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 containerd

Step 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 kubelet

Step 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 nodes

kubeadm 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 -w

Step 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 -A

Running 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=120s

Then 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-pool

Apply 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. Check kubectl get pods -n kube-system and kubectl get pods -n calico-system.
  • kubeadm init preflight fails on swap: re-run sudo swapoff -a and confirm /etc/fstab and zram are clean.
  • kubelet crash-looping before init: that's normal — it settles once kubeadm init (or join) configures it. Watch with journalctl -u kubelet -f.
  • Cgroup driver mismatch: make sure SystemdCgroup = true in containerd's config; it must match the kubelet, which uses systemd by 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:

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.

Share this article