Containers

Install Kubernetes on Rocky Linux 10 / AlmaLinux 10 with Kubeadm

A minimal Rocky Linux 10 install starts firewalld at boot, and that’s where a kubeadm cluster on it quietly breaks. Open the documented Kubernetes ports and the control plane initializes in 18 seconds, both nodes report Ready, and a pod on the worker still can’t resolve a single Service name, because firewalld rejects traffic forwarded into Calico’s pod interfaces. You don’t have to switch the firewall off to fix it.

Original content from computingforgeeks.com - post 102102

This guide shows how to install Kubernetes on Rocky Linux 10 or AlmaLinux 10 with kubeadm, containerd and the Calico network plugin, with SELinux enforcing and firewalld left on. You’ll prepare the nodes, open the ports, join a worker and test the network. Every step was run on a two-node Rocky Linux 10.2 cluster in October 2026 with Kubernetes 1.37.1, containerd 2.4.2 and Calico 3.33.0, and AlmaLinux 10.2 got a package check against its own repositories.

Size the nodes and plan the network

You need one control plane node and at least one worker. Each one needs a unique hostname, MAC address and product_uuid, a fixed IP on the same subnet, and a CPU at the x86-64-v3 level. The documented kubeadm floor is 2 GB of RAM per machine and 2 CPUs on the control plane. That floor covers an empty control plane, not one running a network plugin and your workloads.

What sizes a real cluster is etcd on the control plane and the pods on your workers. The control plane carries etcd, which keeps cluster state in memory and syncs every write to disk. The etcd hardware guide calls it very sensitive to disk write latency, so it asks for SSD storage, and it says 8 GB of memory is typically enough. Your workers size from the pods you plan to run, so add up their memory requests and leave room for the kubelet, containerd, kube-proxy and calico-node. A node takes 110 pods at most by default.

Our own numbers show where the memory goes. With Calico’s full operator stack and CoreDNS running, cp01 used 1.9 GB of its 3.6 GB (total minus available memory), while worker01 used 870 MB with two nginx pods on it. Both nodes had 2 vCPU, 4 GB of RAM and 40 GB of disk, which is a floor for following along, not a production recommendation. For a small production control plane we would start at 4 vCPU and 8 GB on SSD storage, and size each worker to the pods it will carry.

Keep your nodes on one low-latency network, because the API server talks to every kubelet and every calico-node talks to Typha. A highly available control plane is a different cluster, initialized with a stable endpoint and three control plane nodes. There, etcd commits a write once a majority of members agrees, so a slow leader or a slow majority sets the pace. Use similar machines on a low-latency network, spread across power and switch failure domains where latency allows. Before you start, check that each node has:

  • Rocky Linux 10 or AlmaLinux 10, minimal install, updated with dnf upgrade, then rebooted
  • an x86-64-v3 CPU (AVX2, BMI2, FMA)
  • 2 vCPU and 2 GB of RAM at minimum, 4 GB on the control plane
  • a unique hostname, MAC address and product_uuid
  • a static IP on the same subnet as the other nodes
  • a user with sudo rights

The CPU line is the one that bites on hypervisors. Rocky Linux 10 dropped support for anything below x86-64-v3, and both of Proxmox’s usual CPU types sit below that: kvm64, the backend default, and x86-64-v2-AES, which the GUI picks for new VMs. The glibc loader on the node reports the levels it supports:

/usr/lib64/ld-linux-x86-64.so.2 --help | grep -E "x86-64-v[234]"

You want to see x86-64-v3 (supported, searched), as on our nodes:

  x86-64-v4
  x86-64-v3 (supported, searched)
  x86-64-v2 (supported, searched)

On Proxmox, set the VM’s CPU type to host (or x86-64-v3) before you install the OS. If you plan to run the nodes as VMs on your own hardware, our picks of mini PCs for a Proxmox homelab cover the RAM and NVMe headroom a multi-node lab like this needs.

kubeadm also assumes you can read a pod spec and debug a Service that has no endpoints, because that is what the first broken deployment will ask of you. If those ideas are still new, the Kubernetes books we’d start with cover that ground faster than the reference docs.

The lab used one node of each role. Swap in your own names and addresses; any extra worker repeats the worker01 steps.

RoleHostnameIP addressRuns
Control planecp0110.0.1.10kube-apiserver, etcd, scheduler, controller manager, Calico Typha
Workerworker0110.0.1.11your workloads

Decide the pod network now, because kubeadm bakes it into the cluster. Calico’s sample manifest uses 192.168.0.0/16, which overlaps a lot of home and office LANs, ours included, and pods cannot reach LAN hosts whose addresses sit inside the pod range. We used 10.244.0.0/16. Whatever you pick has to stay clear of your node subnet and of the Service range, which kubeadm sets to 10.96.0.0/12 unless you change it. The diagram shows how the two nodes fit together, with the port behind each conversation.

Kubernetes architecture diagram: kube-apiserver and etcd on cp01, kubelet and kube-proxy NodePorts on both cp01 and worker01

Your admin workstation and the worker’s kubelet both talk to the API server on 6443, and the API server reaches every kubelet on 10250. Each calico-node reads the cluster’s network state through Typha on 5473 and programs a route to the other node’s pod block. With this VXLAN pool those routes come from the Calico datastore rather than BGP, but the operator still enables BGP on 179, so that port stays open too. NodePort traffic can arrive at either node, where kube-proxy’s rules DNAT it to a pod. On the node that performs the DNAT, firewalld accepts the connection even before the zone change covered below. If the pod runs on the other node, though, that hop is an ordinary forward into a cali interface and needs the zone change.

Prepare to install Kubernetes on Rocky Linux

Every node needs the same base before you install any Kubernetes package: a few shell variables, a hostname the other nodes can resolve, swap turned off and IPv4 forwarding on. Run this whole section on cp01 and on every worker.

Start with the variables. K8S_MINOR reads the current stable release from dl.k8s.io and keeps only the minor version, which picks the package repository later, so if you need an older minor, set the variable by hand to that minor, such as v1.36, which is also the oldest minor the Calico install below supports. The other three hold your control plane address, your first worker and the pod network you chose:

