K3s puts a working Kubernetes node on Ubuntu 24.04 in under a minute, so the install is the easy part. What bites comes after it. The kubeconfig is readable only by root, UFW rules do not cover the ports you think they cover, and the bundled Traefik chart renamed a provider key and ignores the old spelling without a word.
Here you get the single-node install. You install K3s on Ubuntu 24.04 from the stable channel, check the bundled components and run kubectl as a normal user. Then you put a test app behind Traefik and turn on secrets encryption on the running server. A worker join is covered briefly, and the full multi-node build lives in our K3s cluster guide for Ubuntu 24.04. Every command below was run on Ubuntu 24.04 with K3s v1.36.5+k3s1 from the stable channel in November 2026, and the output blocks come from that lab.
Check what you need
K3s needs at least 2 CPU cores and 2 GB of RAM for a server, and an agent needs 1 core and 512 MB. We gave the server VM 4 GB because the bundled add-ons settle at about 1.2 GB used system-wide once everything is up, which leaves a 2 GB box very little room for your own workloads. You need:
- Ubuntu 24.04 server with a static or reserved IP
- 2 vCPU, 4 GB RAM, 20 GB disk for the server
- A user with sudo and outbound HTTPS to github.com
The lab used two VMs on the same LAN: k3s01 as the server and k3s02 as the optional worker. Everything on the server runs from one k3s binary, and only ports 6443, 80 and 443 matter to clients:

Patch the box before you start, because the installer pulls nothing from apt and will not do it for you. Our clone moved from 24.04.4 to 24.04.5 during this upgrade, which is why the server and the worker report different point releases later on. Run the upgrade now.
sudo apt update && sudo apt -y upgrade
Reboot if a new kernel landed. A reboot now costs nothing, because nothing runs on the box yet. Once workloads land, the same reboot means draining the node first, so take it while it is free.
Open the firewall with UFW
UFW ships inactive on Ubuntu 24.04, and the K3s docs recommend leaving it off. If you want it on, these sources and ports must be allowed in, or CoreDNS and metrics-server cannot reach the API server:
- 6443/tcp for the API server and agent joins
- 10.42.0.0/16, the pod network
- 10.43.0.0/16, the service network
- 8472/udp and 10250/tcp from the LAN, once you add a worker
Scope the last two to the LAN because the flannel VXLAN port should never face the internet. Add the rules and enable the firewall in one go.
sudo ufw allow OpenSSH
sudo ufw allow 6443/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 allow from 192.168.1.0/24 to any port 8472 proto udp
sudo ufw allow from 192.168.1.0/24 to any port 10250 proto tcp
sudo ufw --force enable
Replace 192.168.1.0/24 with your own LAN, then check the result with sudo ufw status numbered. The rule list should look like this:
Status: active
To Action From
-- ------ ----
[ 1] OpenSSH ALLOW IN Anywhere
[ 2] 6443/tcp ALLOW IN Anywhere
[ 3] Anywhere ALLOW IN 10.42.0.0/16
[ 4] Anywhere ALLOW IN 10.43.0.0/16
[ 5] 8472/udp ALLOW IN 192.168.1.0/24
[ 6] 10250/tcp ALLOW IN 192.168.1.0/24
[ 7] OpenSSH (v6) ALLOW IN Anywhere (v6)
[ 8] 6443/tcp (v6) ALLOW IN Anywhere (v6)
There is no rule for 80 or 443 in that list. You’ll see in the Traefik section that the ingress still answered from another machine on the LAN, so UFW is not what protects those ports on a K3s node.
Install K3s from the stable channel
The script at get.k3s.io resolves a channel to a release, verifies the binary’s checksum and writes a systemd unit. Setting INSTALL_K3S_CHANNEL=stable is the default anyway, but spelling it out keeps your intent in the shell history. A rerun months from now lands on whatever is stable then rather than on latest, which already carries the next minor.
curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=stable sh -
Our first run took 24 seconds of wall time and a rerun on the same VM took 2 minutes 29 seconds, and the difference was all GitHub download speed. The second line names the release it picked, so check it before you go further. The symlink lines show that the three CLI tools all point at the same k3s binary.

