k3s vs k0s vs Kubernetes
At its core, the pfrctl command that builds a Kubernetes cluster is one line of shell. It pipes an install script into sh, and when the script returns, the box has an API server, a pod network, DNS, an ingress controller and a load balancer. Joining a second machine is the same line with two environment variables.
That's suspicious if you've ever built a cluster by hand. So before I tell you why pfrctl uses k3s, here's the question this post answers: k3s, k0s and upstream Kubernetes all run the same certified API. If the API is the same, what is actually different, and does the difference matter on a couple of VPSs?
Three names, one API
"k8s" is Kubernetes itself, the upstream project. When people compare it with k3s and k0s, they usually mean a cluster assembled from upstream parts, and the upstream tool for that is kubeadm. k3s and k0s are distributions: upstream Kubernetes compiled into one binary, with choices already made for you.
All three are certified. k3s calls itself "a fully compliant Kubernetes distribution", k0s calls itself "certified and 100% upstream Kubernetes", and both are now CNCF Sandbox projects (k3s since 2020, k0s since 2025). A Deployment, a Service or an HPA you write works on any of them. k3s comes from Rancher, now SUSE; Mirantis backs k0s and sells support for it.
So the difference isn't in what you can ask the cluster to do. It's in how much of the cluster exists the moment the install finishes.
Read the diagram from the bottom. The last three rows are the gap between "the cluster is up" and "a browser can reach my app", and they're exactly where the three part ways.
kubeadm: every choice is yours
kubeadm sets up a control plane and stops. You install a container runtime first, and after kubeadm init the docs tell you to deploy a pod network add-on yourself, because nothing schedules until you do. Then you pick an ingress controller, a storage provisioner, and on a VPS with no cloud load balancer, something like MetalLB to give a Service an external IP.
That's not a flaw. It's the point: every piece is a decision you make and can defend, and it's the same set of pieces a managed cluster runs under the hood. It's also the heaviest of the three on paper. The kubeadm page asks for 2 GB of RAM and 2 CPUs on control plane machines, and by default kubeadm taints the control plane so your pods don't land there, which means a real cluster starts at two machines.
k0s: upstream, packed, unopinionated
k0s is one binary "with zero host OS dependencies besides the host OS kernel". It bundles containerd, kube-router as the CNI, CoreDNS, Konnectivity and metrics-server. The datastore is SQLite on a single node and etcd once you have several.
What it leaves out is on purpose. The k0s docs say the project avoids bundling opinionated add-ons such as ingress, so you still choose the ingress controller and the load balancer. And it isolates the control plane by default: a plain k0s install controller runs the control plane only. To also run pods on that machine you ask for it:
sudo k0s install controller --single # one node, no way to grow
sudo k0s install controller --enable-worker --no-taints # runs pods, can still growThe k0s system requirements list 1 GB of RAM and 1 vCPU for a controller and 0.5 GB for a worker, with a note that these are approximations. For multi-node clusters there's k0sctl, which drives the install over SSH from your laptop.
k3s: the batteries are in
k3s is also one binary, under 100 MB, with the control plane in one process. It bundles containerd, Flannel, CoreDNS, metrics-server, the local-path provisioner for storage, Traefik as the ingress controller, and ServiceLB, a small load balancer that publishes a LoadBalancer Service on every node's own IP. A server also runs pods by default, so one machine is a complete cluster.
The default datastore is SQLite, wrapped by Kine so the API server thinks it's talking to etcd. That keeps a single server light, and it's also the limit: one server on SQLite is one control plane on one box. High availability means three servers on embedded etcd.
On size, the k3s requirements page asks for 2 cores and 2 GB of RAM for a server and 1 core and 512 MB for an agent. Its resource profiling measured a server with every packaged component plus a test workload at about 1.6 GB of RAM at the 95th percentile (k3s v1.26.5 on an AWS c6id.xlarge), and an agent at about 275 MB. Those are each project's own numbers under its own conditions, not a benchmark of one against another, so I'd read them as "all of these fit on a small VPS" and nothing finer.
Side by side
Why pfrctl runs k3s
pfrctl is the Go CLI I use to run my VPSs, and its own story is about turning one box into a platform. Most apps there run on plain Docker behind Traefik. The pfrctl k3s commands exist for the projects that need more than one node, rolling updates or an HPA. Secondbook runs on k3s in production too, installed by its own scripts with the same choices.
The reason for k3s isn't size. It's that every step of pfrctl's cluster workflow leans on something k3s ships, and each one would be a separate install with the other two.
Install is the official script plus one file. pfrctl k3s install runs this over SSH:
if err := m.runShell(`set -e
if command -v k3s >/dev/null 2>&1; then
echo "k3s ya está instalado"
else
curl -sfL https://get.k3s.io | sudo INSTALL_K3S_EXEC="server --write-kubeconfig-mode=644" sh -
fi
sudo systemctl is-active k3s`); err != nil {Then it writes a HelmChartConfig for Traefik into /var/lib/rancher/k3s/server/manifests. k3s applies anything in that directory on its own, so the CLI never needs Helm, and the Let's Encrypt resolver, the Cloudflare trusted IPs and the long idle timeouts for WebSockets are in the cluster about thirty seconds later. Last, it copies /etc/rancher/k3s/k3s.yaml to my Mac and swaps 127.0.0.1 for the node's Tailscale address, so kubectl talks to the cluster over the private mesh.
Join is a token and a URL. The server leaves its join token in /var/lib/rancher/k3s/server/node-token. pfrctl reads it, keeps it in the server inventory, and pfrctl k3s join node-2 --master node-1 runs the same script with K3S_URL and K3S_TOKEN set. No certificate copying, no kubeadm token create.
Deploy reuses the proxy I already run. My Docker apps sit behind Traefik with a resolver called le. The cluster's Traefik gets the same resolver, so pfrctl k3s deploy writes a Deployment, a Service and a Traefik IngressRoute that reads like the labels I write on the Docker side:
spec:
entryPoints:
- websecure
routes:
- match: Host(`web.pablofr.com`)
kind: Rule
services:
- name: web
port: 3000
tls:
certResolver: leOne proxy to learn on both sides of the house. With k0s or kubeadm I'd install Traefik myself and keep its Helm values in step with the edge box.
ServiceLB makes DNS the load balancer. Because ServiceLB opens 80 and 443 on every node, any node can take a request. pfrctl k3s deploy --cf-dns reads the node IPs from the API and syncs one Cloudflare A record per node, deleting the rest. On a VPS provider without a managed load balancer, that's the whole load balancer: no MetalLB, no floating IP.
It also lives next to Docker without a fight. On Secondbook the app tier runs in k3s while Postgres, Redis, NATS and Meilisearch stay in Docker Compose on the same VPS, and pods reach them through the node IP. k3s runs its own containerd, separate from Docker's.
The trade is the one in the k3s section: my server runs on SQLite, so the control plane is one process on one box. If it goes down, the agents keep running their pods, but nothing gets scheduled or rescheduled until it's back. For a couple of nodes I accept that, and the way out is a flag, not a new distribution: the k3s docs say a SQLite server converts to embedded etcd by restarting it with --cluster-init.
Writing this turned up one leftover. pfrctl k3s install used to apply the upstream metrics-server manifest for the HPA, on top of the metrics-server k3s already ships as a packaged component. That step is gone now.
Which one to pick
They're all real Kubernetes, so pick by what you want to own:
- One VPS or a few, one person, apps that need an HTTPS URL: k3s. The ingress, the load balancer and the storage are already there, and a single machine is a full cluster.
- Edge devices, ARM boards, a Raspberry Pi at home: k3s, and it's what its docs profile on a Pi 4. k0s fits there too if you'd rather choose your own ingress.
- You want upstream parts with no opinions on top, and a control plane that never runs your pods: k0s. It's the cleanest split between controllers and workers of the three, k0sctl gives you an SSH-driven install much like pfrctl's, and Mirantis sells support if a company needs a contract.
- Learning Kubernetes properly, or studying for the CKA: kubeadm. Every piece you install by hand is a piece you'll understand when it breaks.
- A platform team with an opinion on every layer (CNI, ingress, storage, etcd topology): kubeadm, or a managed cluster that runs the same upstream pieces for you.
- A handful of apps on one box with no need for rolling updates across nodes: none of them. Docker Compose behind Traefik is less to run, and it's what most of my apps still use.
That last line is the honest frame for the whole comparison. k3s, k0s and kubeadm give you the same API and ask for different amounts of assembly. kubeadm makes you choose every part, k0s chooses the core and leaves the edge to you, and k3s chooses the edge too. pfrctl's job is to make a cluster one command, so it wants the distribution that has already made the most choices. That k3s's choices are Traefik, which I already run, and a load balancer that needs nothing but DNS is what settles it.
Sources
Related articles
Every deploy strands open tabs asking for chunks only the old build had. My skew protection for self-hosted Next.js on k3s: a Go router that pins each tab.
A temporary inbox takes mail from anyone, for anyone, with no account. How Buzontemporal runs on Mailingest: no attachments, a light path, hard limits.