Containers

Install K3s on Ubuntu 26.04 LTS

You have two Ubuntu 26.04 servers and want a Kubernetes cluster on them before lunch. K3s gets you there with one curl command per node: the first box becomes the server with the API, the datastore and Traefik, and the second joins it as an agent with a token. The install itself takes under a minute, so the time goes into the parts around it, which is where this guide spends it.

Original content from computingforgeeks.com - post 166089

We will install K3s on Ubuntu 26.04 from the stable channel and check every bundled component. Then we open UFW, join an agent, put a test app behind Traefik, turn on secrets encryption, move the datastore to embedded etcd and upgrade in place. The official requirements page doesn’t list Ubuntu 26.04, so every command and output below comes from two fresh 26.04 VMs, and we confirmed the whole run working on Ubuntu 26.04.1 LTS (kernel 7.0.0-15) with K3s v1.36.5+k3s1 in November 2026.

Review the two-node layout

Before any command, it helps to know which piece talks to which. The agent dials the server on 6443, your workstation talks to the same API port, and requests for the test app land on Traefik, which forwards them to pods on either node over the flannel VXLAN tunnel:

Architecture diagram of a two-node K3s cluster on Ubuntu 26.04: the k3s agent joins the server on 6443/tcp, kubectl reaches the API on 6443/tcp, curl hits Traefik on 80/tcp, and pod traffic crosses nodes over VXLAN 8472/udp

Everything in the diagram maps to a step below, and the firewall section opens exactly those ports. If you only need one node, skip the agent and firewall steps and the rest still works on a single box.

Prerequisites

K3s is light, but the server carries the API server, the datastore and Traefik in one process tree, so it is the box that needs the memory. Our server settled at about 1.1 GiB used on a 4 GB VM with a test app running, and the agent sat at 292 MiB on 2 GB. Plan the server around that number plus whatever your workloads need, not around the 2 GB floor the K3s requirements page gives for a server (its 512 MB figure is the agent minimum).

  • One Ubuntu 26.04 LTS server for the K3s server: 2 vCPU and 2 GB RAM is the floor, 4 GB is what we’d run (our lab: 2 vCPU, 4 GB, 20 GB disk)
  • One or more Ubuntu 26.04 machines for agents: 2 vCPU and 2 GB RAM each (our lab: 2 vCPU, 2 GB)
  • A sudo user and outbound HTTPS to get.k3s.io and GitHub releases
  • Static or reserved IPs. We use 10.0.1.50 for k3s-server and 10.0.1.51 for k3s-agent

A fresh cloud image keeps running cloud-init after SSH comes up. On our clones its package upgrade took 222 seconds, moved the image from 26.04 to 26.04.1 and restarted networking, which renamed the NIC from ens18 to eth0 and handed the VM a new DHCP address. If your SSH session drops while you wait, reconnect (on the new address if DHCP changed it) and run the wait again; a reserved IP avoids the change. Wait for it before installing anything, because the K3s installer we started too early lost its D-Bus connection mid-run (covered in the errors section). If you are starting from a bare install, the Ubuntu 26.04 initial server setup covers users, SSH and updates first.

cloud-init status --wait

It returns status: done once the first boot is finished. On a machine that wasn’t built from a cloud image, the command either isn’t there or returns at once, and you can move on.

Install the K3s server from the stable channel

Run this part on the server only. The install script resolves a channel to a release, downloads and verifies the binary, then writes a systemd unit. We set INSTALL_K3S_CHANNEL=stable explicitly even though stable is the default, because it documents the choice in your shell history and makes the later upgrade to latest a one-word change:

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

Look at the second line for the release the script picked. On our run stable resolved to v1.36.5+k3s1, while latest pointed at v1.37.1+k3s1. The two lines that download the checksum file and the binary from that release’s GitHub page sit between the first lines and Verifying binary download; the screenshot further down shows them in full:

[INFO]  Finding release for channel stable
[INFO]  Using v1.36.5+k3s1 as release
[INFO]  Verifying binary download
[INFO]  Installing k3s to /usr/local/bin/k3s
[INFO]  Skipping installation of SELinux RPM
[INFO]  Creating /usr/local/bin/kubectl symlink to k3s
[INFO]  Creating /usr/local/bin/crictl symlink to k3s
[INFO]  Creating /usr/local/bin/ctr symlink to k3s
[INFO]  Creating killall script /usr/local/bin/k3s-killall.sh
[INFO]  Creating uninstall script /usr/local/bin/k3s-uninstall.sh
[INFO]  env: Creating environment file /etc/systemd/system/k3s.service.env
[INFO]  systemd: Creating service file /etc/systemd/system/k3s.service
[INFO]  systemd: Enabling k3s unit
Created symlink '/etc/systemd/system/multi-user.target.wants/k3s.service' → '/etc/systemd/system/k3s.service'.
[INFO]  systemd: Starting k3s

Notice the symlinks the script created: the kubectl, crictl and ctr commands all point at the one K3s binary, so there is nothing else to install for day-to-day work. Confirm which build you got:

k3s --version

Keep the commit hash and Go version handy when you compare against a GitHub issue.

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

On the lab server’s terminal, the same install and version check looked like this:

K3s install script on Ubuntu 26.04 picking v1.36.5+k3s1 from the stable channel, then k3s --version

You’ll also see that the script skipped the SELinux RPM. That is expected on Ubuntu, which uses AppArmor, so you do not need the k3s-selinux step that Rocky and RHEL guides carry.

Check the node, containerd and bundled components

Still on the server, check that the node registered and which runtime it reports. The kubeconfig is root-only at this point, so these first checks use sudo:

sudo kubectl get nodes -o wide
sudo crictl version

The node should be Ready with the control-plane role, and both commands should agree on the containerd build that K3s embeds:

NAME                STATUS   ROLES           AGE    VERSION        INTERNAL-IP     EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION             CONTAINER-RUNTIME
k3s-server          Ready    control-plane   4m4s   v1.36.5+k3s1   10.0.1.50       <none>        Ubuntu 26.04.1 LTS   7.0.0-15-generic (amd64)   containerd://2.3.4-k3s1.36
Version:  0.1.0
RuntimeName:  containerd
RuntimeVersion:  v2.3.4-k3s1.36
RuntimeApiVersion:  v1

The ROLES column shows control-plane alone. Older K3s releases also printed master, so scripts that select nodes by that label need updating. The same two commands as a terminal capture:

kubectl get nodes -o wide and crictl version on Ubuntu 26.04 showing K3s v1.36.5 with containerd 2.3.4

Next, list the system pods and the images behind them. The image tags are the honest answer to “which Traefik and CoreDNS did I get”, since the K3s release notes describe a whole minor line at once:

sudo kubectl get pods -A
sudo kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u
sudo kubectl get helmcharts -A -o custom-columns=NAME:.metadata.name,CHART:.spec.chart,VERSION:.spec.version

Two helm-install pods show Completed, which is how K3s deploys Traefik: a job runs the bundled chart and exits. A restart count of 2 on that job is normal on first boot, because it retries until the CRDs exist:

K3s system pods, bundled component images and Traefik chart 40.1.5 on Ubuntu 26.04

Pulling the versions out of that output and the etcd log later in the guide gives the full component set for this build. Keep it handy when you’re checking a CVE or a chart compatibility note:

ComponentVersion on K3s v1.36.5+k3s1Where we read it
Kubernetesv1.36.5kubectl get nodes
containerdv2.3.4-k3s1.36crictl version
Traefik3.7.13 (chart 40.1.5+up40.1.0)pod image, HelmChart
CoreDNS1.14.7pod image
metrics-serverv0.9.0pod image
local-path-provisionerv0.0.37pod image
klipper-lb (ServiceLB)v0.4.17pod image
etcd (after cluster-init)3.6.14k3s journal
Datastore by defaultSQLite via kine/var/lib/rancher/k3s/server/db/state.db

