
Fedora 44 is one of those distributions that gets treated like a toy in production conversations, and that reputation is undeserved. Anyone who has spent real time inside /etc/sysconfig and journalctl on a Fedora box knows the kernel is fresh, SELinux is mature, and the package cadence keeps you closer to upstream Kubernetes than most “enterprise” distros ever will. The catch is that Fedora also moves fast enough to break tutorials written six months ago — cgroup defaults shift, SELinux policies get stricter, and the kubeadm packaging inside Fedora’s own repos doesn’t always match what you’d expect from the official Kubernetes documentation.
That gap is exactly where most sysadmins get stuck. You follow a guide written for Ubuntu or CentOS, hit an SELinux denial you’ve never seen before, and spend two hours chasing an AVC message instead of provisioning your cluster. Or you install containerd from the Fedora repo, forget to flip SystemdCgroup to true, and watch kubelet crash-loop with a cryptic cgroup driver mismatch.
This guide walks through installing Kubernetes on Fedora 44 the way you’d actually do it on a real box — whether that’s a single-node lab cluster for testing manifests, or the first control-plane node of a bare-metal production deployment. We’ll cover both container runtime paths that matter on Fedora: containerd (the path most guides assume) and CRI-O (the runtime Fedora’s own package maintainers optimize for and ship natively). We’ll also get into the parts most tutorials skip entirely — SELinux context handling, cgroup v2 quirks, firewall rules that don’t just nuke firewalld outright, and the troubleshooting steps you actually need when kubeadm init hangs at “waiting for control plane.”
By the end, you’ll have a working single-node or multi-node kubeadm cluster on Fedora 44, know why each step exists, and have a mental checklist for the failure modes that trip people up in the first 48 hours after standing up a new cluster.
Before You Start: Planning the Environment
Kubernetes isn’t picky about hardware, but it is picky about consistency. Every node in the cluster needs a unique hostname, a unique MAC address, and — this one bites people on cloned VM templates constantly — a unique product_uuid. If you cloned your Fedora 44 VMs from a single template without regenerating machine identifiers, kubeadm will happily let you join two nodes with identical UUIDs and then give you baffling scheduling and networking errors weeks later.
Check it before anything else:
sudo dmidecode -s system-uuid
cat /sys/class/dmi/id/product_uuid
cat /etc/machine-id
If two nodes match, regenerate the machine ID:
sudo rm /etc/machine-id
sudo systemd-machine-id-setup
Minimum resource baseline
For a control-plane node, don’t go below 2 vCPUs and 2GB RAM — kubeadm’s preflight checks will actually block you if you try. In practice, give the control plane 4GB+ RAM once you start running more than a handful of pods, because etcd and kube-apiserver are not shy about memory usage under load. Worker nodes scale according to workload, but 2 vCPU / 4GB is a sane floor for anything beyond a toy deployment.
Network topology matters more than people admit. Pod CIDR, service CIDR, and your node network must not overlap. A common mistake: node subnet is 10.0.0.0/24, and someone picks 10.244.0.0/16 for pods without checking that it doesn’t collide with an existing VPN range or another cluster on the same network. Sketch this out on paper before you type a single dnf install.
Step 1: Prepare Every Node
These steps run on every node — control plane and workers alike. Skipping this on even one worker is the single most common reason clusters fail to join cleanly.
Update the system and set hostnames
sudo dnf update -y
sudo hostnamectl set-hostname k8s-master01
Use meaningful, unique hostnames per node (k8s-master01, k8s-worker01, k8s-worker02). Add them to /etc/hosts on all nodes if you don’t have internal DNS resolving them reliably — Kubernetes leans on hostname resolution more than people expect, and a flaky DNS setup will manifest as intermittent node “NotReady” flapping that’s miserable to debug.
Disable swap
Kubelet refuses to play nice with active swap by default (you can configure it to tolerate swap since 1.22+ with NodeSwap feature gates, but don’t bother for a first install — it adds complexity without benefit for most workloads).
sudo swapoff -a
sudo sed -i '/\bswap\b/s/^/#/' /etc/fstab
Fedora 44’s default install often ships with zram swap instead of a disk-backed swap partition. That’s a different beast — check for it explicitly:
lsblk | grep zram
If you see a zram device mounted as swap, disable the generator entirely rather than just running swapoff, or it’ll reappear after reboot:
sudo systemctl mask systemd-zram-setup@zram0.service
sudo dnf remove -y zram-generator-defaults
Load required kernel modules
Kubernetes networking depends on bridge netfilter and overlay filesystem support. Fedora 44’s kernel has both, but they’re not always loaded by default.
cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
EOF
sudo modprobe overlay
sudo modprobe br_netfilter
Configure sysctl for networking
cat <<EOF | sudo tee /etc/sysctl.d/99-kubernetes-cri.conf
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
EOF
sudo sysctl --system
That net.ipv4.ip_forward flag is the one people forget when they’ve hand-tuned sysctl before for something else, then wonder why pod-to-pod traffic across nodes silently drops.
Firewall: don’t just nuke it
Plenty of tutorials tell you to systemctl disable --now firewalld and move on. That’s fine for a disposable lab VM. It’s a bad habit to build for anything you plan to run in production, because firewalld is doing real work protecting the host, and turning it off entirely means every port on the box is wide open the moment you plug it into a real network.
For a control-plane node, open exactly what’s needed:
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=10259/tcp
sudo firewall-cmd --permanent --add-port=10257/tcp
sudo firewall-cmd --reload
For a worker node:
sudo firewall-cmd --permanent --add-port=10250/tcp
sudo firewall-cmd --permanent --add-port=30000-32767/tcp
sudo firewall-cmd --reload
If you’re running a CNI plugin that needs VXLAN (Flannel) or BGP (Calico), you’ll need additional ports — 8472/udp for Flannel VXLAN, 179/tcp for Calico BGP. Check your CNI’s documentation before you assume connectivity issues are a Kubernetes bug when they’re actually a firewall rule you forgot.
Step 2: Install a Container Runtime
Since Kubernetes deprecated dockershim, you need a CRI-compliant runtime. On Fedora 44 you have two realistic choices: containerd (the ecosystem default, matches almost every third-party guide and Helm chart assumption) or CRI-O (built specifically for Kubernetes, maintained with tight version alignment to upstream k8s releases, and the runtime Fedora’s own Kubernetes packages are tuned around).
Option A: containerd (most common path)
sudo dnf install -y containerd
sudo mkdir -p /etc/containerd
containerd config default | sudo tee /etc/containerd/config.toml
Now edit the cgroup driver. This single line is responsible for more failed kubeadm init runs than anything else on this list:
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
Why does this matter so much? Fedora 44 runs systemd as its cgroup manager by default (cgroups v2, unified hierarchy). If containerd is still using the cgroupfs driver while kubelet expects systemd, you get two different processes fighting over resource accounting on the same host — the symptom is usually a kubelet that starts, then dies, with vague messages about cgroup mismatches in journalctl -u kubelet.
sudo systemctl restart containerd
sudo systemctl enable containerd
Option B: CRI-O (Fedora-native path)
CRI-O is version-locked to Kubernetes minor releases, so match the version you intend to install. Fedora 44’s repos carry versioned packages like cri-o1.35:
sudo dnf install -y cri-o1.35 crun
sudo systemctl enable --now crio
CRI-O defaults to systemd cgroups on Fedora already, which is one reason it’s the friction-free option here — no manual config-file surgery required. If you’re building anything resembling a bare-metal production cluster on Fedora and don’t have an existing reason to prefer containerd (existing tooling, Helm charts, etc.), CRI-O is genuinely the path of least resistance on this particular distro.
Step 3: Add the Kubernetes Repository and Install kubeadm, kubelet, kubectl
The official Kubernetes project moved its package hosting to pkgs.k8s.io, split by minor version. Pick a version and stick with it across every node in the cluster — mismatched minor versions between control plane and workers is asking for subtle scheduling bugs.
cat <<EOF | sudo tee /etc/yum.repos.d/kubernetes.repo
[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/v1.31/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/v1.31/rpm/repodata/repomd.xml.key
EOF
sudo dnf install -y kubelet kubeadm kubectl
sudo systemctl enable kubelet
Swap v1.31 for whatever current stable minor version you’re targeting — check the Kubernetes releases page before you commit, since the repo path is version-specific and older paths eventually stop receiving security patches.
If you went the CRI-O route in Step 2 with Fedora’s native kubernetes1.35 packages instead, the install looks slightly different because Fedora ships its own kubeadm build aligned to that same version:
sudo dnf install -y kubernetes1.35 kubernetes1.35-kubeadm kubelet kubernetes1.35-client iproute-tc container-selinux
sudo systemctl enable kubelet
Either path gets you a working kubeadm, kubelet, and kubectl. The difference is purely about which upstream you’re tracking — the Kubernetes project’s own repo, or Fedora’s package maintainers. For long-term production use, lean toward the official pkgs.k8s.io repo simply because patch releases land faster there, and CVE turnaround on kubelet/kubeadm matters more than most people budget for.
Step 4: SELinux — Don’t Disable It, Configure It
Setting SELinux=permissive is the fastest way to make a Kubernetes tutorial work on the first try, and it’s also how you end up explaining a preventable breach in a postmortem six months later. Fedora 44 ships SELinux in enforcing mode by default, and Kubernetes on RHEL-family distros has had working SELinux support for years — you just have to install the right policy package.
sudo dnf install -y container-selinux
If you hit an AVC denial (check with sudo ausearch -m avc -ts recent), the fix is almost always to generate a targeted policy module rather than flip enforcement off wholesale:
sudo ausearch -c 'kubelet' --raw | audit2allow -M k8s-kubelet-policy
sudo semodule -i k8s-kubelet-policy.pp
A specific, known issue on newer kernels: iptables rules interacting with cgroup directories can trigger a denial on iptables_t accessing cgroup_t with ioctl. If you see that exact pattern, this custom module resolves it cleanly:
cat > k8s.te <<'EOF'
module k8s 1.0;
require {
type cgroup_t;
type iptables_t;
class dir ioctl;
}
allow iptables_t cgroup_t:dir ioctl;
EOF
checkmodule -m -M -o k8s.mod k8s.te
semodule_package --outfile k8s.pp --module k8s.mod
sudo semodule -i k8s.pp
Keep this module in your infrastructure-as-code repo. It’s a small thing, but it’s the difference between rebuilding a node in five minutes versus chasing the same AVC denial from memory a year later.
Step 5: Initialize the Control Plane
On the node you’ve designated as control plane:
sudo kubeadm init --pod-network-cidr=10.244.0.0/16 --cri-socket=unix:///run/containerd/containerd.sock
Adjust --cri-socket if you went with CRI-O instead — it’ll be unix:///var/run/crio/crio.sock. The pod CIDR value depends on which CNI you’re deploying next; 10.244.0.0/16 is Flannel’s expected default, while Calico typically wants 192.168.0.0/16 unless you override it in the Calico manifest to match.
This command takes a few minutes. It’s pulling control-plane images, generating certificates, and bootstrapping etcd. When it finishes, you’ll get a join command printed to the terminal — copy it somewhere safe immediately, because the token expires in 24 hours by default.
Configure kubectl for your user:
mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
Verify:
kubectl get nodes
kubectl get pods -A
You’ll see the control-plane node in a NotReady state at this point. That’s expected — no CNI plugin means no pod networking, and kubelet won’t mark the node ready until one is installed.
Step 6: Deploy a CNI Plugin
Pick one and commit — mixing CNI plugins on the same cluster is a recipe for routing chaos.
Flannel (simplest, good default for small clusters):
kubectl apply -f https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml
Calico (better choice if you’ll eventually need NetworkPolicy enforcement, which Flannel doesn’t support natively):
kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml
Give it 60–90 seconds, then recheck node status:
kubectl get nodes
The control plane should flip to Ready once the CNI daemonset pods are running.
Step 7: Join Worker Nodes
Run the earlier steps (1–4) on every worker node, then execute the join command generated during kubeadm init. If you lost it or the token expired:
kubeadm token create --print-join-command
Run the output on the worker:
sudo kubeadm join <control-plane-ip>:6443 --token <token> --discovery-token-ca-cert-hash sha256:<hash> --cri-socket=unix:///run/containerd/containerd.sock
Back on the control plane, confirm the worker registered:
kubectl get nodes -o wide
Troubleshooting: What Actually Goes Wrong
kubeadm init hangs at “waiting for kubelet to start”
Almost always a cgroup driver mismatch between the container runtime and kubelet, or containerd not running at all. Check systemctl status containerd first, then journalctl -u kubelet -f for the actual error. If you see cgroup driver mismatch, revisit Step 2 and confirm SystemdCgroup = true in /etc/containerd/config.toml, then restart both containerd and kubelet.
Nodes stuck in NotReady after CNI install
Check kubectl get pods -n kube-system — if CNI daemonset pods are CrashLoopBackOff, it’s usually a pod CIDR mismatch between what you passed to kubeadm init and what the CNI manifest expects. Fix by re-initializing with the matching CIDR, or editing the CNI’s ConfigMap before it schedules.
SELinux denials flooding /var/log/audit/audit.log
Don’t reflexively disable SELinux. Run ausearch -m avc -ts recent to see exactly what’s being denied, then generate a targeted policy with audit2allow as shown in Step 4. This takes five extra minutes and keeps your enforcing posture intact.
kubeadm join fails with a certificate error
Tokens and cert hashes expire after 24 hours by default. Regenerate on the control plane with kubeadm token create --print-join-command rather than reusing an old command from your notes.
DNS resolution fails inside pods (CoreDNS crash loop)
Fedora 44 uses systemd-resolved, and /etc/resolv.conf often points to the local stub resolver at 127.0.0.53, which CoreDNS chokes on with a loop-detection error. Point kubelet at the real resolver file instead by adding resolvConf: /run/systemd/resolve/resolv.conf to your kubelet configuration, or symlink /etc/resolv.conf to bypass the stub if that fits your setup.
Firewall silently blocking pod-to-pod traffic across nodes
If pods on different nodes can’t reach each other but same-node pods work fine, it’s almost always a missing firewall rule for your CNI’s overlay protocol (VXLAN UDP 8472 for Flannel, or BGP TCP 179 for Calico). Add the rule rather than disabling firewalld cluster-wide.
Performance and Security Hardening
A cluster that “works” and a cluster that survives a traffic spike at 2 a.m. are different things. A few adjustments worth making before you call this production-ready:
- etcd on fast disk. etcd is latency-sensitive to disk fsync performance. If your control plane is on spinning disk or a network-backed volume with high write latency, you’ll see apiserver slowness under load. Put etcd’s data directory on local NVMe if at all possible.
- Reserve resources for system daemons. Set
--system-reservedand--kube-reservedin kubelet config so runaway pods can’t starve the node’s own kernel and systemd processes. - Enable audit logging on the apiserver. For anything touching production data,
--audit-log-pathand a basic audit policy give you a forensic trail that’s invaluable during an incident. - RBAC by default, not as an afterthought. Kubeadm enables RBAC out of the box on modern versions, but verify with
kubectl auth can-i --listunder a non-admin service account before assuming least privilege is actually enforced. - Rotate certificates. kubeadm certs expire after a year by default. Set a calendar reminder —
kubeadm certs check-expiration— because a cluster with expired certs at 3 a.m. is a genuinely bad way to start a shift. - Monitor cgroup v2 memory pressure. Fedora 44’s cgroup v2 defaults handle memory limits differently than cgroup v1 did — containers get throttled rather than instantly OOM-killed in some scenarios. Watch
kubectl top nodesandkubectl describe nodefor pressure conditions, not just raw usage numbers.