How To

Install Kubernetes on Ubuntu 24.04 Using K3s

Kubeadm is a lot of machinery for a cluster of two or three Ubuntu servers, and K3s is the shortcut we reach for instead. It packs the API server, scheduler, containerd, Flannel, CoreDNS, Traefik and a storage provisioner into one binary, so a server node and an agent node each take a single install command.

Original content from computingforgeeks.com - post 75879

This guide builds a multi-node K3s cluster on Ubuntu 24.04, then deploys a workload behind Traefik ingress, installs Helm, tests persistent volumes and moves the datastore to embedded etcd. We rebuilt the whole thing in November 2026 on two fresh Ubuntu 24.04.5 VMs with UFW turned on, using k3s v1.36.5+k3s1 from the stable channel and Helm v4.3.1, so every output below comes from that run. K3s is a CNCF project and a certified Kubernetes distribution, so the manifests and charts here work on any conformant cluster.

If one machine is all you need for development or a homelab service, the single-node K3s install on Ubuntu 24.04 is the shorter path. Readers comparing distributions first may prefer our k0s vs k3s vs MicroK8s comparison.

The cluster layout

We run one server node and one agent node. The server holds the Kubernetes API, the datastore and the bundled add-ons; the agent only runs kubelet and containerd and takes workloads. That split keeps the agent cheap, but it also means the server is a single point of failure for scheduling until you add more servers on embedded etcd, which the configuration section covers.

Two-node K3s cluster on Ubuntu 24.04: admin and clients reach the k3s server on 6443 and Traefik with ServiceLB on port 80, the k3s agent joins the server over 6443 and 8472/udp VXLAN, and app pods spread across both nodes

Traffic for applications enters through Traefik on port 80 or 443 of either node, because the bundled ServiceLB runs a small proxy pod on every node. Admin traffic goes to the API on 6443, and node-to-node pod traffic rides Flannel VXLAN on 8472/udp. Those three port groups are what the firewall rules later open. Keeping the picture this small is deliberate: every extra hop, such as an external load balancer or a separate etcd cluster, is something to size, patch and monitor, and a two-node cluster rarely earns that overhead until it carries production traffic.

What k3s installs by default

Everything in this table arrived with the default install on our server node; we read the image tags off the running pods and the node status. Nothing here needs a separate install, which is the main reason K3s suits small clusters where nobody wants to babysit add-ons.

ComponentVersion (v1.36.5+k3s1)Purpose
containerd2.3.4-k3s1.36Container runtime (no Docker needed)
FlannelBuilt into the k3s binaryPod networking over VXLAN
CoreDNS1.14.7Cluster DNS
Traefik3.7.13 (chart traefik-40.1.5)Ingress controller
ServiceLB (klipper-lb)v0.4.17LoadBalancer services on bare metal
local-path-provisionerv0.0.37Default StorageClass
metrics-serverv0.9.0Resource metrics for kubectl top
SQLite via kineEmbeddedDefault datastore (embedded etcd optional)

The trade-off is that you accept Rancher’s choices. In practice this means Traefik owns ports 80 and 443 on every node, and local-path volumes live on whichever node first ran the pod. Both can be switched off with install flags, shown near the end. We keep the defaults for this build because they let us prove ingress and storage with zero extra components, and swapping them later is a reinstall flag rather than a migration, as long as you decide before workloads depend on them.

Prerequisites

The K3s minimum is 2 cores and 2 GB of RAM for a server and 1 core with 512 MB for an agent, per the K3s requirements page. Our server sat at 1.4 GB of memory a minute after install with no workloads, so we treat 4 GB as the practical floor for the server:

  • Servers: two or more Ubuntu 24.04 machines, each with a unique hostname, since K3s refuses duplicate node names.
  • Sizing: 2 vCPU and 4 GB RAM per node in our lab; add memory in proportion to the workloads you schedule.
  • Network: nodes reach each other on 6443/tcp, 8472/udp and 10250/tcp, plus 2379-2380/tcp if you run embedded etcd.
  • Access: a user with sudo on every node.