The etcd row matters because the release notes we read listed two different etcd versions for this line. The log on our server settled it at 3.6.14, and after the upgrade to v1.37.1 at the end of this guide it read 3.7.1. Ubuntu 26.04 itself raised no warnings: sudo k3s check-config ended in STATUS: pass, the kernel mounts only cgroup2, and iptables -V reports the nf_tables backend, which K3s uses without any extra package.

Set a readable kubeconfig for kubectl

Out of the installer, /etc/rancher/k3s/k3s.yaml is mode 600 and owned by root, so plain kubectl as your user fails with a permission error. On a single-admin box, the simplest fix is a K3s config file that sets the mode. Everything K3s reads at startup can live in /etc/rancher/k3s/config.yaml, and we’ll keep adding to this file in later steps:

printf 'write-kubeconfig-mode: "0644"\n' | sudo tee /etc/rancher/k3s/config.yaml
sudo systemctl restart k3s

After the restart the file is world-readable and kubectl works without sudo:

ls -l /etc/rancher/k3s/k3s.yaml
kubectl get nodes

Both lines should change from the earlier run:

-rw-r--r-- 1 root root 2945 Nov  4 05:05 /etc/rancher/k3s/k3s.yaml
NAME                STATUS   ROLES           AGE     VERSION
k3s-server          Ready    control-plane   5m37s   v1.36.5+k3s1

Mode 644 means every local user can read cluster-admin credentials, so do not do this on a shared host. There, use write-kubeconfig-group with a dedicated group, or copy the file into your home directory with sudo cp and chown it, which is what we’d pick for anything beyond a lab.

To manage the cluster from your workstation, you need kubectl installed there, then a copy of the kubeconfig pointed at the server’s address instead of 127.0.0.1. The scp as a normal user only works because of the 0644 mode set above. We ran these on the second VM, playing the workstation, where the agent’s K3s symlinks supplied kubectl:

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

The server: line now reads https://10.0.1.50:6443, and the node list comes back over the network:

NAME                STATUS   ROLES                AGE   VERSION
k3s-agent           Ready    <none>               78s   v1.36.5+k3s1
k3s-server          Ready    control-plane,etcd   13m   v1.36.5+k3s1

That run happened after the agent join and the etcd switch, which is why there are two nodes and an etcd role. The certificate in the kubeconfig carries the server’s IP as a SAN, so TLS verification passes without any --insecure-skip-tls-verify.

Test anonymous access to the supervisor

Port 6443 serves both the Kubernetes API and the K3s supervisor, which agents use to fetch their config and certificates. The v1.36.5 release notes include #14702, which rejects anonymous access to the supervisor router (the spegel, pprof and metrics paths). The same change landed in v1.35.9 and v1.37.1. We probed the paths without credentials to see what a scanner or a stale monitoring job gets:

for p in /v1-k3s/readyz /cacerts /metrics /debug/pprof/ /v2/ /version /v1-k3s/config; do printf '%-18s %s\n' $p $(curl -sk -o /dev/null -w '%{http_code}' https://127.0.0.1:6443$p); done

Only /cacerts answers anonymously, because a joining agent needs the CA before it can authenticate at all:

/v1-k3s/readyz     403
/cacerts           200
/metrics           401
/debug/pprof/      401
/v2/               401
/version           401
/v1-k3s/config     403

This is a security fix aimed at the paths the supervisor used to let through, the spegel registry mirror and pprof, plus supervisor metrics when you run with --supervisor-metrics. A default install already answers 401 on /metrics because the API server runs with --anonymous-auth=false, so you only notice the change if you had enabled one of those features and scraped it without credentials. The kubeconfig change and this probe, captured together:

K3s write-kubeconfig-mode set in config.yaml and anonymous supervisor requests answered with 401 and 403

The /v2/ path is the spegel registry mirror. It is off unless you enable embedded registry mirroring, but the router still guards it.

Deploy a test app behind Traefik

Now the app side. Traefik is already listening on 80 and 443 through ServiceLB, so all a test needs is a Deployment, a Service and an Ingress. Create the first two from the command line:

kubectl create deployment web --image=nginx:1.29-alpine --replicas=2
kubectl expose deployment web --port=80

Put the Ingress in a file so you can version it, starting with a new manifest.

vim web-ingress.yaml

Paste the rule that sends web.example.com to the web Service:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
spec:
  rules:
  - host: web.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web
            port:
              number: 80

Apply it and wait for the rollout, then check the objects and send a request with the matching Host header:

kubectl apply -f web-ingress.yaml
kubectl rollout status deployment/web --timeout=120s
kubectl get deploy,svc,ingress
kubectl get pods -o wide
curl -s -H 'Host: web.example.com' http://10.0.1.50/ | grep -i '<title>'

The Ingress picks up the traefik class without you naming it, its ADDRESS is the node IP, and the pods get addresses from 10.42.0.0/24, the server’s slice of the pod CIDR:

nginx test deployment, service and Traefik ingress on K3s answering curl with the nginx welcome page

The Welcome to nginx! title confirms the full path: host port 80, ServiceLB, Traefik, the Service, then a pod. For a real hostname, point DNS at the node and add TLS with cert-manager. To watch routes while you test, the Traefik dashboard on K3s guide shows how to expose it.

Update Traefik overrides for chart v40

You never edit /var/lib/rancher/k3s/server/manifests/traefik.yaml, because K3s rewrites it on every start. Overrides go in a HelmChartConfig named traefik in the same directory. The bundled chart is on the v40 line now, and the release notes from v1.36.2 onward carry a warning that matters if you copied an override from an older post: the ingress-nginx compatibility provider was renamed from kubernetesIngressNginx to kubernetesIngressNGINX.

We tested both spellings. First the old key, in the file K3s watches:

sudo vim /var/lib/rancher/k3s/server/manifests/traefik-config.yaml

Older guides wrote the override like this.

apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  valuesContent: |-
    providers:
      kubernetesIngressNginx:
        enabled: true

K3s re-ran the Helm job within seconds and reported success, so the job status tells you nothing. Count the provider flags on the Traefik deployment instead:

kubectl -n kube-system get deploy traefik -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -ci ingressnginx

With the old key the count was 0: the chart ignored it without a warning. We then changed the one line to kubernetesIngressNGINX: in the same file, and after the next Helm run the count went to 8 and Traefik logged the provider start:

Traefik chart v40 HelmChartConfig: old kubernetesIngressNginx key gives 0 flags, kubernetesIngressNGINX gives 8

The eight flags include --providers.kubernetesingressnginx.ingressClass=nginx, so Ingress objects with the nginx class now route through Traefik. Our traefik-class test app kept answering 200 throughout. If you do not migrate from ingress-nginx, you don’t need this provider at all, but grep any override you carry for the old spelling before upgrading.

Enable secrets encryption and check secrets-encrypt status

Back on the server: by default K3s stores Secrets base64-encoded in the datastore, so anyone with a copy of state.db or an etcd snapshot can read them. Secrets encryption fixes that with an AES-CBC key the API server applies on write. The release notes for 1.36 and 1.37 list no changes to it, so what follows is how the current build behaves. We created a test Secret first, so we could check it survives:

kubectl create secret generic demo --from-literal=password=s3cr3t-lab
sudo k3s secrets-encrypt status

On a fresh install the status is Encryption Status: Disabled, no configuration file found. The documented procedure for a cluster that is already running starts with enable, and it needs a restart before anything else. We called status straight after enable and got secret-encrypt error ID 88476, which is the server telling you it hasn’t restarted with encryption yet. The sequence that worked:

  1. Run k3s secrets-encrypt enable, then add secrets-encryption: true to /etc/rancher/k3s/config.yaml so the setting survives restarts, and restart K3s.
  2. Check the status: it reads Disabled with Current Rotation Stage: start, which means the key file exists but nothing is encrypted yet.
  3. Run rotate-keys, which generates the active key and re-encrypts every existing Secret in one pass.
  4. Restart once more and confirm the status reads Enabled.

Here are those four steps as commands:

sudo k3s secrets-encrypt enable
echo "secrets-encryption: true" | sudo tee -a /etc/rancher/k3s/config.yaml
sudo systemctl restart k3s
sudo k3s secrets-encrypt status
sudo k3s secrets-encrypt rotate-keys
sudo systemctl restart k3s
sudo k3s secrets-encrypt status

The final status is the one to keep in a runbook. Enabled plus reencrypt_finished means the rotation completed, and All hashes match means every server agrees on the key file, which matters once you have more than one:

Encryption Status: Enabled
Current Rotation Stage: reencrypt_finished
Server Encryption Hashes: All hashes match

Active  Key Type  Name
------  --------  ----
 *      AES-CBC   aescbckey-2026-11-04T05:08:44Z

We captured the full sequence as it ran, including the failed status call after the bare enable:

k3s secrets-encrypt status before and after enabling encryption and running rotate-keys on Ubuntu 26.04

A green status does not prove the bytes on disk changed, so we read the raw row for the demo Secret out of SQLite (install the sqlite3 package first). The value now starts with k8s:enc:aescbc:v1:aescbckey-2026-11-04T0, while kubectl get secret demo still decodes to s3cr3t-lab. The key itself lives in /var/lib/rancher/k3s/server/cred/encryption-config.json (mode 600), so back that file up with your snapshots, because a restored datastore without it is unreadable.

sudo sqlite3 /var/lib/rancher/k3s/server/db/state.db "select substr(value,1,40) from kine where name='/registry/secrets/default/demo' order by id desc limit 1;"

Run rotate-keys again whenever you’d rotate any other key, for example after an admin leaves. It adds a new active key and re-encrypts in place, with no downtime on a single server.

Switch the datastore to embedded etcd

A single server runs on SQLite through kine, and that is fine until you want a second server or etcd snapshots. K3s can convert an existing SQLite server to embedded etcd in place: add cluster-init to the config and restart. We did it with the test app and the encrypted Secret still in the cluster, to see whether both survive:

echo "cluster-init: true" | sudo tee -a /etc/rancher/k3s/config.yaml
sudo systemctl restart k3s

The journal shows the switch. The old process logged its SQLite pool, and the new one starts etcd and migrates the data across:

sudo journalctl -u k3s --since '-2min' --no-pager | grep -iE 'migrat|sqlite|etcd.*version|Starting etcd' | cut -c1-250 | head -8

Look for Migrating content from sqlite to etcd and the etcd-version field, which is where the 3.6.14 in the component table came from:

Nov 04 05:09:34 k3s-server k3s[40483]: time="2026-11-04T05:09:34Z" level=info msg="Configuring sqlite3 database connection pooling: maxIdle=20, maxOpen=0, maxLifetime=0s, maxIdleTime=2m0s"
Nov 04 05:09:34 k3s-server k3s[40483]: time="2026-11-04T05:09:34Z" level=info msg="Kine built with sqlite from github.com/mattn/go-sqlite3 version 3.53.4"
Nov 04 05:09:37 k3s-server k3s[40483]: I1104 05:09:37.073967   40483 apis.go:112] API group "storagemigration.k8s.io" is not enabled, skipping.
Nov 04 05:09:41 k3s-server k3s[40483]: I1104 05:09:41.411520   40483 controllermanager.go:641] "Warning: skipping controller" controller="storage-version-migrator-controller"
Nov 04 05:10:31 k3s-server k3s[41115]: time="2026-11-04T05:10:31Z" level=info msg="Starting etcd for new cluster, cluster-reset=false"
Nov 04 05:10:31 k3s-server k3s[41115]: {"level":"info","ts":"2026-11-04T05:10:31.699709Z","caller":"embed/etcd.go:323","msg":"starting an etcd server","etcd-version":"3.6.14","git-sha":"HEAD","go-version":"go1.26.8","go-os":"linux","go-arch":"
Nov 04 05:10:31 k3s-server k3s[41115]: {"level":"info","ts":"2026-11-04T05:10:31.744225Z","caller":"etcdserver/server.go:598","msg":"starting etcd server","local-member-id":"1f5063b8e4c2d15a","local-server-version":"3.6.14","cluster-version":"
Nov 04 05:10:31 k3s-server k3s[41115]: time="2026-11-04T05:10:31Z" level=info msg="Migrating content from sqlite to etcd"

Then confirm the result from three angles: the database directory, the node roles, and a snapshot, which only works on etcd:

sudo ls /var/lib/rancher/k3s/server/db
kubectl get nodes
sudo k3s etcd-snapshot save --name pre-agent

SQLite’s file is renamed state.db.migrated, an etcd directory appears, and the node gains the etcd role. The three warnings from the snapshot command are harmless; we explain them in the errors section:

K3s server migrated from SQLite to embedded etcd with cluster-init and an etcd snapshot saved

The demo Secret still decoded correctly after the migration, and secrets-encrypt status still showed the same active key. One etcd member gives you snapshots but no fault tolerance. For that you’d join two more servers with --server https://10.0.1.50:6443 and the same token to reach a three-member quorum. Their config.yaml must match this one on the cluster-wide flags, secrets-encryption: true included, and ports 2379 and 2380 must be open between servers. Follow the K3s embedded etcd HA docs; we stopped at one server in this lab.

Open the cluster ports in UFW

UFW ships inactive on the Ubuntu 26.04 cloud image, so the agent join works without any rules. The K3s docs’ first suggestion is simply ufw disable, which is fine on a private network, but turning UFW on without the right rules breaks pod networking in ways that look like DNS problems, so add these before you enable it. The K3s requirements page warns that the VXLAN port must never face the internet, because it opens the pod network to anyone, so flannel and the kubelet only accept the other node. On the server, allow the agent’s address:

sudo ufw allow OpenSSH
sudo ufw allow 6443/tcp
sudo ufw allow 80,443/tcp
sudo ufw allow from 10.0.1.51 to any port 8472 proto udp
sudo ufw allow from 10.0.1.51 to any port 10250 proto tcp
sudo ufw allow from 10.42.0.0/16 to any
sudo ufw allow from 10.43.0.0/16 to any
sudo ufw --force enable

The agent gets the mirror image of that, pointing back at the server. It doesn’t serve the API, so 6443 stays closed there:

sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw allow from 10.0.1.50 to any port 8472 proto udp
sudo ufw allow from 10.0.1.50 to any port 10250 proto tcp
sudo ufw allow from 10.42.0.0/16 to any
sudo ufw allow from 10.43.0.0/16 to any
sudo ufw --force enable

OpenSSH only covers port 22, so if your sshd listens elsewhere, allow that port first or ufw enable locks you out. Each rule maps to one wire in the diagram, and the last two are the ones people skip:

  • 6443/tcp (server only): the API and supervisor, for agents and kubectl
  • 10250/tcp from the other node: the kubelet, so kubectl logs and exec reach pods on each node
  • 8472/udp from the other node: flannel VXLAN between nodes
  • 80,443/tcp: Traefik through ServiceLB, on every node
  • 10.42.0.0/16 and 10.43.0.0/16: the default pod and service CIDRs, which the K3s requirements page tells UFW users to allow

We ran exactly these rules on both nodes in a second pass. ufw status listed 8472/udp and 10250/tcp as allowed only from the peer’s address, the agent still joined, kubectl logs read a pod on the agent over 10250, and that pod fetched the nginx page through the web service in 8 of 8 tries, with half the replicas on the server. That pass ran on a fresh SQLite server, not the etcd one above. With more nodes, add one source rule per node or use your node subnet as the source. Ports 2379 and 2380 (etcd peer traffic) only need opening between servers when you add more of them.

Add an agent node to the server

An agent needs two values from the server, its URL and the node token. Read the token on the server:

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

It is a single 108-character line starting with K10. Treat it like a password, because anyone holding it can join a node to your cluster. On the agent machine, export it once and run the installer with both values; setting K3S_URL is what makes the script install k3s-agent instead of a server:

export K3S_TOKEN='paste-the-node-token-here'
curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=stable K3S_URL=https://10.0.1.50:6443 K3S_TOKEN="${K3S_TOKEN}" sh -

Apart from the unit names, which tell you it took the agent path, the output matches the server install.

[INFO]  Creating uninstall script /usr/local/bin/k3s-agent-uninstall.sh
[INFO]  env: Creating environment file /etc/systemd/system/k3s-agent.service.env
[INFO]  systemd: Creating service file /etc/systemd/system/k3s-agent.service
[INFO]  systemd: Enabling k3s-agent unit
Created symlink '/etc/systemd/system/multi-user.target.wants/k3s-agent.service' → '/etc/systemd/system/k3s-agent.service'.
[INFO]  systemd: Starting k3s-agent

Back on the server, check both nodes, then scale the test app so the scheduler puts replicas on the agent:

kubectl get nodes -o wide
kubectl scale deployment web --replicas=4
kubectl get pods -o wide

About 30 seconds after the installer returned, the agent showed Ready with an AGE of 42s, no role and the same containerd build. The two new replicas got 10.42.1.x addresses, the agent’s slice of the pod CIDR:

Two-node K3s cluster on Ubuntu 26.04 with web pods spread across the server and the agent

To verify the round trip across nodes, send requests to the agent’s IP instead of the server’s. ServiceLB runs Traefik’s host port on every node, so the agent accepts the request and VXLAN carries it to whichever pod Traefik picks:

for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' -H 'Host: web.example.com' http://10.0.1.51/; done; echo

All four came back 200 200 200 200. At this point kubectl top nodes showed the server at 130m CPU and 1175Mi, the agent at 212m and 292Mi, which is the memory gap the prerequisites warned about. If you are weighing K3s against other small distributions on that number, our k0s vs k3s vs MicroK8s benchmark measures idle memory and install time on identical VMs.

Upgrade K3s in place

Upgrading is the same install command with a different channel. Take an etcd snapshot first, then upgrade the servers before the agents, since the Kubernetes version skew policy lets a kubelet run older than the API server but not newer. Don’t skip minor versions either: go 1.36 to 1.37, not 1.35 to 1.37. We moved our server to the latest channel to see the 1.37 changes:

sudo k3s etcd-snapshot save --name pre-upgrade
curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=latest sh -
k3s --version
kubectl get nodes

The server came back on v1.37.1+k3s1 while the agent stayed on v1.36.5, and both remained Ready. In the capture we trimmed the installer output to its release and start lines and filtered the snapshot’s config-file warnings with grep -v, the same three lines explained in the errors section:

K3s server upgraded in place from v1.36.5 to v1.37.1 through the latest channel after an etcd snapshot

After the upgrade, Secrets stayed encrypted with the same key, Traefik kept the 3.7.13 image and the test app still returned 200. Embedded etcd moved to 3.7.1. Run the agent upgrade with the same INSTALL_K3S_CHANNEL and the K3S_URL and K3S_TOKEN values from the join. For production we’d stay on stable and let latest soak for a few weeks.

A few flag changes between these releases are worth knowing before you write a config file:

  • --write-kubeconfig-name is new in 1.37 and names the cluster, user and context in the generated kubeconfig instead of default. Our upgraded server lists it in k3s server --help; on a 1.36.5 server the same grep found nothing, only the -mode and -group variants.
  • 1.37 also adds --advertise-address to the API server certificate SANs, per its release notes. Our server never set a separate advertise address, so its SAN list held only the usual names: localhost, 127.0.0.1 and ::1, the kubernetes.* service names, the hostname, the node IP and the service IP.
  • --tls-san accepts wildcards such as *.example.com since 1.36.3.
  • 1.36.5 fixes --flannel-cni-conf, which before #14598 never reached the embedded flannel. If you set it and saw no effect on an older build, that was the bug.

All four come from the K3s v1.36 release notes and the 1.37 page next to them. Only the first one was visible in our lab output; we didn’t run a custom flannel CNI config or wildcard SAN.

Fix the errors the lab hit

Every message below came up during this run on Ubuntu 26.04. None needed a reinstall, and two of them are not errors at all.

Unable to read /etc/rancher/k3s/k3s.yaml, please start server with –write-kubeconfig-mode

Plain kubectl as a normal user right after the install prints this warning followed by error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied. The file is root-only by design. Use sudo kubectl, set write-kubeconfig-mode in config.yaml as shown above, or copy the file to ~/.kube/config and own it.

secret-encrypt error ID 88476: missing annotation on node

The CLI only shows Internal error occurred: secret-encrypt error ID 88476 and sends you to the server log, where journalctl -u k3s adds missing annotation on node k3s-server. We got it by calling status right after k3s secrets-encrypt enable, before restarting the server. Add secrets-encryption: true to the config, restart, and run rotate-keys; the status works from then on. The ID number is random per request, so search for the text after it.

Error: see server log for details: etcd datastore disabled

k3s etcd-snapshot ls on a default single server fails with this, because the datastore is SQLite and there is no etcd to snapshot. Back up /var/lib/rancher/k3s/server/db/ and the token file instead, or convert the server with cluster-init as shown earlier, after which snapshots work.

Unknown flag –write-kubeconfig-mode found in config.yaml, skipping

k3s etcd-snapshot reads the same config.yaml as the server but only understands its own flags, so it warns once per server-only key. With our three keys it printed three lines and then Snapshot pre-agent-k3s-server-1793769091 saved. The warnings are harmless and the snapshot is fine.

D-Bus connection terminated while waiting for jobs

Our very first install run ended with this line and Failed to wait for response: Connection reset by peer, right after systemd: Starting k3s. cloud-init was still running its first-boot package upgrade, which restarted system services and networking under the script, and SSH refused connections for a short while. K3s itself came up active, and re-running the installer printed No change detected so skipping service start. Run cloud-init status --wait before installing on a fresh cloud image to avoid it.

Remove K3s from both nodes

Remove agents first, so the server can drop them from the node list cleanly. On each agent:

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

On the server, delete the node object and run the server script.

kubectl delete node k3s-agent
sudo /usr/local/bin/k3s-uninstall.sh

The scripts stop the units, tear down the CNI interfaces and iptables rules, delete /var/lib/rancher/k3s and remove the binary and its symlinks, so which k3s kubectl came back empty afterwards. One thing stays behind on both machines: /etc/rancher/node, the directory that holds the node’s join password. It is a few bytes and harmless, but delete it with sudo rm -rf /etc/rancher/node when you want the machine to carry no trace of the old cluster.

Other systems give the same install their own quirks, so we keep separate tested guides for K3s on Ubuntu 24.04, K3s on Debian 13 and a K3s quickstart for Rocky Linux 10. If you need kubeadm’s control over the CNI and control plane instead, see kubeadm on Ubuntu 26.04, and for small hardware the Raspberry Pi 5 K3s cluster build compares three ways to rack the boards.

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 CapRover Docker Install on Debian 13: Deploy Your First App Containers CapRover Docker Install on Debian 13: Deploy Your First App Configure MongoDB Replication On Ubuntu 22.04|20.04|18.04|16.04 Databases Configure MongoDB Replication On Ubuntu 22.04|20.04|18.04|16.04

Leave a Comment

Press ESC to close