To pin a release instead of a channel, swap the variable for INSTALL_K3S_VERSION with the exact tag from the K3s releases page. That is what you want on anything you rebuild from scripts, because the stable channel moves to a new minor without warning and a rebuilt node that comes up one minor ahead of its neighbours is a support headache you don’t need.
Verify the node and container runtime
Check the service first, because a K3s that failed to start still leaves the binary and symlinks in place. You want active (running) and the two modprobe lines ending in SUCCESS.
systemctl status k3s --no-pager | head -12
Then ask the API server for the node and containerd for its version. Use sudo on both for now, and the next section explains why plain kubectl fails.
sudo kubectl get nodes -o wide
sudo crictl version
The node reported Ready 2 seconds after the service started. The ROLES column now reads only control-plane, where the old 1.29-era output on this page showed control-plane,master. Any script or node selector that still matches on “master” will match nothing, so update it before you upgrade an older cluster.

The binary reports its own build too, which is the string to quote in a bug report:
k3s --version
It prints the K3s tag with its commit, and the Go toolchain it was built with:
k3s version v1.36.5+k3s1 (3dd98cc5)
go version go1.26.8
With the service, node and runtime confirmed, fix the kubeconfig permissions before going further.
Use kubectl without sudo
K3s writes the admin kubeconfig to /etc/rancher/k3s/k3s.yaml with mode 600 and root ownership. The first kubectl you run as your own user therefore fails with this error.
time="2026-11-04T04:55:04Z" level=warning msg="Unable to read /etc/rancher/k3s/k3s.yaml, please start server with --write-kubeconfig-mode or --write-kubeconfig-group to modify kube config permissions"
error: error loading config file "/etc/rancher/k3s/k3s.yaml": open /etc/rancher/k3s/k3s.yaml: permission denied
The warning suggests --write-kubeconfig-mode 644, which works but makes a cluster-admin credential readable by every local account on the box. Copy the file into your home directory instead and keep it private.
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $(id -u):$(id -g) ~/.kube/config
chmod 600 ~/.kube/config
echo "export KUBECONFIG=~/.kube/config" >> ~/.bashrc
export KUBECONFIG=~/.kube/config
Now kubectl get nodes works without sudo. The copy embeds a client certificate that K3s issues for 365 days and renews on a restart close to expiry, so repeat the copy after a renewal or your kubectl starts failing roughly a year from now. It also points at https://127.0.0.1:6443, so to manage the cluster from a workstation, change that address to the server’s IP after copying the file over. Tools like k9s read the same file.
Check the bundled components
List what K3s deployed into kube-system and the image behind each container. This is the quickest way to answer “which Traefik am I running”, since the version is in the image tag and nowhere else obvious.
kubectl get pods -A
kubectl get pods -A -o jsonpath='{range .items[*]}{range .spec.containers[*]}{.image}{"\n"}{end}{end}' | sort -u
Two helm-install jobs show Completed, and the Traefik one restarted along the way (once on our first run, twice on the second). That is normal, because the Traefik chart job starts before the CRD job finishes and simply retries. Traefik, CoreDNS, metrics-server and the local-path provisioner are all Running.

There’s no etcd pod or process here. A single server without --cluster-init keeps cluster state in SQLite through kine, and you’ll find state.db under /var/lib/rancher/k3s/server/db/. That is fine for one node, and it is lighter than etcd. Embedded etcd only comes into play when you build an HA control plane, so back up that directory along with the server token if this node matters to you.
Expose a test app through Traefik
K3s ships Traefik as the ingress controller and its own ServiceLB (klipper-lb), which gives the Traefik LoadBalancer service the node’s IP. That means an Ingress works on a bare VM with no MetalLB and no cloud load balancer. Create a two-replica nginx deployment, a service and an Ingress for a test hostname.
kubectl create deployment web --image=nginx:stable --replicas=2
kubectl expose deployment web --port=80
kubectl create ingress web --class=traefik --rule="web.example.com/*=web:80"
kubectl rollout status deployment web --timeout=120s
Traefik routes on the Host header, so a request without it should 404 and a request with it should reach nginx. Check the Ingress and the Traefik service, then curl both ways, swapping in your own node IP.
kubectl get ingress web
kubectl get svc -n kube-system traefik
curl -s -H "Host: web.example.com" http://192.168.1.174/ | grep -i "<title>"
curl -s -o /dev/null -w "nohost %{http_code}\n" http://192.168.1.174/
ServiceLB gave the Traefik service, and with it the Ingress, the node IP as its address. The two curls return the nginx welcome page and a 404 as expected.

