
Most Kubernetes tutorials assume a clean Ubuntu VM and a lot of luck. Fedora is a different animal. It ships a very new kernel, cgroup v2 by default, SELinux in enforcing mode, firewalld, and swap on zram that quietly reappears after a reboot. Each of those can break a kubeadm bootstrap if you follow a generic guide line by line. The result is a kubelet that crash-loops, a control plane that never comes up, or pods that cannot reach each other.
Fedora 44 was released in late April 2026. It tracks recent kernels, and its security advisories have already covered the 7.0.x and 7.1.x series. That makes it a good fit for a lab, a homelab cluster, or a staging environment where you want modern kernel features such as better cgroup v2 accounting. It is rarely the right choice for a long-lived production control plane, because the release cycle is short and you will be upgrading the OS roughly every year. If you accept that trade-off, the platform is pleasant to work with.
This guide builds a single control-plane cluster with one or more workers using kubeadm, CRI-O as the container runtime, and the official community package repositories. Along the way it explains why each step exists, because understanding the reason is what lets you fix things at 2 AM when a node goes NotReady. A short troubleshooting section, security notes and tuning advice follow the main procedure.
What You Need Before Starting
Plan the hardware and network first. Changing hostnames or IP addresses after bootstrap is painful.
Minimum per node:
- 2 vCPUs (4 is more comfortable for a control plane)
- 2 GB RAM for workers, 4 GB for the control plane in practice
- 20 GB of disk, SSD or NVMe preferred, since etcd is sensitive to fsync latency
- A static IP or a DHCP reservation
- Unique hostname, MAC address and product_uuid on every node
- Fedora 44 Server or Workstation, fully updated
Example lab layout used in this guide:
| Role | Hostname | IP address |
|---|---|---|
| Control plane | k8s-cp1 | 192.168.50.10 |
| Worker | k8s-w1 | 192.168.50.11 |
| Worker | k8s-w2 | 192.168.50.12 |
Cloned VMs are the classic trap. If you cloned a template, the machine-id and product_uuid may be identical across nodes, and the cluster will behave strangely. Check before you proceed:
cat /etc/machine-id
sudo cat /sys/class/dmi/id/product_uuid
ip link
If two nodes match, regenerate the machine-id on the clone:
sudo rm -f /etc/machine-id
sudo systemd-machine-id-setup
Step 1: Update the System and Set Hostnames
Start from a current baseline. A kernel update mid-install is annoying, so get it out of the way now.
sudo dnf upgrade --refresh -y
sudo reboot
Fedora’s own guidance is that dnf update --security covers security-relevant updates including the kernel, but for a fresh node a full upgrade is cleaner.
Set the hostname on each machine:
sudo hostnamectl set-hostname k8s-cp1
Then add all nodes to /etc/hosts on every machine. Relying on DNS you do not control is a common cause of mysterious join failures.
sudo tee -a /etc/hosts <<'EOF'
192.168.50.10 k8s-cp1
192.168.50.11 k8s-w1
192.168.50.12 k8s-w2
EOF
Time sync matters too. Certificates, etcd leader elections and token validity all depend on sane clocks. Fedora ships chrony, so confirm it is running:
sudo systemctl enable --now chronyd
chronyc tracking
Step 2: Disable Swap, Including zram
The kubelet has historically refused to start when swap is active. Newer Kubernetes releases do have a swap feature, but it is a deliberate opt-in with its own configuration, and a default install should still run swap-free so that memory limits and eviction behave predictably.
Here is the Fedora-specific catch. Fedora enables swap on zram out of the box through zram-generator. Running swapoff -a works until the next reboot, then the zram device returns and the kubelet fails. Check what you have:
swapon --show
zramctl
The durable fix is to remove the default zram configuration package and reboot:
sudo dnf remove -y zram-generator-defaults
sudo swapoff -a
sudo reboot
If the system also has a traditional swap partition or file, comment it out in /etc/fstab:
sudo sed -i.bak '/\sswap\s/ s/^/#/' /etc/fstab
After the reboot, swapon --show should print nothing. Verify before moving on, because this single issue accounts for a big share of “kubelet will not start” reports on Fedora.
Step 3: Load Kernel Modules and Set sysctl Parameters
Pod networking relies on bridged traffic being visible to iptables or nftables, and on IP forwarding being on. Two modules are needed: overlay for container filesystems and br_netfilter for bridge filtering.
sudo tee /etc/modules-load.d/k8s.conf <<'EOF'
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
Now the sysctl values:
sudo tee /etc/sysctl.d/99-kubernetes.conf <<'EOF'
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
Confirm they stuck:
lsmod | grep -E 'overlay|br_netfilter'
sysctl net.ipv4.ip_forward net.bridge.bridge-nf-call-iptables
A small observation from running clusters: if ip_forward is 1 at the console but 0 after reboot, something else is overriding it. NetworkManager profiles and leftover files in /etc/sysctl.conf are the usual suspects. Run sudo sysctl --system and read the order in which files are applied.
Step 4: Handle SELinux Properly
Many guides tell you to set SELinux to permissive and move on. That works, but it throws away one of Fedora’s best security features. The kubeadm documentation itself has traditionally recommended permissive mode so containers can access host filesystems such as pod networks, and the Kubernetes packages ship with SELinux support that has matured considerably.
Here is a pragmatic approach. For a lab, use permissive mode and get a working cluster first:
sudo setenforce 0
sudo sed -i 's/^SELINUX=enforcing$/SELINUX=permissive/' /etc/selinux/config
For anything serious, keep enforcing and install container-selinux, which CRI-O’s own instructions list as a required dependency. Watch the audit log for denials when you add storage drivers or hostPath-heavy workloads:
sudo ausearch -m avc -ts recent
The honest advice: bootstrap in permissive, confirm the cluster is healthy, then switch to enforcing and test your actual workloads. If something breaks, you know it is SELinux and not your configuration.
Step 5: Configure the Firewall
Fedora Server and Workstation both run firewalld. Blocking the wrong port gives you symptoms that look like networking bugs: joins that hang, kubectl logs timing out, metrics that never arrive.
Control plane ports:
sudo firewall-cmd --permanent --add-port=6443/tcp
sudo firewall-cmd --permanent --add-port=2379-2380/tcp
sudo firewall-cmd --permanent --add-port=10250/tcp
sudo firewall-cmd --permanent --add-port=10257/tcp
sudo firewall-cmd --permanent --add-port=10259/tcp
Worker node ports:
sudo firewall-cmd --permanent --add-port=10250/tcp
sudo firewall-cmd --permanent --add-port=10256/tcp
sudo firewall-cmd --permanent --add-port=30000-32767/tcp
Flannel’s VXLAN overlay (all nodes):
sudo firewall-cmd --permanent --add-port=8472/udp
sudo firewall-cmd --reload
Port 6443 is the API server, 2379 to 2380 is etcd, and 10250 is the kubelet API. NodePort services use the 30000 to 32767 range. If you pick a different CNI later, its required ports will differ, so check its documentation. Do not expose etcd ports to the wider network. Restrict them with a rich rule or a dedicated zone if your control plane has more than one interface.
Step 6: Add the Kubernetes and CRI-O Repositories
Kubernetes packages come from pkgs.k8s.io, and each minor version has its own repository. The old Google-hosted yum.kubernetes.io repositories were deprecated and frozen in September 2023, and versions released after that date are only available from the new location. If you find an old tutorial pointing at the legacy URL, close the tab.
At the time of writing, the current documentation targets Kubernetes v1.37. Set a variable so you can change it in one place:
export K8S_VERSION=v1.37
export CRIO_VERSION=v1.37
Create the Kubernetes repo. The exclude line is intentional: it stops a routine dnf upgrade from bumping your cluster components to a new minor version unannounced, which could break the control plane.
sudo tee /etc/yum.repos.d/kubernetes.repo <<EOF
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/${K8S_VERSION}/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/${K8S_VERSION}/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni
EOF
Add the CRI-O repo. The CRI-O project publishes through community infrastructure and recommends matching its minor version to your Kubernetes minor version:
sudo tee /etc/yum.repos.d/cri-o.repo <<EOF
[cri-o]
name=CRI-O
baseurl=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/${CRIO_VERSION}/rpm/
enabled=1
gpgcheck=1
gpgkey=https://download.opensuse.org/repositories/isv:/cri-o:/stable:/${CRIO_VERSION}/rpm/repodata/repomd.xml.key
EOF
CRI-O’s repository location has moved over the years, so check the CRI-O packaging README for the current baseurl before pasting. That ten-second check saves a lot of “repomd.xml not found” frustration.
Fedora also carries its own cri-o and kubernetes-* packages, which some admins prefer for tighter distro integration. They lag the upstream project and use different package names (kubernetes-kubeadm, kubernetes-node, kubernetes-client). Mixing the two sources on one node is a bad idea. Pick one lineage and stay with it. This guide uses upstream packages because they track the Kubernetes release schedule and match the official documentation.
Step 7: Install CRI-O and the Kubernetes Components
Why CRI-O instead of containerd or Docker? Docker Engine needs an extra shim to talk to the kubelet, so it is not a natural fit. CRI-O is purpose-built for Kubernetes, has a small footprint and follows Kubernetes’ version numbers. Containerd is equally valid. If your team already runs it elsewhere, use it. The kubeadm steps are the same either way.
Install the prerequisites and packages. Fedora 44 uses DNF5, which changed the flag for temporarily lifting repository excludes:
sudo dnf install -y container-selinux conntrack-tools ethtool iptables socat iproute-tc
sudo dnf install -y cri-o kubelet kubeadm kubectl \
--setopt=disable_excludes=kubernetes
If you copy a command from an older guide that says --disableexcludes=kubernetes, it applies to DNF4 systems, meaning Fedora before 41. On Fedora 44 use the --setopt form shown above. The kubeadm docs also mention adding --setopt=install_weak_deps=False to avoid pulling optional extras, which is handy on minimal nodes.
Enable the runtime and the kubelet:
sudo systemctl enable --now crio
sudo systemctl enable --now kubelet
Do not panic when the kubelet restarts every few seconds right now. It is waiting for instructions from kubeadm, and it will settle down once the node is initialized or joined. Check the runtime though:
sudo systemctl status crio --no-pager
sudo crictl --runtime-endpoint unix:///var/run/crio/crio.sock info | head
Confirm the cgroup driver
Modern Fedora uses systemd and cgroup v2. Both CRI-O and the kubelet must use the systemd cgroup driver, or you will see instability under load. CRI-O defaults to systemd on these systems, and recent kubeadm versions default the kubelet to systemd too. Verify the host:
stat -fc %T /sys/fs/cgroup/
The output should read cgroup2fs. If you ever need to be explicit, drop a file in /etc/crio/crio.conf.d/:
sudo tee /etc/crio/crio.conf.d/02-cgroup.conf <<'EOF'
[crio.runtime]
cgroup_manager = "systemd"
EOF
sudo systemctl restart crio
Step 8: Initialize the Control Plane
Everything so far was preparation. Now run kubeadm on k8s-cp1 only.
A config file beats command-line flags because it is reviewable and reproducible. Create kubeadm-config.yaml:
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
advertiseAddress: 192.168.50.10
bindPort: 6443
nodeRegistration:
criSocket: unix:///var/run/crio/crio.sock
name: k8s-cp1
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: stable
networking:
podSubnet: 10.244.0.0/16
serviceSubnet: 10.96.0.0/12
controlPlaneEndpoint: "k8s-cp1:6443"
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
Pin kubernetesVersion to the exact version you installed (for example the output of kubeadm version -o short) rather than leaving stable. If the installed binaries and the requested control plane image versions drift, kubeadm will warn or fail. Check the schema version too: v1beta4 is current for recent releases, but run kubeadm config print init-defaults to see what your binary expects.
Pre-pull the images, which also validates registry connectivity:
sudo kubeadm config images pull --config kubeadm-config.yaml
Then bootstrap:
sudo kubeadm init --config kubeadm-config.yaml --upload-certs
This takes a couple of minutes. On success, kubeadm prints a kubeadm join command containing a token and a discovery hash. Copy it somewhere safe. Tokens expire after 24 hours by default, but you can regenerate one later with kubeadm token create --print-join-command.
Configure kubectl for your regular user:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
Check the state:
kubectl get nodes
kubectl get pods -A
The node will show NotReady and CoreDNS pods will sit in Pending. That is expected. Without a pod network, there is nothing for them to attach to.
Step 9: Install a Pod Network (CNI)
A cluster without a CNI plugin is just a collection of confused processes. Choose based on need:
| CNI | Best for | Network policy | Notes |
|---|---|---|---|
| Flannel | Labs, small clusters | No (needs add-on) | Simplest, VXLAN overlay |
| Calico | General production | Yes | BGP or VXLAN, mature |
| Cilium | Advanced observability, eBPF | Yes | Needs a recent kernel, which Fedora provides |
For a first build, Flannel is the least fiddly. Apply the manifest from the project’s releases page:
kubectl apply -f https://github.com/flannel-io/flannel/releases/latest/download/kube-flannel.yml
Flannel’s default pod network is 10.244.0.0/16, which is why the podSubnet in the kubeadm config above uses that value. If you picked a different CIDR, download the manifest and edit the net-conf.json block before applying it. A mismatch between the two is a very common reason pods get addresses that cannot route.
Watch the rollout:
kubectl get pods -n kube-flannel -w
kubectl get nodes
Within a minute or two the control plane node flips to Ready and CoreDNS starts.
If network policies matter to you, which they should in any multi-tenant setup, skip Flannel and install Calico or Cilium instead. Retrofitting a CNI later means draining nodes and restarting every pod, so decide now.
Step 10: Join the Worker Nodes
Complete Steps 1 through 7 on each worker. That means swap off, modules loaded, sysctl applied, firewall ports opened, repos configured, and CRI-O plus kubelet and kubeadm installed. You do not need kubectl on workers, though it does no harm.
Then run the join command that kubeadm printed:
sudo kubeadm join k8s-cp1:6443 --token <token> \
--discovery-token-ca-cert-hash sha256:<hash>
If the token expired, regenerate it on the control plane:
kubeadm token create --print-join-command
Verify from the control plane:
kubectl get nodes -o wide
You should see three nodes in Ready state after a short wait. Label the workers so output is easier to read:
kubectl label node k8s-w1 node-role.kubernetes.io/worker=
kubectl label node k8s-w2 node-role.kubernetes.io/worker=
Test with a real workload
A green node list proves little. Deploy something and hit it:
kubectl create deployment web --image=nginx:stable --replicas=3
kubectl expose deployment web --type=NodePort --port=80
kubectl get svc web
Take the NodePort from the output and request it from your workstation:
curl -I http://192.168.50.11:<nodeport>
A 200 response from every node IP confirms that the overlay network, kube-proxy and the firewall rules are all behaving. Also test DNS from inside the cluster:
kubectl run dns-test --rm -it --image=busybox:1.36 --restart=Never -- nslookup kubernetes.default
Troubleshooting Common Fedora Kubernetes Errors
Experience says that roughly 80 percent of failed bootstraps come from the same handful of problems. Work through these in order.
Kubelet keeps crash-looping after kubeadm init
Read the logs first, not forums:
sudo journalctl -u kubelet -n 100 --no-pager
The usual causes are swap being active (look for “running with swap on is not supported”), a cgroup driver mismatch between kubelet and runtime, or a wrong criSocket path. Re-check Step 2 and Step 7.
“[ERROR FileContent–proc-sys-net-bridge-bridge-nf-call-iptables]”
The br_netfilter module is not loaded or the sysctl is unset. Re-run the commands from Step 3. If it returns after reboot, check that /etc/modules-load.d/k8s.conf exists.
Node stays NotReady after installing the CNI
Describe the node and inspect the CNI pods:
kubectl describe node k8s-w1 | sed -n '/Conditions/,/Addresses/p'
kubectl logs -n kube-flannel -l app=flannel --tail=50
Typical culprits are a blocked UDP 8472 on firewalld, a pod CIDR mismatch, or /etc/cni/net.d containing a stale config from a previous attempt. Remove leftover files there when rebuilding.
Pods cannot resolve DNS or reach services
Confirm kube-proxy is running and check CoreDNS logs. On Fedora, firewalld masquerading can get in the way. Enabling it on the zone often resolves it:
sudo firewall-cmd --permanent --add-masquerade
sudo firewall-cmd --reload
If the cluster worked until a reboot, suspect firewalld reloading and dropping interface assignments, or NetworkManager grabbing CNI interfaces. A drop-in file in /etc/NetworkManager/conf.d/ marking flannel*, cali* and cilium* interfaces as unmanaged prevents that:
sudo tee /etc/NetworkManager/conf.d/k8s-cni.conf <<'EOF'
[keyfile]
unmanaged-devices=interface-name:flannel*;interface-name:cni*;interface-name:cali*;interface-name:vxlan.calico;interface-name:cilium*
EOF
sudo systemctl reload NetworkManager
“repomd.xml not found” or GPG errors when installing
Refresh metadata with sudo dnf clean all && sudo dnf makecache, a fix that has been needed since the earliest pkgs.k8s.io rollout. Then double check that the minor version in your baseurl exists. Version strings must be written like v1.37, not 1.37.
Image pull failures
CRI-O pulls from registries using containers-common policy files. If you sit behind a proxy, set it through a systemd drop-in for crio.service, not just your shell environment:
sudo systemctl edit crio
[Service]
Environment="HTTP_PROXY=http://proxy.example.local:3128"
Environment="HTTPS_PROXY=http://proxy.example.local:3128"
Environment="NO_PROXY=localhost,127.0.0.1,10.96.0.0/12,10.244.0.0/16,192.168.50.0/24,.svc,.cluster.local"
SELinux denials
When enforcing is on and a pod fails with permission errors on a mounted path, look at ausearch -m avc, and relabel the host directory with chcon -Rt container_file_t /path or a proper semanage fcontext rule. Disabling SELinux to make an error vanish is the lazy answer. Fix the label.
Starting over cleanly
Sometimes the fastest route is a reset:
sudo kubeadm reset -f
sudo rm -rf /etc/cni/net.d $HOME/.kube
sudo systemctl restart crio
Reboot after that to clear iptables or nftables state left behind by kube-proxy.