The two lab nodes look like this. Substitute your own addresses wherever 10.0.1.50 and 10.0.1.51 appear in the commands. The two machines sit on one flat subnet with nothing filtering between them except UFW on each host, which is the simplest shape to debug and the one most homelab and branch-office readers will have.

HostnameIP AddressRoleSpecs
k3s-master10.0.1.50Server (control plane)2 vCPU, 4 GB RAM
k3s-worker10.0.1.51Agent (worker)2 vCPU, 4 GB RAM

Node preparation

Run these steps on every node, server and agents alike. Start by bringing packages current, because a kernel update applied after K3s is running means draining and rebooting a live node later, while a reboot now costs nothing. Our template picked up a new Ubuntu point release during this step, so expect a real download on an older image.

sudo apt update && sudo apt -y upgrade

Reboot if the upgrade pulled in a new kernel or core libraries. The /var/run/reboot-required file exists when Ubuntu thinks one is needed, so check for it if you are unsure.

sudo systemctl reboot

Give each node the name you want to see in kubectl get nodes. K3s registers a node under its hostname at first start, and renaming a host afterwards leaves the old node object behind, so set the names before installing anything. On the server, run this.

sudo hostnamectl set-hostname k3s-master

The worker gets its own name the same way, and every further worker needs a unique one. K3s ties each node name to a node password stored on the server, so a second host registering under a name already in use is rejected rather than merged. If your provisioning reuses hostnames, the --node-name or --with-node-id install flags avoid the clash.

sudo hostnamectl set-hostname k3s-worker

Without internal DNS, host entries let the nodes resolve each other by name. K3s itself joins by IP, so this is mainly for you and for any tooling that addresses nodes by name. Open /etc/hosts on every node.

sudo vim /etc/hosts

Append both nodes, adjusting the addresses to your network. Keep the same two lines on every node so lookups agree everywhere.

10.0.1.50 k3s-master
10.0.1.51 k3s-worker

Check with getent hosts k3s-worker from the server; it should print the worker’s address before you move on. If it prints nothing, the entry has a typo, and fixing it now is cheaper than chasing a name lookup inside a failing pod later.

The server node install

The install script reads the stable channel, downloads the matching binary, verifies its checksum and writes a systemd unit. Because it follows the channel, the release you get depends on the day you run it, which is fine for a first cluster and a problem for a fleet; pinning is covered later. On the server node, the default form is all you need.

curl -sfL https://get.k3s.io | sh -

The second line of the output names the release the channel resolved to, which was v1.36.5+k3s1 in our run. The script also creates three symlinks to the same binary, so there is no separate client package to install.

  • kubectl: the standard Kubernetes client, reading the root-only K3s kubeconfig until you give your user a copy.
  • crictl: talks to containerd through the CRI, handy for checking images and containers when a pod will not start.
  • ctr: the low-level containerd client, which you rarely need unless you are importing images by hand.

The full installer run, ending with the version check, looked like this. Notice the last lines, because the systemd unit is enabled as well as started, so the server comes back by itself after a reboot.

K3s installer on Ubuntu 24.04 resolving the stable channel to v1.36.5+k3s1, creating the k3s systemd unit, and k3s --version output

Confirm the unit is active and the binary reports the version you expect. If the unit is in a restart loop instead, journalctl -u k3s usually names a port already in use or a datastore error within the first few lines.

sudo systemctl status k3s --no-pager
k3s --version

Our server showed active (running) with about 1.4 GB of memory in use, and k3s --version printed the release and the Go toolchain it was built with. That memory figure is with no workloads at all, so it is the baseline your applications add to.

k3s version v1.36.5+k3s1 (3dd98cc5)
go version go1.26.8

Within a minute the node registers and the bundled add-ons start in kube-system. Check both before adding agents, since an add-on that fails on the server will fail the same way everywhere.

sudo kubectl get nodes -o wide
sudo kubectl get pods -A

The node is Ready with the bundled containerd runtime, and every add-on pod is Running. The two helm-install pods end in Completed because they are one-shot jobs the built-in Helm controller uses to deploy Traefik; a couple of restarts on the Traefik job during first boot are normal:

kubectl get nodes -o wide showing k3s-master Ready on Ubuntu 24.04.5 with containerd 2.3.4, and kube-system pods for CoreDNS, Traefik, metrics-server and local-path-provisioner

If a pod sits in ContainerCreating for more than a couple of minutes, the node is usually still pulling images; the troubleshooting section shows how to confirm that. Image pulls on a fresh node are the slowest part of the first start, so give it time before you start changing things.

Firewall rules for UFW

Ubuntu ships UFW inactive, and the K3s docs actually recommend leaving it off. We kept it on, because most production Ubuntu hosts run it, and opened only what the cluster needs. Allow SSH first, or enabling UFW will cut your session; if sshd listens on a port other than 22, allow that port instead. On the server node:

sudo ufw allow 22/tcp
sudo ufw allow 6443/tcp
sudo ufw allow 8472/udp
sudo ufw allow 10250/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable

Agents need fewer ports, since they never serve the API. They still need 10250 open so the server can reach the kubelet for logs and exec, and 8472/udp for the overlay.

sudo ufw allow 22/tcp
sudo ufw allow 8472/udp
sudo ufw allow 10250/tcp
sudo ufw --force enable

Running sudo ufw status on the server should list the six rules for both IPv4 and IPv6. Enable UFW before joining agents, as we did, so any missing rule shows up as a failed join now rather than a flaky cluster later.

sudo ufw status on the K3s server node allowing 22, 6443, 8472/udp, 10250, 80 and 443

Each port has one job, and knowing which saves time when something stops talking. When a symptom appears, match it to the port in this list first, because a single closed port explains most broken K3s clusters we see.

  • 6443/tcp: the Kubernetes API and the K3s supervisor that agents register through.
  • 8472/udp: Flannel VXLAN, the overlay that carries pod traffic between nodes.
  • 10250/tcp: the kubelet, which metrics-server, kubectl logs and kubectl exec reach.
  • 80 and 443/tcp: application traffic into Traefik.

One thing surprised us. The worker has no UFW rule for port 80, yet curl to the worker’s IP on port 80 still returned 200 from the test app later on. ServiceLB publishes ports with iptables NAT rules, which take the packet before UFW’s input filtering sees it, so UFW is not the control that hides an ingress port. If a node must not serve traffic, label only the nodes that should with svccontroller.k3s.cattle.io/enablelb=true, which switches ServiceLB to an allow list as described in the K3s networking services docs, or disable ServiceLB entirely. The K3s requirements page also suggests allowing the pod and service ranges (10.42.0.0/16 and 10.43.0.0/16) through UFW; our cross-node service tests passed without those rules, but add them if pod-to-pod traffic times out on your hosts.

Agent nodes joining the cluster

An agent proves it belongs to the cluster with the server’s node token, and the agent also uses it to verify the server’s CA, so a wrong token fails on both sides. Read it on the server.

sudo cat /var/lib/rancher/k3s/server/node-token

The value starts with K10, carries the CA hash, and ends in ::server: followed by a password. Treat it like a cluster credential: anyone holding it can join a node. On each agent, point the installer at the server’s API and pass the token:

curl -sfL https://get.k3s.io | K3S_URL=https://10.0.1.50:6443 K3S_TOKEN="your-token-here" sh -

Setting K3S_URL switches the script into agent mode, so it creates k3s-agent.service and a separate k3s-agent-uninstall.sh rather than a second server. Check the service on the worker. The agent writes its own environment file with the server URL and token, so it reconnects after a reboot without any further input from you.

sudo systemctl status k3s-agent --no-pager

The agent uses far less than the server, about 370 MB of memory in our run, because it carries no API server or datastore. That gap is why small clusters run one beefy server and several modest agents, rather than sizing every node the same.

systemctl status k3s-agent on the worker node showing the agent active and running

Back on the server, list the nodes again. A new agent normally shows Ready within a minute; NotReady for longer points at the overlay port, covered in troubleshooting.

sudo kubectl get nodes -o wide