We ran the same Host-header curl from the second VM on the LAN with UFW active on the server, and it returned 200. So did a request to the Traefik NodePort (30172 in that run). Kubernetes rewrites traffic for the LoadBalancer IP and NodePorts in the iptables nat table before routing, so those packets are forwarded to the pod and UFW’s input rules never see them. If the node sits on an untrusted network, restrict 80, 443 and the NodePort range upstream or in a Traefik middleware, because UFW alone won’t. For the Traefik dashboard itself, see enabling the Traefik dashboard on K3s.
Customize Traefik with a HelmChartConfig
Never edit /var/lib/rancher/k3s/server/manifests/traefik.yaml, because K3s rewrites it on every start and your change disappears. Overrides go in a HelmChartConfig named traefik next to it. The bundled chart is now the v40 line, and that matters if you copied an old override from a blog. The ingress-nginx compatibility provider was renamed from kubernetesIngressNginx to kubernetesIngressNGINX, a change of case that is easy to miss in review.
sudo vim /var/lib/rancher/k3s/server/manifests/traefik-config.yaml
This example turns on the provider that lets Traefik serve Ingress objects written for ingress-nginx (class nginx), which helps when you move manifests off an nginx cluster.
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
providers:
kubernetesIngressNGINX:
enabled: true
The helm-controller picks the file up within a few seconds, runs a new helm-install-traefik job and rolls the Traefik pod. Confirm the provider flags made it onto the deployment.
kubectl -n kube-system get deploy traefik -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i -E "nginx|kubernetesingress"
With the new key, eight kubernetesingressnginx flags appear on the Traefik deployment:

Then we put the old spelling back to see what breaks. Nothing visibly does, and that is the problem. The Helm upgrade reported STATUS: deployed and “Upgrade complete” on both of our runs, yet the deployment carried zero nginx flags, so the provider was silently off. If ingress-nginx objects stopped routing after a K3s upgrade, check this key first.
Turn on secrets encryption
By default K3s stores Secrets in its datastore base64-encoded, not encrypted, so anyone who copies state.db can read them. Older K3s only took --secrets-encryption at first start. The March 2026 patch releases (v1.35.3, v1.34.6 and v1.33.10) and everything after them, including the whole 1.36 line, let you switch it on for a cluster that is already running. Create a test Secret first so you can check it survives, then look at the current state.
kubectl create secret generic demo --from-literal=password=s3cr3t-lab
sudo k3s secrets-encrypt status
A fresh install reports no encryption config at all:
Encryption Status: Disabled, no configuration file found
Enable it, then add the flag to the K3s config file so the server keeps it across restarts. Use tee -a because a plain tee would overwrite any settings already in that file, and K3s would start without them. The restart is not optional, because the docs warn that not following the procedure can leave the cluster permanently corrupted.
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
After the restart the status still says Disabled, but the rotation stage is start. The encryption config is staged but no key is active and nothing has been re-encrypted yet, so the demo Secret is still plain base64 on disk.
Encryption Status: Disabled
Current Rotation Stage: start
Server Encryption Hashes: All hashes match
Rotating the keys does the actual work. It generates the AES-CBC key and rewrites every existing Secret with it. The docs ask for one more restart afterwards, so check the status, restart and check again.
sudo k3s secrets-encrypt rotate-keys
sudo k3s secrets-encrypt status
sudo systemctl restart k3s
sudo k3s secrets-encrypt status
To confirm the Secret still reads through the API and is encrypted on disk, decode it and count the encrypted records in the SQLite files. The sh -c wrapper matters, because without it your own shell expands the glob before sudo runs and grep finds no files.
kubectl get secret demo -o jsonpath="{.data.password}" | base64 -d
sudo sh -c "grep -a -c k8s:enc:aescbc:v1 /var/lib/rancher/k3s/server/db/state.db*"
On both of our runs rotate-keys already reported Enabled and reencrypt_finished before the restart, and the status held after it. The demo Secret decoded to its value, and grep counted 10 encrypted lines in state.db and 10 in its WAL file.

