---
title: k3s vs k0s vs Kubernetes
date: '2026-10-19'
description: 'All three pass the same conformance tests. What separates them is everything between an API server and an HTTPS URL, and that''s why pfrctl runs k3s.'
author: Pablo Fernandez
tags:
  - Kubernetes
  - k3s
  - k0s
  - Self-hosting
  - Infrastructure
source: 'https://www.pablofr.com/blog/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](https://k3s.io/), here's the question this post answers: k3s, [k0s](https://k0sproject.io/) 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](https://kubernetes.io/) 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](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-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](https://www.cncf.io/) Sandbox projects ([k3s since 2020](https://www.cncf.io/projects/k3s/), [k0s since 2025](https://www.cncf.io/blog/2025/05/20/a-milestone-for-lightweight-kubernetes-k0s-joins-cncf-sandbox/)). 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.

```
                     kubeadm        k0s            k3s
  ─────────────────  ─────────────  ─────────────  ─────────────
  API server + etcd  bundled        bundled        bundled
  container runtime  you            bundled        bundled
  pod network (CNI)  you            bundled        bundled
  CoreDNS            bundled        bundled        bundled
  metrics-server     you            bundled        bundled
  storage class      you            you            bundled
  load balancer      you            you            bundled
  ingress            you            you            bundled
```

*What each one hands you after install, from the API server up to a public HTTPS route.*

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](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/) 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](https://metallb.io/) 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](https://www.kube-router.io/) 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:

```bash:terminal
sudo k0s install controller --single                       # one node, no way to grow
sudo k0s install controller --enable-worker --no-taints    # runs pods, can still grow
```

The k0s [system requirements](https://docs.k0sproject.io/stable/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](https://github.com/k0sproject/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](https://github.com/flannel-io/flannel), CoreDNS, metrics-server, the [local-path provisioner](https://github.com/rancher/local-path-provisioner) for storage, [Traefik](https://traefik.io/) 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](https://github.com/k3s-io/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](https://docs.k3s.io/datastore/ha-embedded).

On size, the k3s [requirements page](https://docs.k3s.io/installation/requirements) asks for 2 cores and 2 GB of RAM for a server and 1 core and 512 MB for an agent. Its [resource profiling](https://docs.k3s.io/reference/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

| | kubeadm (upstream) | k0s | k3s |
|---|---|---|---|
| Shape | a set of tools | one binary | one binary, one process |
| Datastore | etcd | SQLite single node, etcd multi | SQLite (Kine), etcd for HA |
| CNI | your choice | kube-router | Flannel |
| Ingress | your choice | your choice | Traefik |
| Load balancer on a VPS | your choice | your choice | ServiceLB |
| Control plane runs pods | no (tainted) | no, unless asked | yes |
| Stated minimum, control plane | 2 GB, 2 CPUs | 1 GB, 1 vCPU | 2 GB, 2 cores |
| Multi-node install | kubeadm join per node | k0sctl over SSH | token + install script |

## Why pfrctl runs k3s

pfrctl is the Go CLI I use to run my VPSs, and [its own story](/blog/pfrctl-infrastructure-as-code) 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.

```
  pfrctl k3s install  node-1   ─▶  get.k3s.io script        bundled
       │                           Traefik config via
       │                           the manifests dir        bundled
       ▼
  pfrctl k3s join  node-2      ─▶  node-token + K3S_URL     bundled
       │
       ▼
  pfrctl k3s deploy  <image>   ─▶  IngressRoute + "le"      bundled
                                   ServiceLB on 80/443      bundled
                                   A record per node IP
```

*The cluster workflow in pfrctl, and the k3s piece each command leans on.*

**Install is the official script plus one file.** `pfrctl k3s install` runs this over SSH:

```go:tools/pfrctl/cmd_k3s.go
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](https://tailscale.com/) 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:

```yaml:manifest
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`web.pablofr.com`)
      kind: Rule
      services:
        - name: web
          port: 3000
  tls:
    certResolver: le
```

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

```
            web.pablofr.com
          A  node-1 · node-2 · node-3
                    │
       ┌────────────┼────────────┐
       ▼            ▼            ▼
  ┌─────────┐  ┌─────────┐  ┌─────────┐
  │ node-1  │  │ node-2  │  │ node-3  │
  │ 80/443  │  │ 80/443  │  │ 80/443  │
  └────┬────┘  └────┬────┘  └────┬────┘
       └────────────┼────────────┘
                    ▼
       ServiceLB ─▶ Traefik ─▶ Service web
```

*With --cf-dns, every node's IP is an A record, and every node can serve the route.*

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](https://training.linuxfoundation.org/certification/certified-kubernetes-administrator-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

- [k3s documentation](https://docs.k3s.io/)
- [k3s: packaged components](https://docs.k3s.io/installation/packaged-components)
- [k3s: requirements](https://docs.k3s.io/installation/requirements)
- [k3s: resource profiling](https://docs.k3s.io/reference/resource-profiling)
- [k0s documentation](https://docs.k0sproject.io/stable/)
- [k0s: installation](https://docs.k0sproject.io/stable/install/)
- [k0s: system requirements](https://docs.k0sproject.io/stable/system-requirements/)
- [Kubernetes: installing kubeadm](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/)
- [Kubernetes: creating a cluster with kubeadm](https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/create-cluster-kubeadm/)