Both nodes report Ready on the same release. The worker shows <none> under ROLES, which is how Kubernetes displays a node with no control-plane label, not a sign that something is missing. Workloads can land on either node now, because K3s does not taint the server, so expect your pods to mix across both.

kubectl get nodes -o wide listing k3s-master and k3s-worker both Ready on v1.36.5+k3s1

Repeat the agent command on every additional worker; nothing on the server needs to change as the cluster grows. The only change on the server side is capacity, so watch its memory as the agent count climbs.

kubectl access without sudo

K3s writes its admin kubeconfig to /etc/rancher/k3s/k3s.yaml with mode 600, owned by root, which is why every command so far used sudo. Give your own user a copy on the server. Leaving it root-only is the right default, because it holds full admin credentials for the cluster.

mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
echo 'export KUBECONFIG=~/.kube/config' >> ~/.bashrc
source ~/.bashrc

Now plain kubectl works. The copy is a snapshot: K3s renews the admin client certificate inside k3s.yaml on start as it nears expiry, but your copy keeps the old one, so copy the file again after a renewal or when kubectl starts reporting certificate errors.

kubectl get nodes

K3s also has an install flag that makes the original file world-readable. We avoid it on shared hosts, because that file is a cluster-admin credential and mode 644 hands it to every local account. Copying the file per user, as above, costs one extra step and keeps that credential private.

curl -sfL https://get.k3s.io | sh -s - --write-kubeconfig-mode 644

For a remote workstation, copy your user’s kubeconfig rather than the root one; Ubuntu disables root SSH logins by default, so an scp root@ copy fails on a stock server. Replace ubuntu with your own sudo user, then swap the loopback address for the server’s IP. On a Mac, BSD sed needs sed -i '' instead of sed -i. Your user’s copy has the same contents and is readable without root, so it is the simpler file to fetch.

mkdir -p ~/.kube
scp [email protected]:~/.kube/config ~/.kube/k3s-config
sed -i 's/127.0.0.1/10.0.1.50/' ~/.kube/k3s-config
export KUBECONFIG=~/.kube/k3s-config
kubectl get nodes

We ran that from the worker node with an upstream kubectl binary standing in for a workstation and got the same two Ready nodes, which confirms port 6443 is open and the server certificate covers its IP. If it hangs instead, port 6443 is blocked somewhere between your workstation and the server.

A test application behind Traefik

A two-replica Nginx deployment is the cheapest end-to-end check we know for a new cluster, because it exercises the three layers that most often break on a fresh multi-node install. If any of them fails, the cluster is not ready for real workloads, so it is worth five minutes before going further.

  • Scheduling: two replicas should land on different nodes, which proves the agent accepts work.
  • Service routing: a ClusterIP service balances between pods on both nodes, which only works if the VXLAN overlay passes traffic.
  • Ingress: Traefik maps a host name to that service, which proves ServiceLB and the ingress controller are wired up.

Open a new manifest file in your home directory on the server.

vim nginx-demo.yaml

Paste the deployment, service and ingress. The three objects share one file and are separated by --- lines, so a single apply creates them and a single delete removes them.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-demo
  labels:
    app: nginx-demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx-demo
  template:
    metadata:
      labels:
        app: nginx-demo
    spec:
      containers:
      - name: nginx
        image: nginx:alpine
        ports:
        - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-demo
spec:
  selector:
    app: nginx-demo
  ports:
  - port: 80
    targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-demo
spec:
  ingressClassName: traefik
  rules:
  - host: nginx.example.local
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: nginx-demo
            port:
              number: 80

Apply it, then check where the pods landed, what the ingress picked up, and whether Traefik answers for the host name. The curl sends a Host header because nginx.example.local does not resolve anywhere, and Traefik routes on that header, not on the IP.

kubectl apply -f nginx-demo.yaml
kubectl get pods -l app=nginx-demo -o wide
kubectl get ingress nginx-demo
curl -s -o /dev/null -w "%{http_code}\n" -H "Host: nginx.example.local" http://127.0.0.1