Back up /var/lib/rancher/k3s/server/cred/encryption-config.json together with the token. A datastore restore without that key leaves every Secret unreadable.
Know what changed since the 1.29 builds
Nothing changed in the install command, and no install flag was added or removed in the 1.36 line, though 1.36.3 started accepting wildcards such as --tls-san=*.example.com. What did change shows up after the install, and each row below was checked on this lab server.
| Change | What we saw on the lab server |
|---|---|
| Node role label | ROLES shows control-plane only, no master |
| Traefik chart v40 | kubernetesIngressNGINX works, the old kubernetesIngressNginx key is ignored silently |
| Supervisor anonymous access | /metrics and /debug/pprof/ return 403 to anonymous requests |
| Secrets encryption | Enabled on a running server with an existing Secret |
| Datastore | Single server still uses SQLite (kine), no etcd |
The supervisor change is the one most likely to break monitoring. From the v1.34.12, v1.35.9, v1.36.5 and v1.37.1 patch releases, K3s rejects anonymous requests to the supervisor’s metrics, pprof and embedded registry routes on port 6443. Both endpoints are off by default, so to test it we turned them on in the server config and restarted K3s.
printf "enable-pprof: true\nsupervisor-metrics: true\n" | sudo tee -a /etc/rancher/k3s/config.yaml
sudo systemctl restart k3s
Then hit the endpoints once anonymously and once with the admin client certificate that K3s keeps on the server:
curl -sk https://127.0.0.1:6443/metrics
curl -sk -o /dev/null -w "%{http_code}\n" https://127.0.0.1:6443/debug/pprof/
sudo curl -sk --cert /var/lib/rancher/k3s/server/tls/client-admin.crt --key /var/lib/rancher/k3s/server/tls/client-admin.key https://127.0.0.1:6443/metrics | grep -m2 ^k3s_
Both anonymous requests get a 403, and the /metrics response names system:anonymous, while the request with the client certificate returns the k3s_certificate_expiration_seconds series:

So a Prometheus job that scraped the supervisor without credentials stops working after the upgrade, and the only symptom is a target turning red. Give it a client certificate or a bearer token with access to those paths. If you do not scrape them, delete the two lines from config.yaml and restart, since pprof has no business running on a production node.
Add a worker node
A worker needs the server URL and the join token, which lives on the server. Read it there and keep it out of shell history and chat logs, because anyone holding it can join a node to your cluster.
sudo cat /var/lib/rancher/k3s/server/node-token
Patch the worker with apt upgrade first. We skipped that on k3s02, which is why it still reports 24.04.4. Then run the same installer with K3S_URL and K3S_TOKEN set. Keep the channel identical so both nodes run the same release, and paste your real token in place of the placeholder. Our worker kept UFW inactive. If you enable it there too, give it the same pod, service, 8472/udp and 10250/tcp rules as the server.
curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=stable K3S_URL=https://192.168.1.174:6443 K3S_TOKEN='paste-node-token-here' sh -
The agent installs as k3s-agent.service. It took 1 minute 25 seconds on our first run and 15 seconds on the second, again mostly the binary download. Back on the server, scale the test deployment so the scheduler has a reason to use the new node, then check where the pods landed.
kubectl scale deployment web --replicas=4
kubectl get nodes -o wide
kubectl get pods -l app=web -o wide
Both nodes are Ready on the same release, and the two new replicas went to k3s02:

To prove the flannel VXLAN path works through UFW, curl a pod on the worker from a pod of the same deployment. Use a pod IP from your own output, here 10.42.1.4 on k3s02:
kubectl exec deploy/web -- curl -s -m 5 -o /dev/null -w "pod -> 10.42.1.4 on k3s02: %{http_code}\n" http://10.42.1.4/
A 200 means pod traffic crosses nodes with UFW active on the server:
pod -> 10.42.1.4 on k3s02: 200
Clustering beyond one worker is out of scope here. Labels, taints, Helm and an HA control plane are in the multi-node K3s guide for Ubuntu 24.04. If you’re on the newer LTS, the Ubuntu 26.04 K3s guide covers the same install there, and our k0s vs K3s vs MicroK8s benchmark shows how K3s compares on memory and startup time.
Uninstall K3s
The installer leaves an uninstall script that stops the service, kills the containers, removes the CNI interfaces and deletes /var/lib/rancher/k3s. Remove workers first and then delete their node objects from the server, or the server keeps listing them as NotReady. On each worker, run the agent script.
sudo /usr/local/bin/k3s-agent-uninstall.sh
Back on the server, delete the node object and then uninstall the server itself:
kubectl delete node k3s02
sudo /usr/local/bin/k3s-uninstall.sh
Both scripts exited 0 and removed the binary, the kubectl symlink, /etc/rancher/k3s and the data directory. A few things survive on the server, so clean them up yourself if the box will not run K3s again:
- the empty
/var/lib/rancherdirectory /etc/rancher/nodewith the node password file- the UFW rules, removed by number with
sudo ufw delete - your copy of the kubeconfig in
~/.kube/config
The kubeconfig is harmless once the server is gone, but a stale context pointing at a dead IP wastes time the next time you run kubectl against a new cluster.
For a single box, this is the whole job: one curl, a private kubeconfig, and secrets encryption switched on before real Secrets land. Skip the HelmChartConfig until you need it, but when you do, spell the provider key the v40 way and confirm the flags on the deployment rather than trusting a green Helm status.