Skip to content

K3s and K3sup

RewrittenChapter 17 of 3811 min read

Rewritten for this edition in October 2026 for Kubernetes 1.37. Commands checked against the official documentation (K3s v1.37.1+k3s1, k3sup 0.13.14).

K3s is a conformant Kubernetes distribution that ships as one binary under 100 MB. The whole control plane runs as a single process, the default datastore is SQLite, and the add-ons a bare cluster lacks come pre-wired: containerd, Flannel, CoreDNS, the Traefik ingress controller, a built-in LoadBalancer called ServiceLB, and a local-path storage provisioner. On any Linux machine, curl -sfL https://get.k3s.io | sh - installs it as a service. k3sup (said "ketchup") does the same over SSH from your laptop, then hands you the kubeconfig.

This chapter builds a small K3s cluster you can keep: one server, one agent, managed from your own machine.

What K3s changes, and what it leaves alone#

K3s isn't a fork. The project calls itself a distribution: it keeps a small set of patches (its README says well under 1,000 lines) and tries to stay as close to upstream as it can. Only two things are cut from upstream Kubernetes, the in-tree storage drivers and the in-tree cloud provider. Both have out-of-tree replacements (CSI drivers and an external cloud controller manager), so nothing you learn in this book stops working.

What it adds matters more for you, because it changes how later chapters behave on this cluster:

  • Traefik is your ingress controller from minute one. An Ingress you write in the Kubernetes Ingress chapter gets served without installing anything.
  • ServiceLB gives a type: LoadBalancer Service a real address by binding host ports on the nodes. On upstream Kubernetes without a cloud, that same Service would sit at pending forever.
  • local-path-provisioner answers PersistentVolumeClaims with storageClassName: local-path. Volumes land under /var/lib/rancher/k3s/storage on whichever node the pod was scheduled to, and the pod stays tied to that node.
  • CoreDNS and metrics-server, deployed as add-ons.
  • Flannel for pod networking (VXLAN backend by default) and kube-router's network policy controller, so NetworkPolicy objects are enforced.

The datastore is the part people get wrong. A single server uses embedded SQLite, and SQLite can't be shared, so a cluster with more than one server needs either embedded etcd (start the first server with --cluster-init, then run an odd number of servers, three or more) or an external MySQL, MariaDB, PostgreSQL or etcd. You can convert a running single server from SQLite to etcd later by restarting it with --cluster-init.

Hardware is modest. The documented minimum is 2 CPU cores and 2 GB of RAM for a server, 1 core and 512 MB for an agent, on x86_64, arm64 or armhf. Those figures cover K3s and its packaged components only; your workloads come on top. The docs also warn that etcd is write-heavy and SD cards can't keep up, so a Raspberry Pi running embedded etcd wants an SSD.

Before you start#

You need one Linux host for the server and, if you want to follow the join step, a second for the agent. VMs work. So do Pis. If you'd rather stay on your laptop for now, the Setup Kubernetes Cluster chapter and KinD cover that, and you can come back here later.

Three things trip up first installs:

  1. Every node needs a unique hostname. Cloned VMs often share one. Set K3S_NODE_NAME per node, or pass --with-node-id to append a random suffix.
  2. The network has to let nodes talk. Agents reach servers on TCP 6443. Nodes reach each other on UDP 8472 for Flannel VXLAN and on TCP 10250 for metrics-server. Embedded etcd adds TCP 2379 and 2380 between servers. Keep 8472 off the public internet: anyone who can reach it can get onto your pod network.
  3. On Raspberry Pi OS, cgroups are off by default and K3s won't start. Append cgroup_memory=1 cgroup_enable=memory to the single line in /boot/firmware/cmdline.txt (on Debian 11 and older Pi OS it's /boot/cmdline.txt) and reboot.

The K3s docs recommend turning off ufw or firewalld. If you keep ufw, these are the default rules it needs (10.42.0.0/16 is the pod range, 10.43.0.0/16 the service range):

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

One version detail before you install. With no version set, the script uses the stable channel, and in October 2026 that channel points at v1.36.5+k3s1. The newest release, v1.37.1+k3s1 (published 30 September 2026), is on the latest channel. This book targets 1.37, so the commands below pin it. K3s versions read as the Kubernetes version plus a +k3s build suffix; current releases are on the K3s releases page.

Install a single-node server#

On the server host:

curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="v1.37.1+k3s1" sh -

If you'd rather follow patch releases of 1.37 automatically, there's a channel for every minor version: INSTALL_K3S_CHANNEL=v1.37 in place of INSTALL_K3S_VERSION. Plain curl -sfL https://get.k3s.io | sh - works too, it'll just give you whatever stable points at.

When the script finishes, you have:

  • a k3s systemd service that restarts after a reboot or a crash;
  • kubectl, crictl and ctr linked into /usr/local/bin (unless those commands already exist), plus k3s-killall.sh and k3s-uninstall.sh;
  • an admin kubeconfig at /etc/rancher/k3s/k3s.yaml, which the bundled kubectl reads by default.

That file is owned by root with mode 600. So the first check needs sudo:

sudo kubectl get nodes
sudo kubectl get pods -n kube-system

Give it a moment and the node should show Ready. In kube-system you'll see pods for coredns, local-path-provisioner, metrics-server and Traefik, and ServiceLB pods whose names start with svc: those are the ones holding ports 80 and 443 for Traefik's LoadBalancer Service. That's a working cluster. A single server runs the datastore, the control plane, the kubelet and the container runtime, so it'll schedule your pods without any agents at all.

Choosing what K3s installs: flags and the config file#

Server flags go after sh -s -. For example, to skip Traefik (because you'll install another ingress controller) and make the kubeconfig readable without sudo:

curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="v1.37.1+k3s1" \
  sh -s - --disable traefik --write-kubeconfig-mode 644

--disable accepts coredns, servicelb, traefik, local-storage, metrics-server and runtimes, comma-separated or repeated. Disabling a component on a running cluster uninstalls it and deletes its manifest. Two side effects worth knowing: with servicelb disabled (say, to run MetalLB), every LoadBalancer Service stays pending until another load balancer exists; and mode 644 lets any user on that host read cluster-admin credentials, which is fine on a lab box and a bad idea on a shared one.

Here's the trap. The install script writes your flags into the service definition, and re-running it (which is how you upgrade) rewrites that definition from scratch. Forget a flag on the second run and it's gone, so Traefik comes back. The fix is the config file, which the installer never touches. Every CLI flag maps to a YAML key, and repeatable flags become lists:

# /etc/rancher/k3s/config.yaml
write-kubeconfig-mode: "0644"
tls-san:
  - "k3s.home.lan"
disable:
  - traefik

K3s reads /etc/rancher/k3s/config.yaml on every start (plus any drop-ins in /etc/rancher/k3s/config.yaml.d/), however it was installed. Create it before the first install, or restart the service after editing it. When a flag and the file disagree, the flag wins. The configuration page shows the equivalent ways to pass options and how drop-ins merge.

If you add more servers later, some flags have to match on all of them (anything passed to --disable, the cluster and service CIDRs, cluster DNS). A server that disagrees refuses to join with critical configuration value mismatch.

Use the cluster from your laptop#

Running kubectl on the server gets old fast. Copy the admin kubeconfig to your own machine, into its own file so it doesn't clobber an existing ~/.kube/config that already holds your kind contexts:

export SERVER_IP=192.168.1.50
ssh ubuntu@$SERVER_IP "sudo cat /etc/rancher/k3s/k3s.yaml" > ~/.kube/k3s.yaml
chmod 600 ~/.kube/k3s.yaml

Open ~/.kube/k3s.yaml and change the URL on its server: line to https://192.168.1.50:6443 (your server's IP or name, port 6443). Then:

export KUBECONFIG=~/.kube/k3s.yaml
kubectl get nodes

If you reach the server through a DNS name or an address the server itself doesn't know about (a public IP in front of NAT, for instance), add that name with tls-san before you rely on it. Otherwise kubectl rejects the server certificate. That's what the tls-san entry in the config file above is for.

One more thing. K3s refreshes the certificates inside its admin kubeconfig every time it starts, and your copy doesn't get those updates. When the certificates in your copy expire, fetch the file again. You don't need to debug kubectl.

Add an agent node#

An agent runs the kubelet, containerd and Flannel, with no control plane and no datastore. To join one you need two values from the server: its URL and its join token.

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

A full token in the secure format starts with K10, followed by a hash of the cluster's CA certificate, ::, and the credentials. That hash is what lets the agent check it's talking to your server before it sends anything secret. Treat the whole string like a password.

On the agent host, run the same script with K3S_URL and K3S_TOKEN set. K3S_URL is what switches the installer into agent mode:

export TOKEN="paste-the-node-token-here"
curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION="v1.37.1+k3s1" \
  K3S_URL=https://192.168.1.50:6443 K3S_TOKEN="$TOKEN" sh -

Pin the same version as the server. When you upgrade later, the docs say servers go first, one at a time, then agents. The service on the agent is called k3s-agent, and sudo journalctl -u k3s-agent is the first place to look if the node never appears. Back on your laptop, kubectl get nodes should list both machines as Ready.

Rebuilding an agent with the same hostname has a catch. The cluster keeps a node-password secret for each registered node, and a fresh install with a new password gets refused. Run kubectl delete node on the old entry first; that removes the secret along with it.

Install K3s over SSH with k3sup#

k3sup is a small Go binary by Alex Ellis that runs on your laptop (macOS, Linux or Windows). It does three things over SSH: runs the same get.k3s.io script on a remote host, passes that host's IP so the server certificate covers it, and downloads a kubeconfig already pointed at the right address. In other words, it automates the last three sections. The free Community Edition is MIT-licensed; the README also describes a paid Pro edition, which this chapter doesn't use.

The remote user needs passwordless sudo. The k3sup README says Ubuntu cloud images usually allow it already. If yours doesn't, add a line like ubuntu ALL=(ALL) NOPASSWD: ALL with sudo visudo. Without it, k3sup fails with sudo: a terminal is required to read the password. Your SSH key has to be on the host too (ssh-copy-id [email protected]).

Install the client:

curl -sLS https://get.k3sup.dev | sh
sudo install k3sup /usr/local/bin/
k3sup --help

Then the server. This writes the kubeconfig to its own file under a context name you'll recognise later:

export SERVER_IP=192.168.1.50
k3sup install --ip $SERVER_IP --user ubuntu \
  --k3s-version v1.37.1+k3s1 \
  --local-path $HOME/.kube/k3s-lab \
  --context k3s-lab

Without --local-path, the file lands in ./kubeconfig in whatever directory you ran the command from, and gets overwritten next time. To fold the cluster into your main config, use --local-path $HOME/.kube/config --merge together with --context.

export KUBECONFIG=$HOME/.kube/k3s-lab
k3sup ready --context k3s-lab --kubeconfig $HOME/.kube/k3s-lab
kubectl get nodes

Joining an agent is one command. k3sup connects to the server as well as the new host (that's why it wants --server-ip), so you never copy the token by hand:

export AGENT_IP=192.168.1.51
k3sup join --ip $AGENT_IP --server-ip $SERVER_IP --user ubuntu \
  --k3s-version v1.37.1+k3s1

If the SSH user differs between the two hosts, give the server's with --server-user.

The flags you're likely to reach for:

  • --k3s-extra-args '--disable traefik' hands any K3s server flag to the installer. Quote the whole thing.
  • --no-extras is shorthand for disabling both servicelb and traefik.
  • --ssh-key ~/.ssh/id_ed25519 picks a key. k3sup doesn't read ~/.ssh/config, so host aliases and per-host keys from there are ignored.
  • --print-command shows what it's about to run over SSH, handy when something fails halfway.
  • --cluster on install starts embedded etcd, and --server on join adds another server. For quorum, run three.
  • --local installs on the machine you're sitting at, no SSH involved.

When a k3sup run fails, the problem usually lives on the host, and the README says so itself. Log in and read sudo journalctl -u k3s (or -u k3s-agent). The k3sup README lists the rest of the flags and its own troubleshooting notes.

Common mistakes#

  • Upgrading by re-running the installer without the original flags. Disabled components come back. Keep options in config.yaml.
  • Something else wants port 80 or 443. By default ServiceLB holds both on every node for Traefik, so other HostPort or NodePort pods can't bind them.
  • Two VMs cloned from one image. Same hostname, and the second node can't register.
  • Embedded etcd on Pi SD cards: the cluster slows, then falls over.

Stop, reset, uninstall#

Stopping the service doesn't stop your workloads. sudo systemctl stop k3s (or k3s-agent) halts K3s itself, but pod containers keep running on purpose, so a server can restart during an upgrade without taking apps down.

To stop everything on a node, run k3s-killall.sh. It kills all K3s containers, resets containerd state and cleans up the network setup, iptables chains included. Cluster data survives it.

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

Uninstalling is final. On a server it deletes the local datastore, every local-path volume, the node's configuration and the K3s tools; data in an external database or on external persistent volumes is left alone. Agents have their own script:

# on a server
sudo /usr/local/bin/k3s-uninstall.sh
# on an agent
sudo /usr/local/bin/k3s-agent-uninstall.sh

If you plan to rejoin that node later, delete it from the cluster first so its node-password secret goes too. k3sup Community Edition has no uninstall command (the one in its README is a Pro feature), so for a remote host run the script over SSH: ssh ubuntu@$AGENT_IP sudo /usr/local/bin/k3s-agent-uninstall.sh. The uninstall page has the full list of what each script removes.

K3s or kind for this book#

Both run real Kubernetes, and they're good at different things. kind runs each node as a Docker container, bootstraps it with kubeadm, and was built for testing Kubernetes itself. K3s runs as a system service on a Linux host and survives reboots.

kindK3s
Runs onDocker, Podman or nerdctl on your laptopa Linux host, VM or Pi
Lifetimecreated and deleted with one commanda systemd service that comes back after reboot
LoadBalancer Servicesneed cloud-provider-kind running on the hostwork out of the box (ServiceLB)
Ingressserved by cloud-provider-kind (v0.9.0+) or a controller you addTraefik preinstalled
Multi-nodeseveral containers on one machineseparate machines over the network

Pick kind when you want a clean cluster per experiment or per CI run and you'll throw it away tonight. Pick K3s when you want something that stays up: a homelab, a Pi on a shelf, a cheap VM you'll keep deploying to through the Helm and GitOps chapters. If you want K3s's batteries inside Docker, k3d wraps K3s in containers the way kind wraps kubeadm; it's a community project, not an official Rancher one.

Where this fits in the book#

With a K3s cluster up, three chapters behave differently from a bare kind cluster. A type: LoadBalancer Service in Kubernetes Service gets a node IP straight away. The Ingress examples run on the Traefik you already have. And claims in Kubernetes Volumes bind through local-path. For a terminal view of all of it, k9s works with the same kubeconfig.

Further watching#