One pod went to each node, the ingress lists both node IPs as addresses, and the curl through Traefik returned 200. That spread is the scheduler balancing replicas across nodes by default, so you get some resilience without asking for it.

nginx-demo pods scheduled on both K3s nodes, the Traefik ingress listing both node IPs, and curl returning HTTP 200

The pod IPs tell you the overlay works: 10.42.0.x lives on the server and 10.42.1.x on the worker, each node owning its own /24 out of the pod range. To prove traffic crosses between them, we ran wget against the service from inside one pod six times and all six succeeded, so requests reached the pod on the other node through VXLAN. That is the test we would rerun after any firewall change, because a broken overlay hides well: nodes still show Ready and pods still start, but roughly half of all requests through a service fail as they land on a pod across the gap. Remove the demo when you are done:

kubectl delete -f nginx-demo.yaml

For anything public, put a real DNS name on the ingress host and add TLS; Traefik already listens on 443 for that. A certificate manager such as cert-manager can issue those certificates automatically once the host name resolves publicly.

Helm on the cluster

Most third-party software (Prometheus, cert-manager, ingress controllers) ships as Helm charts, so Helm belongs on whichever machine holds your kubeconfig. The current Helm major release has its own install script, following the same pattern as the older Helm 3 one:

curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-4 | bash

Helm reads the same KUBECONFIG as kubectl, so it needs no setup. A useful first command is listing releases in every namespace, because K3s itself installs Traefik as Helm releases:

helm version --short
helm list -A

To confirm charts deploy across both nodes, install a small public chart with two replicas. A chart with a known output is the quickest way to prove the Helm install works end to end before you trust it with something real.

helm repo add podinfo https://stefanprodan.github.io/podinfo
helm install demo podinfo/podinfo --set replicaCount=2 --wait
kubectl get pods -l app.kubernetes.io/name=demo-podinfo -o wide

Helm reported its version, the traefik and traefik-crd releases appeared in kube-system, and the podinfo replicas landed one per node. The bundled releases show up because K3s deploys Traefik through its own Helm controller, so Helm sees them like any other release.

helm version showing Helm v4.3.1, helm list with the bundled traefik releases, and a podinfo chart deployed with two replicas across both K3s nodes

Do not change the bundled Traefik releases with helm upgrade. K3s owns their HelmChart manifests, so a manual upgrade holds only until K3s next re-applies the chart, for example on a K3s upgrade, and then silently replaces your values. The supported route is a HelmChartConfig object, covered in the K3s Helm docs. Remove the test with helm uninstall demo.

Persistent storage with local-path

K3s makes the Rancher local-path provisioner the default StorageClass, so a PVC without a class still gets a volume. The binding mode matters: WaitForFirstConsumer means the volume is created on whichever node schedules the pod, and the data stays on that node. Create a claim and a pod that writes to it:

vim pvc-test.yaml

Add the PVC and the pod. As with the demo app, both objects live in one file so cleanup is one command.

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: test-pvc
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: local-path
  resources:
    requests:
      storage: 1Gi
---
apiVersion: v1
kind: Pod
metadata:
  name: pvc-test
spec:
  containers:
  - name: busybox
    image: busybox
    command: ["sh", "-c", "echo hello-from-pvc > /data/test.txt && cat /data/test.txt && sleep 3600"]
    volumeMounts:
    - name: data
      mountPath: /data
  volumes:
  - name: data
    persistentVolumeClaim:
      claimName: test-pvc

Apply it, wait about 20 seconds, then check the claim and the pod’s output. The claim stays Pending until the pod is scheduled, which is the WaitForFirstConsumer mode doing its job rather than a fault.

kubectl apply -f pvc-test.yaml
kubectl get pvc test-pvc
kubectl logs pvc-test

The claim is Bound to a 1Gi volume and the log prints the line the pod wrote to it. Reading the log proves the write went to the mounted volume, not just that Kubernetes created an object.

local-path default StorageClass in K3s, a 1Gi PVC bound, and the pod log printing hello-from-pvc

