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.
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:

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.ioand GitHub releases - Static or reserved IPs. We use 10.0.1.50 for
k3s-serverand 10.0.1.51 fork3s-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:

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:

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:

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:
| Component | Version on K3s v1.36.5+k3s1 | Where we read it |
|---|---|---|
| Kubernetes | v1.36.5 | kubectl get nodes |
| containerd | v2.3.4-k3s1.36 | crictl version |
| Traefik | 3.7.13 (chart 40.1.5+up40.1.0) | pod image, HelmChart |
| CoreDNS | 1.14.7 | pod image |
| metrics-server | v0.9.0 | pod image |
| local-path-provisioner | v0.0.37 | pod image |
| klipper-lb (ServiceLB) | v0.4.17 | pod image |
etcd (after cluster-init) | 3.6.14 | k3s journal |
| Datastore by default | SQLite 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:

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:

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:

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:
- Run
k3s secrets-encrypt enable, then addsecrets-encryption: trueto/etc/rancher/k3s/config.yamlso the setting survives restarts, and restart K3s. - Check the status: it reads
DisabledwithCurrent Rotation Stage: start, which means the key file exists but nothing is encrypted yet. - Run
rotate-keys, which generates the active key and re-encrypts every existing Secret in one pass. - 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:

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:

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 kubectl10250/tcpfrom the other node: the kubelet, sokubectl logsandexecreach pods on each node8472/udpfrom the other node: flannel VXLAN between nodes80,443/tcp: Traefik through ServiceLB, on every node10.42.0.0/16and10.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:

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:

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-nameis new in 1.37 and names the cluster, user and context in the generated kubeconfig instead ofdefault. Our upgraded server lists it ink3s server --help; on a 1.36.5 server the same grep found nothing, only the-modeand-groupvariants.- 1.37 also adds
--advertise-addressto 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-sanaccepts wildcards such as*.example.comsince 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.