Single-node k3s on a VPS: when it is worth it
k3s puts the real Kubernetes API on one VPS. What it costs in RAM, why port 80 collides at install, and when Docker Compose is still the right answer.
What k3s is, and what one node of it gives you
k3s is a full Kubernetes distribution packaged as one binary, and running it on a single VPS gives you the real Kubernetes API without a three-machine control plane. It is a certified Kubernetes distribution, so a manifest that applies here applies to a managed cluster later. The install is one command and about a minute. What you pay is memory that is no longer available to your applications, plus a set of failure modes that never happen with Docker Compose.
SUSE builds k3s for edge sites and small installations, and every difference from upstream Kubernetes exists to make it smaller. The default datastore is sqlite behind a shim called kine, not etcd, so there is no etcd quorum to maintain. containerd is embedded in the binary instead of installed separately. The same binary also ships CoreDNS for cluster DNS, Traefik as the ingress controller, ServiceLB (also called klipper-lb) so that LoadBalancer services work with no cloud provider behind them, the local-path provisioner for persistent volumes, metrics-server, and flannel for pod networking. Every one of those starts by default. That is why the port collision described below is the most common first problem on a VPS that was already doing something.
When a single k3s node is worth it
Use this rule. Run k3s when the Kubernetes API is the thing you want: you are learning Kubernetes on a machine you control, or the software you want only publishes a Helm chart. Manifest portability counts too, because a Deployment you write here moves to a managed cluster unchanged. Run Docker Compose when the applications are the thing you want. Compose starts the same containers with far fewer moving parts, and a Compose file on a VPS is easier to read a year later than a directory of manifests.
Be clear about what one node does not give you.
- No high availability. When the VPS reboots, every workload stops. Kubernetes reschedules a pod onto another node, and there is no other node.
- No rolling update that keeps a service up, unless the application tolerates two replicas on one machine sharing one volume.
- Storage that is pinned to the box, for the reason described in the local-path section below.
- A control plane that costs around a gigabyte of RAM whether you deploy anything or not.
None of that makes k3s a bad choice. It makes it a bad choice for the reason people usually give, which is reliability. If what you actually want is several machines so you can build a real multi-node cluster, that decision comes first: Proxmox on your own hardware against a rented VPS settles where the nodes come from before k3s settles what runs on them.
What k3s costs in RAM and CPU before you deploy anything
The k3s project publishes measured figures rather than estimates. Read them carefully, because the number people quote is not idle k3s.
The data behind this chart
[
{
"label": "Server, sqlite datastore",
"ram_mb": "1,596",
"cpu_percent_of_one_core": 6
},
{
"label": "Server, embedded etcd",
"ram_mb": "1,606",
"cpu_percent_of_one_core": 6
},
{
"label": "Agent node only",
"ram_mb": "275",
"cpu_percent_of_one_core": 3
}
]A server node in that test used 1,596 MB of RAM at the 95th percentile and about 6 percent of one core. Those are published figures, not measurements from this guide, and the test ran k3s v1.26.5 with all packaged components enabled plus a Prometheus and Grafana monitoring stack, so the number includes a real workload rather than an empty cluster. Swapping sqlite for embedded etcd moved it to 1,606 MB. An agent node, which runs kubelet and containerd with no control plane, used 275 MB. The documented minimum for a server is 2 cores and 2 GB of RAM, and that minimum covers k3s and its packaged components before your workloads.
The practical reading: on a 2 GB VPS the control plane and its bundled add-ons leave you very little, and the first thing that happens under pressure is the kubelet evicting pods. 4 GB is a comfortable floor for one node with a few small services. Measure your own box instead of trusting any published figure, including this one.
free -h
sudo k3s kubectl top node
sudo k3s kubectl get pods -ARun free -h before you install and again once every pod in kube-system reads Running. The difference is what the control plane costs on your hardware. k3s kubectl top node returns error: Metrics API not available for the first minute or two after install, because metrics-server has not scraped anything yet. That is not a fault. If you are sizing one box for this and for other work at the same time, the arithmetic in sizing RAM and CPU for a VPS applies here without change.
Install k3s pinned to a release, not to latest
The quick start line everybody copies takes whatever the stable channel points at on the day you run it. On a machine you intend to keep, pin it. k3s publishes a channel per Kubernetes minor version, so INSTALL_K3S_CHANNEL=v1.36 follows patch releases inside v1.36 and never jumps a minor version under you. As of August 2026 the stable channel points at v1.36.3+k3s1.
Write the config file first, then install. k3s reads /etc/rancher/k3s/config.yaml when it starts, so anything in it applies to the first boot as well as every later one.
sudo mkdir -p /etc/rancher/k3s
sudo tee /etc/rancher/k3s/config.yaml >/dev/null <<'EOF'
tls-san:
- k3s.example.com
EOF
curl -sfL https://get.k3s.io | INSTALL_K3S_CHANNEL=v1.36 sh -To pin one exact release instead of a channel, use INSTALL_K3S_VERSION=v1.36.3+k3s1. The plus sign is part of the tag. Then check that it came up.
k3s --version
sudo systemctl status k3s
sudo k3s kubectl get node
sudo k3s kubectl get pods -Akubectl get node should list one node with STATUS Ready within about thirty seconds, and every pod in kube-system should reach Running or Completed. A node stuck at NotReady usually means the container runtime never started, so read sudo journalctl -u k3s -n 100 --no-pager. On an unusual VPS image, run sudo k3s check-config before you debug anything else: it reports missing kernel features, which is a much faster answer than reading logs.
Why port 80 is already in use, and what to give up to fix it
This is the failure that catches people on a VPS that was already serving something. The install succeeds. Traefik then never gets an address, and the site you were already running keeps working, so nothing looks broken until you try to reach an ingress.
The mechanism: the bundled Traefik chart creates a Service of type LoadBalancer on ports 80 and 443. ServiceLB answers that request by creating a DaemonSet of small pods, named with a svclb- prefix, that claim those port numbers as hostPort on each node. hostPort publishes the container port straight onto the node's own network namespace, exactly like docker run -p 80:80 does. If nginx, Caddy, Apache or another container already holds port 80, the kernel will not hand it out twice, so the scheduler has nowhere to put the pod.
sudo k3s kubectl -n kube-system get pods
sudo k3s kubectl -n kube-system get svc traefik
sudo ss -lntp '( sport = :80 or sport = :443 )'You will see the svclb pod Pending and the service with no external address:
svclb-traefik-8f2c1a-r6k9x 0/2 Pending 0 3m
traefik LoadBalancer 10.43.62.11 <pending> 80:31480/TCP,443:30219/TCPRunning kubectl -n kube-system describe pod svclb-traefik-... names the cause directly:
0/1 nodes are available: 1 node(s) didn't have free ports for the requested pod ports.ss -lntp tells you which process holds the port. There is more than one way out, and each one costs you something.
Give the ports to k3s. Stop and disable the existing web server, then let Traefik own 80 and 443. This is the right answer when the VPS is going to be a k3s box and nothing else, and everything you were serving moves behind an Ingress.
Disable ServiceLB and keep your existing proxy. Install with --disable=servicelb. A Service of type LoadBalancer still allocates a NodePort, so Traefik stays reachable on a high port such as 31480, and your nginx or Caddy proxies to 127.0.0.1:31480. What you give up is the external address: the service reports <pending> for ever, which looks like a fault when it is a choice you made.
Disable Traefik and route with your own proxy. Install with --disable=traefik. You then have no ingress controller, so Ingress objects do nothing at all: they sit in the API with no controller watching them. That is fine when you route from a host proxy to NodePorts, and it is the honest choice if you already know how you want HTTP handled. If you are undecided about what belongs in front, settle the choice between nginx, Caddy and Traefik as a reverse proxy before you disable anything.
Both flags belong on the installer, or in the config file:
tls-san:
- k3s.example.com
disable:
- traefik
- servicelbEditing that file after installation and running sudo systemctl restart k3s works too, because --disable does more than skip a component at install time. It also deletes a component that is already deployed, so the change takes effect on a running cluster.
To keep Traefik but change how the chart is configured, do not edit /var/lib/rancher/k3s/server/manifests/traefik.yaml. k3s rewrites that file with defaults every time it starts. Add a separate file in the same directory instead, because anything in /var/lib/rancher/k3s/server/manifests is applied automatically at startup and whenever it changes on disk.
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
web:
forwardedHeaders:
trustedIPs:
- 10.0.0.0/8That example sets one Traefik chart value, the trusted proxy addresses. The same mechanism sets any other value the chart exposes, including its ports.
Persistent storage on one node
k3s ships a default StorageClass called local-path, backed by Rancher's local-path provisioner. A PersistentVolumeClaim with no storageClassName gets it. Volumes live in /var/lib/rancher/k3s/storage, one subdirectory per volume, on the node's own disk.
sudo k3s kubectl get storageclass
sudo k3s kubectl get pvc -A
sudo ls -l /var/lib/rancher/k3s/storageTwo consequences follow from "on the node's own disk", and both bite later rather than today.
The StorageClass uses volumeBindingMode: WaitForFirstConsumer, so a new PVC stays Pending until a pod actually mounts it. kubectl describe pvc prints:
waiting for first consumer to be created before bindingThat is normal, so creating a PVC on its own and waiting will never clear it.
Once bound, the volume carries a node affinity for the node that created it, which pins every pod using that claim to that node for the life of the volume. On one node you will never notice. Add a second node later and a pod that refuses to move looks like a scheduler bug until you run kubectl get pv -o yaml and find the hostname sitting in nodeAffinity.
Backups are your job. Rebuilding the VPS destroys that directory, and so does the uninstall script below. Back up /var/lib/rancher/k3s/storage, plus the sqlite datastore at /var/lib/rancher/k3s/server/db/state.db copied with the service stopped, since it is a live database. The alternative is to treat the cluster as disposable and keep every manifest in git.
Ingress and TLS
With Traefik left enabled, a standard Ingress object is all you need.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello
annotations:
cert-manager.io/cluster-issuer: letsencrypt
spec:
ingressClassName: traefik
tls:
- hosts:
- hello.example.com
secretName: hello-tls
rules:
- host: hello.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello
port:
number: 80Certificates do not appear on their own. The usual answer is cert-manager, installed from its published manifest, plus one ClusterIssuer. Version v1.21.1 is current as of August 2026.
sudo k3s kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.1/cert-manager.yamlapiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt
spec:
acme:
email: you@example.com
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-account-key
solvers:
- http01:
ingress:
ingressClassName: traefikThe HTTP-01 challenge means the ACME (automatic certificate management environment) server connects to http://hello.example.com/.well-known/acme-challenge/... from the public internet. So the DNS A record must already point at the VPS, and port 80 must reach Traefik. If you disabled ServiceLB and put your own proxy in front, that proxy has to forward the challenge path as well, or cert-manager stalls with this on the Challenge object:
Waiting for HTTP-01 challenge propagation: wrong status code '404', expected '200'Watch issuance with sudo k3s kubectl describe certificate hello-tls and sudo k3s kubectl get order,challenge -A.
The kubeconfig, and why the API server stays private
k3s writes admin credentials to /etc/rancher/k3s/k3s.yaml. The file is owned by root and written with mode 600 by default. It holds a client certificate with cluster-admin rights, so anyone who can read it owns the cluster.
sudo k3s kubectl get node
export KUBECONFIG=/etc/rancher/k3s/k3s.yaml
kubectl get nodeA separately installed kubectl with no KUBECONFIG set fails with The connection to the server localhost:8080 was refused - did you specify the right host or port? because it falls back to a default that has nothing to do with k3s. Set KUBECONFIG, or use sudo k3s kubectl, which reads the right file on its own.
You will see --write-kubeconfig-mode 644 recommended so a normal user can run kubectl. Understand what that does: it makes a cluster-admin credential readable by every local account on the machine. On a single-admin box that may be an acceptable trade. On a shared box it is not. Copying the file instead gives one user access without publishing it:
mkdir -p ~/.kube
sudo install -o $USER -g $USER -m 600 /etc/rancher/k3s/k3s.yaml ~/.kube/configThe server: line in that file reads https://127.0.0.1:6443. To use kubectl from your laptop, do not open 6443 to the internet. A public Kubernetes API is a standing target, and an exposed one is how small clusters end up mining cryptocurrency for somebody else. Tunnel it over SSH and leave the address in the file alone:
ssh -N -L 6443:127.0.0.1:6443 user@your-vps
KUBECONFIG=./k3s.yaml kubectl get nodeIf you must reach the API over a private network address, install with tls-san listing that name or address, then edit the copied file's server: line to match. Without the SAN (subject alternative name) entry, kubectl refuses the connection:
x509: certificate is valid for 127.0.0.1, 10.43.0.1, not 203.0.113.10The documented inbound ports for a cluster are TCP 6443 for the API, UDP 8472 for flannel VXLAN between nodes, and TCP 10250 for kubelet metrics. On a single node, none of them needs to be open to the internet.
containerd is not Docker
k3s runs its own embedded containerd, and it does not share an image store with Docker. An image you just built with docker build is invisible to k3s, so the pod fails with ErrImagePull even though docker images lists it. Import it explicitly:
docker save myapp:0.1 | sudo k3s ctr images import -
sudo k3s crictl imagesThen avoid the :latest tag on that container, because :latest defaults to an imagePullPolicy of Always and the kubelet goes to a registry anyway. Any other tag defaults to IfNotPresent, which uses the image you imported. Running both on one VPS works, and it helps to know that a normal Docker install on a VPS and k3s each keep their own image store and their own iptables rules on the same machine.
How to remove k3s
The installer writes an uninstall script. There is no partial removal and no undo.
sudo /usr/local/bin/k3s-uninstall.shIt stops and removes the service, deletes the datastore, deletes persistent volume data, removes the node configuration, and removes the tools the installer added. On an agent node the script is k3s-agent-uninstall.sh instead. Copy anything under /var/lib/rancher/k3s/storage off the box first, because that directory goes with it. Afterwards, check that nothing is left holding ports or interfaces with ip link show and sudo ss -lntp. A leftover cni0 or flannel.1 interface clears on the next reboot.
Deciding that one node of Kubernetes was more machinery than the job needed is a normal outcome rather than a failure. Moving those workloads back to Compose is usually an afternoon.
Failure modes and the strings you will see
Node NotReady, or k3s restarting in a loop. Read sudo journalctl -u k3s -n 200 --no-pager first. On a small VPS the common cause is the kernel out-of-memory killer taking the process, which appears in dmesg as a line naming k3s-server. The documented 2 GB minimum is a real floor.
Pod stuck in Pending. kubectl describe pod names it every time. Insufficient memory or Insufficient cpu means the node has no room left. didn't have free ports is the hostPort collision above. waiting for first consumer on a PVC means WaitForFirstConsumer is doing its job.
ImagePullBackOff. Either the tag does not exist in any registry the node can reach, or you built the image with Docker and never imported it into containerd.
Traefik answers, the app does not. A response body of 404 page not found comes from Traefik itself and means the request arrived but no router matched. Check that the Ingress host matches the name you typed, and that ingressClassName is traefik.
Cluster DNS fails while the host resolves fine. Check CoreDNS with sudo k3s kubectl -n kube-system logs -l k8s-app=kube-dns. A message like plugin/loop: Loop ... detected for zone "." stops CoreDNS from starting. It happens because CoreDNS is forwarding to a resolver that forwards back to it, which is what a loopback address in /etc/resolv.conf does. Point k3s at the real upstream file with --resolv-conf /run/systemd/resolve/resolv.conf.
FAQ
Is k3s worth running on a single VPS?
It is worth it when the Kubernetes API is what you want: learning it on a machine you control, keeping deployments portable as manifests, running software that only publishes a Helm chart, or building something you will later move to a managed cluster. It is not worth it when you only want containers running, because Docker Compose does that with far less to maintain and roughly a gigabyte more free RAM. One node gives you no high availability, so reliability is never the reason to choose it.
Why does my k3s LoadBalancer service stay Pending?
ServiceLB creates svclb- pods that claim the service's ports as hostPort on the node, so they only schedule where those ports are free. If nginx or another proxy already holds port 80, the pod stays Pending and the service never receives an external address. kubectl -n kube-system describe pod svclb-... reports 0/1 nodes are available: 1 node(s) didn't have free ports for the requested pod ports. Free the port, or reinstall with --disable=servicelb and proxy to the NodePort that the service still allocates.
How much RAM does k3s need on a VPS?
The documented minimum for a server node is 2 cores and 2 GB, and that covers k3s plus its packaged components before your workloads. The project's own profiling measured a server node at 1,596 MB with a monitoring stack running on it, so treat 2 GB as the floor and 4 GB as the first size where one node is comfortable. Measure your own box with free -h taken before the install and again after every pod in kube-system reads Running.
Can I run Docker and k3s on the same VPS?
Yes, and they stay separate. k3s uses its own embedded containerd, so an image built with docker build is not visible to it until you run docker save myapp:0.1 | sudo k3s ctr images import -. Each also writes its own iptables rules and its own bridge networks. Watch the memory total, because Docker plus k3s plus your containers will not fit on a 2 GB box.
How do I remove k3s completely?
Run sudo /usr/local/bin/k3s-uninstall.sh on a server node, or sudo /usr/local/bin/k3s-agent-uninstall.sh on an agent. It stops the service, deletes the datastore, deletes persistent volume data under /var/lib/rancher/k3s/storage, and removes the bundled tools. Copy any data you want to keep off the box first, because there is no undo. A leftover cni0 or flannel.1 network interface disappears on the next reboot.