In our run the pod landed on the worker, so the data sits under /var/lib/rancher/k3s/storage/ on k3s-worker and nowhere else. If that node dies, the volume goes with it, which is fine for caches and test databases but not for state you care about. Kubernetes also pins the pod to that node through the volume’s node affinity, so if the worker goes down the pod stays Pending instead of moving to the server, and you find out at the worst moment. For replicated volumes across nodes, look at Longhorn or Ceph RBD for Kubernetes. Clean up the test:

kubectl delete -f pvc-test.yaml

The K3s and RKE2 storage basics guide goes further into access modes and storage classes. It also compares local-path with network storage in more depth than we can here.

Common k3s configuration options

The default install suits most small clusters, but four flags come up often. Pass them after sh -s - on the install command, or put the same keys in /etc/rancher/k3s/config.yaml as described in the K3s configuration docs. To test them, we uninstalled both nodes and rebuilt the cluster with all of them at once.

Disable bundled components

Drop Traefik when you plan to run your own ingress controller, otherwise both will fight over ports 80 and 443.

curl -sfL https://get.k3s.io | sh -s - --disable traefik

The flag repeats for each component, so the same form also removes ServiceLB or metrics-server, for example when MetalLB or a monitoring stack replaces them. Each disabled component is skipped at install time, so nothing is created and removed later.

curl -sfL https://get.k3s.io | sh -s - --disable traefik --disable servicelb --disable metrics-server

Disabling ServiceLB also removes the per-node port publishing described in the firewall section, so your LoadBalancer services stay pending until something else hands out addresses. Plan the replacement before you disable it, because existing services lose their addresses.

Pin a specific k3s version

The stable channel moves, so two nodes installed a month apart can end up on different releases. Pin the version for anything you plan to keep, and upgrade on purpose:

curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="v1.36.5+k3s1" sh -   # pin: release tested in this guide https://github.com/k3s-io/k3s/releases/tag/v1.36.5%2Bk3s1

Check the K3s releases page for the current patch on your minor version before pinning. Upgrading later is the same command with a newer version string, so a pinned cluster is no harder to move forward.

Use embedded etcd instead of SQLite

SQLite cannot be shared between servers, so a cluster that needs more than one control plane node starts its first server with --cluster-init. Open 2379-2380/tcp on every server for etcd peer traffic first. Embedded etcd needs more memory and disk I/O than SQLite, so we would only switch when you actually plan to add servers.

sudo ufw allow 2379:2380/tcp
curl -sfL https://get.k3s.io | sh -s - --cluster-init

Additional servers join with the token from /var/lib/rancher/k3s/server/token on the first server, plus the server argument so they come up as control plane nodes rather than agents. Each joining server also needs 6443/tcp and 2379-2380/tcp open. Agents keep joining the same way as before, pointing at any server.

curl -sfL https://get.k3s.io | K3S_URL=https://10.0.1.50:6443 K3S_TOKEN="your-token-here" sh -s - server

Our rebuild used --cluster-init --disable traefik with a pinned version and explicit node IP on the first server, and the same flags plus server on the second. No Traefik pods started, the datastore moved to /var/lib/rancher/k3s/server/db/etcd, and both nodes carried the control-plane,etcd roles:

K3s reinstalled with --cluster-init and --disable traefik: no Traefik pods, an etcd data directory, and two nodes with the control-plane,etcd role

Two etcd members is a lab shape, not an HA one: etcd needs a majority to accept writes, so losing either of two members stops the cluster just as a single server would. The K3s embedded etcd HA docs call for an odd number of servers, three at minimum. Three servers tolerate one failure and five tolerate two, so for most small teams three is the sweet spot: the extra memory per server is modest, and the cluster keeps accepting changes while you patch or reboot one control plane node at a time.

Set the node IP explicitly

On hosts with more than one interface, K3s may advertise the wrong address and agents will try to reach the API on a network they cannot route to. Name it. Cloud VMs with a private and a public interface are the usual case.

curl -sfL https://get.k3s.io | sh -s - --node-ip 10.0.1.50 --advertise-address 10.0.1.50