export K8S_MINOR=$(curl -sL https://dl.k8s.io/release/stable.txt | cut -d. -f1,2)
export CP_IP="10.0.1.10"
export WORKER1_IP="10.0.1.11"
export POD_CIDR="10.244.0.0/16"
echo "Kubernetes ${K8S_MINOR}, control plane ${CP_IP}, pod network ${POD_CIDR}"

Exports only last for the current shell, so re-run the block whenever you reconnect or switch to a root shell. A command that expands an empty variable fails in confusing ways, such as a repo URL with no version in it, which is why the echo is worth a glance. It should print your own values.

Kubernetes v1.37, control plane 10.0.1.10, pod network 10.244.0.0/16

If any value comes back empty, fix it before going further, because every later command on this node expands these variables.

Set the hostname and hosts file

Give each machine its final name before kubeadm sees it, because the node registers under that name and renaming it later means you remove and rejoin the node. On the control plane:

sudo hostnamectl set-hostname cp01

Use worker01 on the worker. Then add every node to /etc/hosts on every node, so the names resolve even without internal DNS:

echo "${CP_IP} cp01
${WORKER1_IP} worker01" | sudo tee -a /etc/hosts

Our nodes were cloned from a cloud image, and the header of its /etc/hosts says cloud-init manages the file. With manage_etc_hosts set, cloud-init rebuilds /etc/hosts from its template at boot, so lines you only append to the file itself can disappear. If your hosts file carries that header, append the same lines to the template as well. With both in place, our nodes kept their entries through a reboot:

echo "${CP_IP} cp01
${WORKER1_IP} worker01" | sudo tee -a /etc/cloud/templates/hosts.redhat.tmpl

Check that each node reaches the other by name. From cp01:

ping -c 2 worker01

Both replies should come back from the address you put in the hosts file. Check with ping rather than getent hosts. On cp01, getent answered for its own name with an IPv6 link-local address, because it asks for IPv6 first and systemd’s myhostname module answers when the hosts file only has an IPv4 entry. That looks like a broken hosts file when it isn’t.

PING worker01 (10.0.1.11) 56(84) bytes of data.
64 bytes from worker01 (10.0.1.11): icmp_seq=1 ttl=64 time=0.682 ms
64 bytes from worker01 (10.0.1.11): icmp_seq=2 ttl=64 time=0.509 ms

--- worker01 ping statistics ---
2 packets transmitted, 2 received, 0% packet loss, time 1025ms
rtt min/avg/max/mdev = 0.509/0.595/0.682/0.086 ms

Run the same check from worker01 against cp01, so you know the names resolve in both directions.

Turn off swap

The kubelet refuses to start while swap is active unless you configure it to tolerate swap, which this build does not need. Turn swap off now and comment out its fstab entry so it stays off after a reboot:

sudo swapoff -a
sudo sed -i '/\sswap\s/ s/^/#/' /etc/fstab
swapon --show

Our cloud VMs had no swap partition, so we also ran the sed against a copy of a standard Rocky Linux fstab. It commented out the /dev/mapper/rl-swap line and left the root entry alone. The final swapon --show prints nothing once no swap device is active, which is the state the kubelet needs.

Enable IP forwarding

Your pods on one node reach pods on another only if the kernel routes IPv4 packets between interfaces, which it does not do by default. Make the setting persistent and load it:

echo "net.ipv4.ip_forward = 1" | sudo tee /etc/sysctl.d/k8s.conf
sudo sysctl --system

sysctl --system reloads every file under /etc/sysctl.d, so the value is live now and comes back after every reboot. Read it back anyway, because a file that sorts later in that directory can override yours.

sysctl net.ipv4.ip_forward

You want a 1 here. Anything else means another file wins and you need to find it before going further.

net.ipv4.ip_forward = 1

That is the only kernel setting this build needs. Older guides also load br_netfilter and set the net.bridge.bridge-nf-call sysctls. The Kubernetes container runtime prerequisites now list only IP forwarding, though, kubeadm’s preflight no longer asks for the module, and Calico routes pods over veth pairs without a Linux bridge. We built and tested this cluster without it: with everything running, lsmod lists no br_netfilter on either node and /proc/sys/net/bridge does not exist. The overlay module containerd uses is loaded by containerd’s own systemd unit, so there’s nothing to add for it either.

Flannel is the exception. Its README says it needs br_netfilter to start and that recent kubeadm releases no longer check for it, so if you pick Flannel instead of Calico, load the module. On Rocky Linux 10 it ships in kernel-modules-extra, which has to match the running kernel, and the troubleshooting section shows the modprobe: FATAL error we got when it did not.

Open the firewall and keep firewalld on

firewalld needs two kinds of change for this cluster: the ports Kubernetes and Calico listen on, and a zone entry for Calico’s pod interfaces. The ports come straight from the upstream tables. The zone entry is easy to miss, and without it traffic between pods on different nodes fails while everything else looks healthy.

Start by making sure firewalld runs at all. A minimal install enables it, but cloud images often leave it out, and ours did, so we installed it to match a standard install. If systemctl is-active firewalld reports inactive, or the unit doesn’t exist, install it first:

sudo dnf install -y firewalld

Starting it applies the public zone immediately, and on our nodes that zone allowed only ssh, cockpit and dhcpv6-client. If your sshd listens on a port other than 22, open that port before you start the service or you’ll lock yourself out of a remote node. The firewall-offline-cmd tool edits the configuration while firewalld is stopped; replace 2222 with your SSH port. We didn’t run this line, because our sshd uses 22.

sudo firewall-offline-cmd --add-port=2222/tcp

Then enable and start firewalld:

sudo systemctl enable --now firewalld

On our nodes that install also pulled in iptables-nft and kernel-modules-extra, which comes up again in the br_netfilter troubleshooting entry. These are the ports you need, by node role.

PortProtocolOpen onUsed by
6443TCPcontrol planeKubernetes API server
2379-2380TCPcontrol planeetcd client and peer traffic
10250TCPall nodeskubelet API
10257TCPcontrol planekube-controller-manager
10259TCPcontrol planekube-scheduler
10256TCPworkerskube-proxy health check
30000-32767TCP and UDPworkersNodePort Services
179TCPall nodesCalico BGP
4789UDPall nodesCalico VXLAN between subnets
5473TCPall nodesCalico Typha

Four of them deserve a note. The kubeadm manifests bind the scheduler and controller manager to 127.0.0.1, so opening 10257 and 10259 changes nothing unless you move them off localhost with --bind-address; we opened them only to match the upstream table. In kube-proxy’s default iptables mode, which this build runs, the NodePort range doesn’t gate NodePort traffic either, because kube-proxy DNATs it before firewalld filters it on the receiving node, as the test section shows. Port 4789 carries Calico’s VXLAN traffic only between nodes in different subnets, because the default pool routes same-subnet traffic natively, so on our single-subnet lab it stayed idle.

On the control plane, open the API server, etcd and component ports plus Calico’s three, because cp01 runs calico-node and Typha like any other node. The --permanent flag writes rules to disk without applying them, so nothing changes until the reload at the end of this section.

sudo firewall-cmd --permanent --add-port={6443,2379-2380,10250,10257,10259}/tcp
sudo firewall-cmd --permanent --add-port={179,5473}/tcp --add-port=4789/udp

On every worker, open the kubelet, kube-proxy and NodePort ranges along with the same Calico ports. Workers need nothing for 6443 or etcd, because they only connect to those ports on cp01 and never serve them.

sudo firewall-cmd --permanent --add-port={10250,10256,30000-32767}/tcp --add-port=30000-32767/udp
sudo firewall-cmd --permanent --add-port={179,5473}/tcp --add-port=4789/udp

Next, stop NetworkManager from managing Calico’s interfaces, which Calico’s documentation requires on any node where NetworkManager runs, because it would otherwise interfere with the routes Calico sets up for each pod. On every node:

printf "[keyfile]\nunmanaged-devices=interface-name:cali*;interface-name:tunl*;interface-name:vxlan.calico;interface-name:vxlan-v6.calico;interface-name:wireguard.cali;interface-name:wg-v6.cali\n" | sudo tee /etc/NetworkManager/conf.d/calico.conf
sudo systemctl reload NetworkManager

Finally, put Calico’s interfaces in the public zone and reload firewalld so the permanent rules take effect. Run this on every node too:

sudo firewall-cmd --permanent --zone=public --add-interface=cali+ --add-interface=vxlan.calico
sudo firewall-cmd --reload

That last change is the one that matters. The public zone allows forwarding only between interfaces that belong to it. On a fresh node, firewalld’s nftables chain for that reads oifname "eth0" accept and nothing more. A packet from another node bound for a local pod gets forwarded into a cali interface and matches no rule. It falls through to reject with icmpx admin-prohibited, which a pod sees as “No route to host”. The firewall does accept connections that kube-proxy DNATed on the same node, which is why our NodePort test passed while DNS from the worker failed. With cali+ in the zone (firewalld writes it as the wildcard cali*), the forward becomes intra-zone and passes.

Calico’s requirements page goes further and says to disable firewalld, protecting hosts with Calico host endpoint policy instead. That’s the cleaner design once you run Calico policy everywhere. On a small cluster we would keep firewalld: with these rules both nodes passed every test in this guide and kept working through a reboot of each. Check the zone on a worker.

sudo firewall-cmd --zone=public --list-all | grep -E "interfaces|ports|forward:"

worker01 lists eth0 and both Calico interfaces, the worker ports and forwarding on:

  interfaces: cali+ eth0 vxlan.calico
  ports: 10250/tcp 10256/tcp 30000-32767/tcp 30000-32767/udp 179/tcp 5473/tcp 4789/udp
  forward: yes
  forward-ports:
  source-ports: 

cp01 shows the same three interfaces in its zone, with the control plane ports in place of the worker ones.

Install containerd from the Docker repository

Rocky Linux 10’s BaseOS, AppStream and Extras repositories do not carry containerd or runc, so the practical source is Docker’s RHEL repository. Its containerd.io package bundles runc and pulls container-selinux from AppStream, which supplies the SELinux policy containers need. Run this on every node. The first line provides the config-manager command, which was already present on our nodes, so it changed nothing there:

sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf install -y containerd.io

The package gives you a config file with exactly one active line, disabled_plugins = ["cri"], which switches off the very interface the kubelet talks to. Replace it with containerd’s full default config, then switch runc to the systemd cgroup driver:

sudo containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
grep -n SystemdCgroup /etc/containerd/config.toml

The grep should show the setting flipped:

89:            SystemdCgroup = true

containerd 2.3 and newer write a version 4 config, while 2.0 to 2.2 write version 3. Both keep runc’s options under plugins.'io.containerd.cri.v1.runtime', so the section names from containerd 1.x guides no longer exist and a sed aimed at them silently changes nothing. The systemd driver matters because Rocky Linux 10 runs cgroup v2 under systemd, and the kubelet now asks containerd which driver to use instead of trusting its own setting. Our kubelet log confirmed it with "Using cgroup driver setting received from the CRI runtime" cgroupDriver="systemd". With the config in place, enable and start containerd.

sudo systemctl enable --now containerd
systemctl status containerd --no-pager

Look for active (running). The ExecStartPre line is the overlay module load mentioned earlier:

● containerd.service - containerd container runtime
     Loaded: loaded (/usr/lib/systemd/system/containerd.service; enabled; preset: disabled)
     Active: active (running) since Thu 2026-10-15 20:07:55 UTC; 26ms ago
 Invocation: 00278ab1e73742c58d90d9a2f007aa7c
       Docs: https://containerd.io
    Process: 62529 ExecStartPre=/sbin/modprobe overlay (code=exited, status=0/SUCCESS)
   Main PID: 62531 (containerd)
      Tasks: 8
     Memory: 16.8M (peak: 22.5M)

Get containerd running on every node before you continue, because kubeadm’s preflight checks the container runtime on whichever node runs init or join.

Install kubeadm, kubelet and kubectl

The Kubernetes packages come from pkgs.k8s.io, which publishes one repository per minor release, so the URL carries the K8S_MINOR value you exported. The exclude line keeps a routine dnf upgrade from moving Kubernetes, because a cluster has to be upgraded through kubeadm one minor at a time, as our kubeadm upgrade walkthrough shows. Write the repo file on every node:

echo "[kubernetes]
name=Kubernetes
baseurl=https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/rpm/repodata/repomd.xml.key
exclude=kubelet kubeadm kubectl cri-tools kubernetes-cni" | sudo tee /etc/yum.repos.d/kubernetes.repo

Install the three tools plus cri-tools. The two --setopt flags are the upstream kubeadm install form for RHEL 10. One lifts the exclude for this transaction, and the other skips weak dependencies such as iptables. On our nodes iptables-nft was already installed, because firewalld pulls it in, so the second flag mostly matters on hosts that run without firewalld.

sudo dnf install -y kubelet kubeadm kubectl cri-tools --setopt=disable_excludes=kubernetes --setopt=install_weak_deps=False

Name cri-tools explicitly. The kubeadm package no longer depends on it, so the upstream command alone leaves you without crictl. Our first install did exactly that and ended with crictl: command not found. You reach for crictl when a pod will not start and kubectl has nothing useful to say, and our guide to managing containerd with ctr and crictl covers it in depth.

Enable the kubelet and point crictl at containerd’s socket:

sudo systemctl enable --now kubelet
sudo crictl config --set runtime-endpoint=unix:///run/containerd/containerd.sock

The kubelet now restarts in a loop until kubeadm init or join hands it a config, so systemctl is-active kubelet reporting activating is expected. The crictl setting stops the warning it otherwise prints about deprecated default endpoints. Check the versions:

kubeadm version -o short
kubectl version --client
crictl --version

Every node should report the same Kubernetes version, because a kubelet must never be newer than the API server it talks to. A mismatch here usually means one node read a different repository minor, so fix it before that node joins. Our two nodes matched exactly:

v1.37.1
Client Version: v1.37.1
Kustomize Version: v5.8.1
crictl version v1.37.0

With matching versions everywhere, node preparation is finished, and the next two sections run on cp01 alone.

Initialize the control plane with kubeadm

You run this section on cp01 only. kubeadm init generates the cluster certificates, starts etcd and the control plane components as static pods, and prints the command workers use to join.

Pull the control plane images first. It moves the slowest part out of the init, and a registry or proxy problem shows up before anything on the node has changed. Preflight would pull them anyway, so the only thing you change is when the wait happens.

sudo kubeadm config images pull

These are the exact images kubeadm deploys, including the pause image that matches containerd’s default sandbox, so kubeadm prints no sandbox mismatch warning:

[config/images] Pulled registry.k8s.io/kube-apiserver:v1.37.1
[config/images] Pulled registry.k8s.io/kube-controller-manager:v1.37.1
[config/images] Pulled registry.k8s.io/kube-scheduler:v1.37.1
[config/images] Pulled registry.k8s.io/kube-proxy:v1.37.1
[config/images] Pulled registry.k8s.io/coredns/coredns:v1.14.6
[config/images] Pulled registry.k8s.io/pause:3.10.2
[config/images] Pulled registry.k8s.io/etcd:3.7.0-0

Decide one thing before you run it: this build stays a single control plane node. Extra control plane nodes can only join a cluster initialized with --control-plane-endpoint set to a DNS name or load-balancer address. Turning a cluster created without it into a highly available one is not supported by kubeadm. If there is any chance you’ll want HA, initialize with that flag from the start, as the kubeadm cluster creation guide explains. We did not build that form here.

Now initialize the cluster. The advertise address pins the API server to the node’s LAN address, so it stays right on a node with more than one interface, and the pod CIDR has to match the pool Calico gets in the next section. Without the flag, kubeadm uses the interface that holds the default route.

sudo kubeadm init --apiserver-advertise-address="${CP_IP}" --pod-network-cidr="${POD_CIDR}"

Preflight prints [WARNING Firewalld]: firewalld is active, please ensure ports [6443 10250] are open even with every port open, because kubeadm only checks whether firewalld is running. The whole init took 18 seconds here and ends with the kubeconfig steps and the join command:

Your Kubernetes control-plane has initialized successfully!

To start using your cluster, you need to run the following as a 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

Alternatively, if you are the root user, you can run:

  export KUBECONFIG=/etc/kubernetes/admin.conf

You should now deploy a pod network to the cluster.
Run "kubectl apply -f [podnetwork].yaml" with one of the options listed at:
  https://kubernetes.io/docs/concepts/cluster-administration/addons/

Then you can join any number of worker nodes by running the following on each as root:

kubeadm join 10.0.1.10:6443 --token abcdef.0123456789abcdef \
	--discovery-token-ca-cert-hash sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

Copy the kubeadm join line somewhere safe. It holds a bootstrap token that expires after 24 hours, and the join section shows how to print a fresh one. Next, give your regular user a kubeconfig so kubectl works without sudo:

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config
kubectl get nodes

Your node shows up as NotReady, and that is correct at this point. The kubelet reports cni plugin not initialized until a network plugin is installed, and CoreDNS stays Pending for the same reason:

NAME   STATUS     ROLES           AGE   VERSION
cp01   NotReady   control-plane   12s   v1.37.1

Install Calico before any worker joins, so each worker gets its calico-node pod the moment it registers.

Install the Calico network plugin

Calico installs through the Tigera operator in three steps: its API definitions, the operator itself, and a custom resource file that tells the operator what to build. Run them on cp01, where your kubeconfig lives. Detect the current release first so every URL uses the same version:

export CALICO_VERSION=$(curl -sL https://api.github.com/repos/projectcalico/calico/releases/latest | grep tag_name | head -1 | sed 's/.*"\(v[^"]*\)".*/\1/')
echo "${CALICO_VERSION}"

The echo prints the tag the GitHub API returned. An empty line means the API call failed, usually because GitHub’s limit of 60 unauthenticated requests an hour ran out, so wait or set the tag by hand. GitHub marks the most recently published release as latest, which can be a patch on an older branch, so compare the tag with the Calico releases page before you apply anything.

v3.33.0

Create the Calico API definitions and the operator. This native v3 CRD install needs Kubernetes 1.36 or newer, where the MutatingAdmissionPolicy API it uses is GA, which is why the variables step suggests nothing older than v1.36. For an older minor, follow the Calico on-premises install page instead of these two commands. On 1.34 and 1.35 that page’s manifest also needs the MutatingAdmissionPolicy feature gate and the matching runtime-config switched on for the API server at init, which we did not test:

kubectl create -f "https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/v3_projectcalico_org.yaml"
kubectl create -f "https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/tigera-operator.yaml"

The operator reads its instructions from custom-resources.yaml. Download it and replace the sample 192.168.0.0/16 pool with your pod CIDR before you apply it:

curl -sLO "https://raw.githubusercontent.com/projectcalico/calico/${CALICO_VERSION}/manifests/custom-resources.yaml"
sed -i "s|cidr: 192.168.0.0/16|cidr: ${POD_CIDR}|" custom-resources.yaml
grep -n "cidr:" custom-resources.yaml

The grep confirms the pool now matches the CIDR you gave kubeadm init. Calico would otherwise hand out addresses from its 192.168.0.0/16 sample pool, so your pods would land outside the range kubeadm configured for them.

13:        cidr: 10.244.0.0/16

Create the resources.

kubectl create -f custom-resources.yaml

The file defines four objects, and the operator builds everything else from them:

  • the Installation, which defines the IP pool with VXLANCrossSubnet encapsulation and outgoing NAT
  • the Calico API server
  • Goldmane, the flow aggregator
  • Whisker, the observability UI

From those, the operator deploys calico-node and csi-node-driver on every node plus Typha, kube-controllers and two webhook pods. kubectl confirms the four objects:

installation.operator.tigera.io/default created
apiserver.operator.tigera.io/default created
goldmane.operator.tigera.io/default created
whisker.operator.tigera.io/default created

Follow the rollout with the operator’s status resource. It rolls up every component the operator manages, so it tells you more than a list of pods would.

kubectl get tigerastatus

For about three minutes it looked broken. The apiserver, calico, goldmane and whisker rows reported Degraded with failed calling webhook "tiered-rbac.api.projectcalico.org" and connect: connection refused. The API server has to call Calico’s webhook pods before it accepts those objects, and those pods were still pulling images. Give it time before you debug anything. Once those pods were up, every row read True:

NAME        AVAILABLE   PROGRESSING   DEGRADED   SINCE   MESSAGE
apiserver   True        False         False      25s     All objects available
calico      True        False         False      0s      All objects available
goldmane    True        False         False      25s     All objects available
ippools     True        False         False      3m1s    All objects available
tiers       True        False         False      3m      All objects available
whisker     True        False         False      25s     All objects available

At that point cp01 reports Ready and CoreDNS is running, with pod addresses from the 10.244.0.0/16 pool. The node flips to Ready as soon as calico-node is up, which on our cluster happened while goldmane and whisker still showed Degraded, so a Ready node alone does not prove the network plugin has finished.

Join the worker node

Run the join command from the init output on each worker, with sudo. Your token and hash will differ from the ones shown here:

sudo kubeadm join 10.0.1.10:6443 --token abcdef.0123456789abcdef --discovery-token-ca-cert-hash sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

It took one second on worker01, because the heavy work already happened on cp01. The worker only runs a TLS bootstrap to get its kubelet certificate signed, then starts the kubelet against the API server.

[kubelet-check] The kubelet is healthy after 501.600553ms
[kubelet-start] Waiting for the kubelet to perform the TLS Bootstrap

This node has joined the cluster:
* Certificate signing request was sent to apiserver and a response was received.
* The Kubelet was informed of the new secure connection details.

Run 'kubectl get nodes' on the control-plane to see this node join the cluster.

Back on cp01, list the nodes with their addresses and runtime.

kubectl get nodes -o wide

worker01 went Ready 55 seconds after the join. Its kubelet was up within a second, but the node stays NotReady until its own calico-node pod has started and installed the CNI config, so expect the same short wait on every new worker:

NAME       STATUS   ROLES           AGE    VERSION   INTERNAL-IP   EXTERNAL-IP   OS-IMAGE                        KERNEL-VERSION                          CONTAINER-RUNTIME
cp01       Ready    control-plane   6m3s   v1.37.1   10.0.1.10     <none>        Rocky Linux 10.2 (Red Quartz)   6.12.0-211.65.1.el10_2.x86_64 (amd64)   containerd://2.4.2
worker01   Ready    <none>          67s    v1.37.1   10.0.1.11     <none>        Rocky Linux 10.2 (Red Quartz)   6.12.0-211.65.1.el10_2.x86_64 (amd64)   containerd://2.4.2

The node list and Calico’s pods from the control plane, captured after both nodes had been rebooted once:

kubectl get nodes showing cp01 and worker01 Ready on Rocky Linux 10 with Calico pods running

The restart counts on the Calico pods come from that reboot test, and every pod came back on its own. Bootstrap tokens last 24 hours, so to add a worker later, print a fresh join line on cp01:

sudo kubeadm token create --print-join-command

It prints a complete join command with a new token. The CA hash stays the same for the life of the cluster:

kubeadm join 10.0.1.10:6443 --token ghijkl.0123456789abcdef --discovery-token-ca-cert-hash sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

Join any further workers the same way, then put the cluster through a real workload.

Deploy a test application

Nodes reporting Ready only proves the kubelets can reach the API server, not that your pods can reach each other. The test that matters runs a workload on the worker, reaches it through a NodePort and resolves its Service name from a pod. That DNS query crosses from worker01 to CoreDNS on cp01, so it fails if the firewall zone step was skipped. Create an nginx Deployment and wait for it:

kubectl create deployment nginx-test --image=nginx --replicas=2
kubectl rollout status deployment/nginx-test --timeout=180s

Then check where the pods landed.

kubectl get pods -o wide

Both replicas run on worker01, because kubeadm taints the control plane against your regular workloads:

NAME                          READY   STATUS    RESTARTS   AGE   IP           NODE       NOMINATED NODE   READINESS GATES
nginx-test-858fc98cf5-jt86n   1/1     Running   0          25s   10.244.5.3   worker01   <none>           <none>
nginx-test-858fc98cf5-lql6t   1/1     Running   0          25s   10.244.5.2   worker01   <none>           <none>

Expose the Deployment on a NodePort and look up the port Kubernetes assigned. The Service also gets a ClusterIP, which is the address the DNS test below resolves to.

kubectl expose deployment nginx-test --type=NodePort --port=80
kubectl get svc nginx-test

The number after the colon is the NodePort. Kubernetes picks it from 30000-32767, the range the upstream port table lists for workers.

NAME         TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
nginx-test   NodePort   10.109.59.163   <none>        80:32329/TCP   0s

Read the port into a variable and request the page through the worker’s address. Any node’s address works, cp01’s included, because kube-proxy in its default iptables mode programs DNAT rules for the NodePort on every node and the DNAT happens before firewalld filters the packet. The firewalld forward chain then accepts the DNATed connection, since StrictForwardPorts is off by default, so its port rules never see NodePort traffic. A curl from worker01 to cp01’s address on a NodePort returned 200 in our lab, although cp01 never opened 30000-32767. The pod behind it ran on worker01, so the second hop was an ordinary forward into a cali interface, which the zone change already allows.

NODE_PORT=$(kubectl get svc nginx-test -o jsonpath='{.spec.ports[0].nodePort}')
curl -sI "http://${WORKER1_IP}:${NODE_PORT}" | head -3

If you get a 200 from nginx, kube-proxy accepted the NodePort and forwarded it to a pod. If the curl returns nothing straight after the expose, wait a few seconds and repeat it. Our first attempt, fired the instant the Service was created, came back empty, and the same request worked a minute later:

HTTP/1.1 200 OK
Server: nginx/1.31.7
Date: Thu, 15 Oct 2026 20:35:57 GMT

Now the cross-node test. This throwaway busybox pod lands on worker01, while both CoreDNS replicas run on cp01 because they were scheduled before the worker joined, so every lookup has to cross nodes. The --rm flag deletes the pod as soon as the lookup returns.

kubectl run dnstest --image=busybox:1.37 --restart=Never --rm -i -- nslookup nginx-test.default.svc.cluster.local

The answer comes from 10.96.0.10, the cluster DNS Service, and resolves nginx-test to its ClusterIP. If you get nslookup: read: No route to host instead, go back to the firewall zone step:

Server:		10.96.0.10
Address:	10.96.0.10:53

Name:	nginx-test.default.svc.cluster.local
Address: 10.109.59.163

kubectl also prints a notice that the session is recorded in container logs, and if the pod finishes before kubectl attaches you’ll see warning: couldn't attach to pod/dnstest, falling back to streaming logs with the answer printed twice. Both happened on our runs and both are harmless. The same three checks in one terminal, NodePort first and the DNS answer last:

nginx test Service on Kubernetes answering on its NodePort and resolving through CoreDNS on a Rocky Linux 10 cluster

Keep the test Deployment for now. The hardening section uses its pods to show SELinux labels, then removes it.

NodePorts are fine for a test, but real services on bare metal want a LoadBalancer address. MetalLB on Kubernetes provides one without a cloud provider, so a LoadBalancer Service gets an IP from a pool on your LAN instead of a high port on every node.

Run the same steps on AlmaLinux 10

If you run every command above on AlmaLinux 10, you get the same packages. We did not boot an AlmaLinux cluster. Instead we resolved the full install transaction against AlmaLinux 10’s own BaseOS and AppStream repositories, plus the Docker and pkgs.k8s.io repositories, using dnf’s --installroot and --assumeno, and compared the result with what the Rocky Linux nodes installed:

ItemRocky Linux 10.2 (built)AlmaLinux 10.2 (resolved)
$releasever for the Docker repo1010
containerd.io2.4.2-2.el102.4.2-2.el10
container-selinux2.246.02.248.0
kubeadm, kubelet, kubectl1.37.11.37.1
cri-tools1.37.01.37.0
firewalld2.4.32.4.3
br_netfilter ships inkernel-modules-extrakernel-modules-extra
CPU baselinex86-64-v3 onlyx86-64-v3, plus a separate x86_64_v2 build

The package set matches down to the containerd and Kubernetes builds. Both distributions report a release version of 10, so Docker’s repository URL resolves to the same rhel/10 tree on each, and pkgs.k8s.io serves one distribution-independent repository per Kubernetes minor. The real difference is the CPU: AlmaLinux also publishes an x86_64_v2 build for older processors, which Rocky Linux does not. We did not test Kubernetes on that build, and Docker’s repository targets the standard x86_64 architecture, so treat it as unverified. Firewall and SELinux behavior was not observed on AlmaLinux either; with the same firewalld release we’d expect the same zone fix to be needed.

If part of your fleet runs Ubuntu instead, the kubeadm guide for Ubuntu 26.04 covers the apt side of the same build.

Troubleshooting

These are the errors we actually hit while building and testing this cluster. Each heading is the exact message, so you can match it against your own output, and each entry says what caused it and what fixed it.

nslookup: read: No route to host

Both nodes were Ready and the NodePort answered, yet the DNS test pod on worker01 failed like this:

nslookup: read: No route to host
;; connection timed out; no servers could be reached

From cp01, a direct request to a worker pod failed just as fast, with curl: (7) Failed to connect to 10.244.5.3 port 80 after 0 ms. Both are firewalld rejecting a forwarded packet on the receiving node. The chain that allows forwarding out of the public zone shows why:

sudo nft list chain inet firewalld filter_FWD_public_allow

Before the fix it accepted only traffic leaving through eth0, so anything headed into a pod’s cali interface fell through to the reject rule:

table inet firewalld {
	chain filter_FWD_public_allow {
		oifname "eth0" accept
	}
}

After adding cali+ and vxlan.calico to the public zone on both nodes, the same chain gained two rules and DNS, pod-to-pod traffic and the NodePort all worked:

table inet firewalld {
	chain filter_FWD_public_allow {
		oifname "cali*" accept
		oifname "vxlan.calico" accept
		oifname "eth0" accept
	}
}

Fix one node and the failure only moves, because each node’s firewalld judges only the packets it forwards into its own pods. With just cp01 changed, DNS from the worker resolved, but cp01 still could not reach a pod on worker01. The same rule on worker01 cleared it.

modprobe: FATAL: Module br_netfilter not found in directory /lib/modules

The full message named the running kernel, /lib/modules/6.12.0-211.16.1.el10_2.0.1.x86_64. On Rocky Linux 10, br_netfilter ships in kernel-modules-extra. On our cloud-image nodes that package had arrived only as a dependency of the ipset and iptables-nft packages firewalld pulls in, and only for the newer kernel a pending dnf upgrade had installed. List the kernel packages to see which kernel has it:

rpm -qa "kernel*" | sort

On cp01 only the newer kernel had kernel-modules-extra, while the node was still running the older one, so modprobe searched a module tree that never had br_netfilter in it:

kernel-core-6.12.0-211.16.1.el10_2.0.1.x86_64
kernel-core-6.12.0-211.65.1.el10_2.x86_64
kernel-modules-6.12.0-211.65.1.el10_2.x86_64
kernel-modules-core-6.12.0-211.16.1.el10_2.0.1.x86_64
kernel-modules-core-6.12.0-211.65.1.el10_2.x86_64
kernel-modules-extra-6.12.0-211.65.1.el10_2.x86_64
kernel-tools-6.12.0-211.65.1.el10_2.x86_64
kernel-tools-libs-6.12.0-211.65.1.el10_2.x86_64

The running kernel had no copy of the module. Rebooting into the newest kernel fixed it, and sudo modprobe br_netfilter loaded cleanly afterwards. You only need the module for bridge-based plugins such as Flannel, since Calico as built here runs without it.

Fatal glibc error: CPU does not support x86-64-v2

An EL10 binary refuses to start on a CPU below its baseline. We hit this starting an AlmaLinux 10 container on a VM with Proxmox’s kvm64 CPU type, which exposes no SSE4.2 and no AVX2. We only captured that case; on x86-64-v2-AES, which has the v2 features but no AVX2, expect the same error to name x86-64-v3. Rocky Linux 10’s binaries need the same features, so never run a node VM on either type. Shut the VM down, set its CPU type to host (or x86-64-v3 if you migrate VMs between different hosts) and start it again.

failed calling webhook “tiered-rbac.api.projectcalico.org”

This shows up in kubectl get tigerastatus right after the Calico install, on the calico, apiserver, goldmane and whisker rows, with dial tcp 10.101.225.171:443: connect: connection refused at the end. The operator creates its objects through the API server, which first has to call Calico’s admission webhook, and the webhook pods had not started yet. It cleared without any action about three minutes after the apply. If it is still there after five minutes, run kubectl -n calico-system get pods and read the events of whichever calico-webhooks pod is not Running.

Harden the cluster before real workloads

What you just built is a working baseline. Before it carries anything you care about, confine the pods with SELinux and back up etcd, and keep the kubectl cheat sheet close for the commands you’ll run every day.

Start with SELinux, because the defaults are weaker than getenforce suggests. The upstream kubeadm install page tells RHEL users to set SELinux to permissive. This guide deliberately keeps it enforcing, and at the end of the build sudo ausearch -m avc,user_avc -ts today found no denials on either node. The containerd CRI plugin still ships with enable_selinux = false, though, so every pod runs as spc_t, the unconfined container type. On worker01, with the nginx test pods still running, the labels looked like this:

ps -eZ | grep -E " nginx$| calico-node$| containerd$" | sort -u -k1,1 | head -6

The sort -u -k1,1 keeps one line per label, so the nginx processes collapse into a single line. The containerd daemon runs as container_runtime_t and the pods as spc_t, which means SELinux is not separating them from the host or from each other:

system_u:system_r:container_runtime_t:s0 6125 ?  00:00:15 containerd
system_u:system_r:spc_t:s0         8305 ?        00:00:00 nginx

Turn labeling on for the workers only, because the control plane needs extra work that the end of this section explains. Run this on each worker, and on any worker you join later before it takes pods.

sudo sed -i 's/enable_selinux = false/enable_selinux = true/' /etc/containerd/config.toml
sudo systemctl restart containerd

Your pods that already run keep their old label until you recreate them. From cp01 we deleted worker01’s Calico and kube-proxy pods so their DaemonSets replaced them, then restarted the test Deployment and waited for the rollout. The selectors name worker01, so repeat the deletes with each worker’s name:

kubectl -n calico-system delete pod -l k8s-app=calico-node --field-selector spec.nodeName=worker01 --wait=false
kubectl -n calico-system delete pod -l k8s-app=csi-node-driver --field-selector spec.nodeName=worker01 --wait=false
kubectl -n kube-system delete pod -l k8s-app=kube-proxy --field-selector spec.nodeName=worker01 --wait=false
kubectl rollout restart deployment/nginx-test
kubectl rollout status deployment/nginx-test --timeout=180s

Back on worker01, the nginx processes now run as container_t with a separate pair of MCS categories per pod, while kube-proxy stays spc_t because it runs privileged. The awk keeps only the label and the process name, and sort -u then leaves one line per label:

ps -eZ | grep -E " nginx$| calico-node$| kube-proxy$| csi-driver$| node-driver-re" | awk '{print $1, $NF}' | sort -u

Only nginx and kube-proxy match. Calico 3.33 runs its node agent as a process named calico and the CSI pod as csi-node-driver, so the calico-node and csi-driver patterns find nothing, and both of those run privileged as spc_t like kube-proxy. No denials appeared, and the NodePort and DNS checks still passed afterwards:

system_u:system_r:container_t:s0:c265,c417 nginx
system_u:system_r:container_t:s0:c509,c793 nginx
system_u:system_r:spc_t:s0 kube-proxy

That result covers what this cluster ran. Pods that mount hostPath volumes without running privileged, such as a local-path storage provisioner, node exporters or log shippers, can hit AVC denials once they run as container_t, and we did not run any of them here. After the change, and after you deploy anything of that kind, check the audit log on the worker:

sudo ausearch -m avc,user_avc -ts recent

Ours printed <no matches>, which is the result you want. A denial names the process and the path it tried to reach, so it tells you which pod needs a relabeled volume or a different security context.

Leave the control plane on the default for now. The etcd manifest from kubeadm sets no SELinux options and its data directory /var/lib/etcd is labeled var_lib_t, so switching labeling on there needs relabeling and manifest changes we have not tested.

Remove the test objects now that every check has run:

kubectl delete deployment nginx-test
kubectl delete svc nginx-test

After that, these are the next jobs in the order we would do them:

  1. scheduled etcd snapshots with a tested restore
  2. metrics and alerts from Prometheus and Grafana
  3. a web UI: Headlamp, since the old Kubernetes Dashboard project is archived

That’s the whole build. For a lab or a small internal cluster we’d run exactly this layout, with firewalld on and SELinux enforcing. Anything customer-facing gets its own cluster instead, initialized with a control plane endpoint and three control plane nodes as in our highly available kubeadm setup, plus a tested etcd restore before it gets workloads.

Keep reading

Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) AI Claude Code Cheat Sheet: Commands, Shortcuts and Flags (PDF) OpenAI Codex CLI Cheat Sheet: Shortcuts, Commands, Flags (PDF) AI OpenAI Codex CLI Cheat Sheet: Shortcuts, Commands, Flags (PDF) Best UI Applications for Managing Docker Containers Containers Best UI Applications for Managing Docker Containers CapRover Docker Install on Debian 13: Deploy Your First App Containers CapRover Docker Install on Debian 13: Deploy Your First App Best OpenShift Books for Admins and EX280 Candidates Books Best OpenShift Books for Admins and EX280 Candidates Install VirtualBox on Rocky Linux 10 / AlmaLinux 10 AlmaLinux Install VirtualBox on Rocky Linux 10 / AlmaLinux 10

6 thoughts on “Install Kubernetes on Rocky Linux 10 / AlmaLinux 10 with Kubeadm”

  1. Hi! Thank you for this write-up, I used it to install my very first K8s Cluster on my homelab.
    But I had two problems along the way getting it to finally work:

    1) The tigera-operator didn’t spin up the calico pods. I had to additionaly run
    kubectl create -f https://docs.projectcalico.org/manifests/calico.yaml
    No idea why.

    2) The pods of my first test deployment didn’t get IPs from the subnet that I set by the –pod-network-cidr option when running kubeadm init. This was because the playbook installs crio, and along with it the conf file /etc/cni/net.d/100-crio-bridge.conf . This conf sets the default subnet to ‘10.85.0.0/16’. This can’t be overridden by –pod-network-cidr option of kubeadm init, respectively the latter will just be ignored.
    My quick fix after I had run the playbook was running (remove the groupname [k8snodes] from hosts file first):
    for node in $(cat hosts); do ssh root@$node ‘sed -i “s/10.85.0.0/A.B.C.D/g” /etc/cni/net.d/100-crio-bridge.conf && systemctl restart crio’; done

    And finally destroy the calico pods and let k8s re-create them.

    Reply
  2. Hi! Thank you for this write-up, I used it to install my very first K8s Cluster on my homelab.
    But I had two problems along the way getting it to finally work:

    1) The tigera-operator didn’t spin up the calico pods. I had to additionaly run
    kubectl create -f https://docs.projectcalico.org/manifests/calico.yaml
    No idea why.

    2) The pods of my first test deployment didn’t get IPs from the subnet that I set by the –pod-network-cidr option when running kubeadm init. This was because the playbook installs crio, and along with it the conf file /etc/cni/net.d/100-crio-bridge.conf . This conf sets the default subnet to ‘10.85.0.0/16’. This can’t be overridden by –pod-network-cidr option of kubeadm init, respectively the latter will just be ignored.
    My quick fix after I had run the playbook was running (remove the groupname [k8snodes] from hosts file first):
    for node in $(cat hosts); do ssh root@$node ‘sed -i “s/10.85.0.0/A.B.C.D/g” /etc/cni/net.d/100-crio-bridge.conf && systemctl restart crio’; done

    Reply
  3. During the execution of the command “/root/.local/bin/ansible-playbook -i hosts k8s-prep.yml” as outlined in the guide, I encountered an error message.

    The specific error message I received is
    PLAY RECAP *********************************************************************
    k8s-master-01 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
    k8s-master-02 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
    k8s-master-03 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
    k8s-worker-01 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
    k8s-worker-02 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
    k8s-worker-03 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0
    k8s-worker-04 : ok=24 changed=13 unreachable=0 failed=1 skipped=1 rescued=0 ignored=0

    I greatly appreciate your expertise and was wondering if you could spare a moment to provide guidance on resolving this issue. Your assistance would be immensely valuable to me, and I am eager to learn from your insights.

    Thank you very much for your time and consideration. I look forward to your guidance and appreciate your support in advance.

    Reply
  4. Hello,

    it fails with the error below:

    TASK [kubernetes-bootstrap : Update system packages] *************************************************************************************************
    fatal: [svldev-k8s-n02]: FAILED! => {“changed”: false, “msg”: “Failed to download metadata for repo ‘devel_kubic_libcontainers_stable’: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried”, “rc”: 1, “results”: []}
    fatal: [svldev-k8s-m01]: FAILED! => {“changed”: false, “msg”: “Failed to download metadata for repo ‘devel_kubic_libcontainers_stable’: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried”, “rc”: 1, “results”: []}
    fatal: [svldev-k8s-n01]: FAILED! => {“changed”: false, “msg”: “Failed to download metadata for repo ‘devel_kubic_libcontainers_stable’: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried”, “rc”: 1, “results”: []}
    fatal: [svldev-k8s-m02]: FAILED! => {“changed”: false, “msg”: “Failed to download metadata for repo ‘devel_kubic_libcontainers_stable’: Cannot download repomd.xml: Cannot download repodata/repomd.xml: All mirrors were tried”, “rc”: 1, “results”: []}

    Reply

Leave a Comment

Press ESC to close