The INTERNAL-IP column of kubectl get nodes -o wide should then show the address you passed. If it does not, the flag did not reach the service, so check the unit’s arguments with systemctl cat k3s.

Uninstall k3s

Each role has its own uninstall script, written by the installer. Remove agents first, so the server is not left with nodes it keeps trying to reach. On each agent, run the agent script.

sudo /usr/local/bin/k3s-agent-uninstall.sh

Then remove the server itself, which also deletes the datastore and every object in the cluster. There is no undo, so make sure nothing on the cluster still matters.

sudo /usr/local/bin/k3s-uninstall.sh

We ran both scripts before rebuilding for the etcd test. They removed everything below except /etc/rancher/node, which holds the node password, so delete that directory as well before you repurpose the host. What they remove matters if you planned to keep anything.

  • Services and binaries: the systemd units, the k3s binary and the kubectl, crictl and ctr symlinks.
  • Networking: the iptables rules and CNI interfaces K3s created.
  • Data: /var/lib/rancher/k3s, including the datastore and every local-path volume, so back those up first.

The UFW rules you added stay in place, which is convenient for a rebuild and something to clean up if the host is being repurposed.

Troubleshooting

Agent node stuck in NotReady state

Start with the agent’s own log, which names the address it is trying to reach and the error it gets back.

sudo journalctl -u k3s-agent -f

The usual cause is a firewall or cloud security group blocking 6443 or 8472/udp between nodes. A blocked 6443 stops the join outright, while a blocked 8472/udp lets the node register but breaks pod traffic. Test the API from the agent.

curl -k https://10.0.1.50:6443/ping

A healthy server answers pong. A timeout means the port is closed on the path, and a connection refused means K3s is not running on the server.

Error: “open /etc/rancher/k3s/k3s.yaml: permission denied”

This appears, after a warning that starts with Unable to read /etc/rancher/k3s/k3s.yaml, when a regular user runs kubectl on the server. With KUBECONFIG unset, the K3s kubectl symlink reads the root-only /etc/rancher/k3s/k3s.yaml whenever it exists and ignores ~/.kube/config. So copying the file is not enough on its own: copy it as shown earlier and export KUBECONFIG=~/.kube/config in your shell profile.

Pods stuck in ContainerCreating

Most of the time the node is still pulling the image, which takes a while on a fresh node with a slow link. The pod’s events say which step it is on.

kubectl describe pod <pod-name>

An ErrImagePull event means the node cannot reach the registry, so check DNS and outbound access from that node. Private registries also need credentials configured on every node, which is easy to forget on a newly joined agent.

The scaling path from here

This two-node cluster runs real workloads, but it has one control plane node and storage that is tied to individual hosts. How far we would take it depends on what breaks first for your team:

  • More workers: rerun the agent command on each new node. Watch the server’s memory as agents grow; the K3s requirements page has a server sizing table by agent count.
  • Control plane redundancy: rebuild on embedded etcd with three servers and a fixed registration address (a DNS name or load balancer in front of 6443), so agents survive losing any one server.
  • Durable storage: replace local-path for stateful apps with Longhorn or Ceph once data has to outlive a node.
  • Stricter hardening: when compliance profiles matter more than footprint, an RKE2 HA cluster is the next step up on the same Rancher tooling.

For the same build on Rocky Linux as well as Ubuntu, see the K3s quickstart for Ubuntu and Rocky Linux. If you need a local K3s cluster on a laptop instead of servers, Rancher Desktop runs one on macOS or Windows without building any VMs yourself.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Ubuntu 26.04 vs 24.04 LTS: Should You Upgrade Now? Ubuntu Ubuntu 26.04 vs 24.04 LTS: Should You Upgrade Now? Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Cheat Sheets APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Install Java JDK 21 (OpenJDK 21) on Ubuntu 22.04|20.04|18.04 Programming Install Java JDK 21 (OpenJDK 21) on Ubuntu 22.04|20.04|18.04

3 thoughts on “Install Kubernetes on Ubuntu 24.04 Using K3s”

Leave a Comment

Press ESC to close