Introduction
Kubernetes has a reputation problem. Mention it in a planning meeting and someone will inevitably bring up the control plane bills from a managed offering, the multi-person platform team that "real" clusters supposedly require, and the general sense that the technology is scoped for organizations running hundreds of nodes, not a side project or a two-person startup. That reputation is only half deserved. The Kubernetes API - Deployments, Services, ConfigMaps, the reconciliation loop that keeps declared state and actual state in sync - is the same API whether it's running across a thousand bare-metal machines or on a single €5-a-month virtual private server. What changes is how much slack you have for mistakes, and that's a scheduling and resource-management problem, not a fundamental limitation of the platform.
This article is a practical walkthrough of running a real Kubernetes cluster on inexpensive VPS hosting - the kind of instance you'd rent from Hetzner, DigitalOcean, Vultr, Contabo, or a similar provider for a handful of dollars or euros per month. The focus is on k3s, the lightweight Kubernetes distribution built specifically for resource-constrained environments, and on the operational decisions that determine whether a budget cluster is a reliable piece of infrastructure or a recurring 2 a.m. page. It assumes you already know what a Pod and a Deployment are; the goal here isn't a Kubernetes primer, it's an honest look at what changes when your cluster lives on hardware you'd otherwise dismiss as "too small."
Context: Why Run Kubernetes on a Budget VPS
There are a handful of legitimate reasons engineers reach for this pattern, and it's worth being explicit about them because they shape every downstream decision. The first is learning: reading about Kubernetes primitives is a poor substitute for watching a Deployment actually reschedule a Pod after you kill it, or debugging why a Service isn't routing traffic because you misread a selector. A cheap VPS gives you a disposable, low-stakes environment to build that intuition without a managed cluster's control-plane fee ticking in the background. The second is staging environments - many teams want a Kubernetes environment that mirrors production's manifests and Helm charts closely enough to catch configuration drift, without paying production-grade prices for something that mostly sits idle. The third, and the one with the most real-world weight, is running small production workloads economically: personal SaaS products, internal tools, low-traffic APIs, and side projects where a $73-a-month EKS control plane fee (a stable, well-documented figure at $0.10/hour) would dwarf the cost of the workload itself.
It's worth being precise about what "cheap VPS" means in this context, because the term covers a wide range. The instances relevant here are typically shared-vCPU offerings in the 1-4 vCPU, 1-8 GB RAM range, priced from roughly the cost of a coffee subscription up to perhaps ten to fifteen dollars a month depending on provider and region. Hetzner's Cloud line, DigitalOcean's Droplets, Vultr's Cloud Compute instances, Contabo's VPS tiers, and OVHcloud's VPS offerings all fit this profile, and all of them give you a bare Linux box with root access and nothing else - no managed control plane, no integrated load balancer by default, no CNI, no cloud-native storage layer. Everything above the operating system is your responsibility, which is precisely why the choice of Kubernetes distribution matters so much more here than it does when a cloud provider is managing half the stack for you.
None of this is a claim that a $5 VPS cluster is a drop-in replacement for a properly resourced EKS, GKE, or AKS deployment. It isn't, and treating it as one is how these projects end up in postmortems. What it is, is a legitimate and widely used pattern for a specific class of workload: stateless or lightly stateful services, low-to-moderate traffic, and teams who value learning the underlying mechanics and controlling costs over having someone else's SRE team on call. Setting that expectation early avoids the most common failure mode of these projects, which is discovering the hard way - usually during an incident - that the cluster was never sized or architected for the load it ended up carrying.
Deep Technical Explanation: Picking Your Kubernetes Distribution
The single most consequential decision in this whole exercise is which Kubernetes distribution to run, because it determines your baseline resource floor before a single workload Pod is scheduled. Vanilla Kubernetes, bootstrapped with kubeadm, was designed for environments where the control plane runs on dedicated, reasonably provisioned nodes - the official documentation's minimum for a control-plane node is 2 CPUs and 2 GB of RAM, and in practice etcd's sensitivity to disk latency and its periodic compaction cycles make anything under 4 GB uncomfortable for a control plane sharing a node with actual workloads. On a $5 VPS with 1-2 GB of RAM, that overhead alone can consume most of what you have before your application even starts.
This is the problem lightweight distributions exist to solve. k3s, originally built by Rancher Labs and now a CNCF sandbox project, repackages the full Kubernetes API server, controller manager, and scheduler into a single small binary, replaces etcd with SQLite as the default datastore for single-server setups (via a shim called kine, which translates the etcd v3 API to other backends), and bundles a minimal but functional set of defaults: Flannel for pod networking, Traefik as an ingress controller, and a hostPath-based local-path-provisioner for storage. The result is a control plane that comfortably runs in 512 MB of RAM. It isn't the only option - k0s from Mirantis and MicroK8s from Canonical occupy similar territory, each with its own packaging philosophy (k0s ships as a single dependency-free binary much like k3s; MicroK8s is distributed as a snap package with tight Ubuntu integration) - but k3s's combination of low footprint, mature documentation, and the largest community of the three makes it the default recommendation for VPS deployments, and it's the distribution the rest of this article assumes.
Analogies & Mental Models
It helps to have a working mental model for why this distinction matters beyond a resource number on a spec sheet. Running a full kubeadm cluster on a single small VPS is a bit like installing a shipping-port crane's control system to move furniture around a studio apartment - the crane can technically do the job, but you're paying its operating overhead in every square foot it occupies, whether or not you're using its full range. k3s is the appropriately sized hand truck: it does the actual job - declarative scheduling, service discovery, self-healing - without demanding infrastructure sized for a job you don't have.
A second useful frame is to think of a VPS-based k3s cluster as a distributed-systems sandbox with real consequences. You get the same reconciliation loops, the same Service and Ingress abstractions, and the same kubectl verbs you'd use against a hundred-node EKS cluster - but every resource ceiling is felt immediately and visibly. A misconfigured memory limit that would be invisible noise on an over-provisioned enterprise cluster becomes an OOM-killed Pod within minutes here. That's not a downside so much as a feature for learning: the system gives you fast, unambiguous feedback about resource discipline that larger clusters often mask through sheer excess capacity.
The third mental model is the one that most directly affects how you should operate the cluster: on one or two nodes, treat the cluster as a "declarative single server," not as the self-healing distributed system Kubernetes marketing implies. The self-healing properties of Kubernetes - rescheduling Pods off a failed node, tolerating a lost replica - depend on having spare capacity elsewhere in the cluster to reschedule onto. With one control-plane node, a control-plane failure is a full outage regardless of how many Deployments declare three replicas each. Kubernetes' automation is genuinely valuable at this scale, but mostly for configuration drift and Pod-level restarts, not for node-level fault tolerance. Knowing which failure modes your cluster actually protects against - and which it doesn't - is the difference between a design decision and a surprise.
Implementation Walkthrough
Provisioning starts with picking a specification that won't leave you fighting the control plane for memory. For a single-node cluster meant to run actual workloads (not just be a learning sandbox), 2 vCPUs and 4 GB of RAM is a reasonable floor; agent-only nodes joining an existing cluster can often get by with less, depending on what you schedule onto them. Use a current LTS distribution - Ubuntu 22.04/24.04 or Debian 12 are both well-tested with k3s - and disable swap unless you've deliberately enabled and tested Kubernetes' NodeSwap feature gate, which reached beta in Kubernetes 1.28 but is still not the default assumption most tooling makes. A minimal cloud-init or first-boot script handles the baseline setup and the k3s install itself:
#!/usr/bin/env bash
set -euo pipefail
# Disable swap - required unless NodeSwap is explicitly configured
swapoff -a
sed -i '/ swap / s/^/#/' /etc/fstab
# Kernel/network tuning k3s and most CNIs expect
cat <<'EOF' >> /etc/sysctl.d/99-kubernetes.conf
net.bridge.bridge-nf-call-iptables = 1
net.ipv4.ip_forward = 1
vm.max_map_count = 262144
EOF
sysctl --system
# Bootstrap the first server node.
# --tls-san adds the public IP to the API server's certificate SAN list
# so kubectl works from outside the VPS without TLS errors.
curl -sfL https://get.k3s.io | \
INSTALL_K3S_EXEC="server --tls-san $(curl -s ifconfig.me) --write-kubeconfig-mode 644" \
sh -
Joining additional nodes is a one-line operation once you have the server's node token from /var/lib/rancher/k3s/server/node-token, but it's worth pausing on which kind of node you're actually adding. An agent node only runs workloads - it's the straightforward way to add capacity once a single server is handling more Pods than its resources comfortably allow, and it's the right first move for most small clusters. Turning a second node into an additional server is a different decision: it moves you from the default SQLite datastore to an embedded-etcd configuration (via --cluster-init on the first server and --server https://<first-server>:6443 on subsequent ones), which buys genuine control-plane fault tolerance but also multiplies your baseline resource cost and reintroduces etcd's disk-latency sensitivity that k3s's SQLite mode was specifically designed to avoid. For most budget clusters, one server node plus one or more agents is the right shape until an actual availability requirement - not just a vague sense that "HA is best practice" - justifies the added cost:
curl -sfL https://get.k3s.io | \
K3S_URL="https://<server-ip>:6443" \
K3S_TOKEN="<token-from-node-token-file>" \
sh -
With the cluster reachable, kubectl should be pointed at /etc/rancher/k3s/k3s.yaml (copy it locally and swap 127.0.0.1 for the server's public or WireGuard-tunneled IP), and a quick kubectl get nodes is the first real confirmation that the install worked end to end. It's tempting to stop there and start deploying workloads immediately, but on a cluster with no managed provider watching the control plane on your behalf, that's exactly the moment to invest a small amount of effort in health visibility instead. Manual kubectl apply and occasional kubectl get pods checks run out of steam quickly once you're not actively staring at the terminal, and budget clusters fail quietly - a node running low on memory doesn't announce itself, it just starts evicting Pods. A small script using the official Python Kubernetes client is enough to catch the two failure modes that matter most here - nodes going NotReady and Pods stuck crash-looping - and can be wired into cron or a lightweight alerting channel with very little additional infrastructure:
"""cluster_health.py - polls node and pod status, exits non-zero on issues."""
from __future__ import annotations
import time
from dataclasses import dataclass
from kubernetes import client, config
@dataclass
class HealthIssue:
kind: str
name: str
reason: str
def check_nodes(api: client.CoreV1Api) -> list[HealthIssue]:
issues = []
for node in api.list_node().items:
ready = next((c for c in node.status.conditions if c.type == "Ready"), None)
if ready is None or ready.status != "True":
issues.append(HealthIssue("node", node.metadata.name, "NotReady"))
return issues
def check_pods(api: client.CoreV1Api, grace_seconds: int = 300) -> list[HealthIssue]:
issues = []
now = time.time()
for pod in api.list_pod_for_all_namespaces().items:
started = pod.status.start_time
age = now - started.timestamp() if started else 0
if pod.status.phase == "Pending" and age > grace_seconds:
issues.append(HealthIssue("pod", pod.metadata.name, "StuckPending"))
for cs in pod.status.container_statuses or []:
if cs.state.waiting and cs.state.waiting.reason == "CrashLoopBackOff":
issues.append(HealthIssue("pod", pod.metadata.name, "CrashLoopBackOff"))
return issues
def main() -> None:
config.load_kube_config()
api = client.CoreV1Api()
issues = check_nodes(api) + check_pods(api)
for issue in issues:
print(f"[ALERT] {issue.kind} {issue.name}: {issue.reason}")
if issues:
raise SystemExit(1)
print("Cluster healthy.")
if __name__ == "__main__":
main()
The last piece of a workable setup is closing the loop between CI and the cluster, and it's a step that's easy to skip when a project starts as a weekend experiment and quietly becomes something people depend on. Rather than SSH-ing into the VPS to run kubectl by hand after every deploy - a habit that works fine until the one time someone forgets, or does it from the wrong branch - a small TypeScript script using the official @kubernetes/client-node library can trigger a rolling restart as the final step of a CI pipeline, using the same annotation-patch mechanism kubectl rollout restart uses internally under the hood. It's a deliberately narrow tool: it doesn't try to reimplement a GitOps controller, it just gives a pipeline step a reliable, scriptable way to tell the cluster "the image changed, roll it out," which is often all a small deployment actually needs:
// deploy-rollout.ts - triggers a rolling restart after a new image is pushed
import * as k8s from "@kubernetes/client-node";
interface RolloutTarget {
namespace: string;
deployment: string;
}
async function triggerRollingRestart(target: RolloutTarget): Promise<void> {
const kc = new k8s.KubeConfig();
kc.loadFromDefault(); // honors KUBECONFIG env var
const appsApi = kc.makeApiClient(k8s.AppsV1Api);
const patch = [
{
op: "add",
path: "/spec/template/metadata/annotations/kubectl.kubernetes.io~1restartedAt",
value: new Date().toISOString(),
},
];
await appsApi.patchNamespacedDeployment(
target.deployment,
target.namespace,
patch,
undefined,
undefined,
undefined,
undefined,
{ headers: { "Content-Type": "application/json-patch+json" } }
);
console.log(`Rollout triggered for ${target.namespace}/${target.deployment}`);
}
triggerRollingRestart({ namespace: "default", deployment: "api" }).catch((err) => {
console.error("Rollout failed:", err);
process.exit(1);
});
Networking, Storage, and Ingress on a Budget
Networking is where the absence of a cloud provider's integrations is felt most directly. There's no managed load balancer sitting in front of your cluster by default, so you have two realistic options: rely on k3s's bundled Traefik ingress controller behind the VPS's single public IP for HTTP/HTTPS traffic, or install MetalLB in Layer 2 mode to hand out LoadBalancer-type Service IPs the way a cloud provider would, which matters more once you have multiple nodes and want traffic distributed rather than funneled through one. On the CNI side, k3s's default Flannel with a VXLAN backend is adequate for most small clusters, but it's worth knowing it exists - VXLAN's encapsulation overhead can interact badly with a VPS provider's own virtual networking layer, occasionally producing MTU mismatches that manifest as mysteriously dropped or fragmented packets between pods on different nodes. Cilium or Calico are the usual upgrades if you outgrow Flannel or need network policies enforced with less overhead.
Storage is the area where the gap between a cheap VPS and a managed cluster is widest, and it's worth resisting the urge to over-engineer it. k3s's default local-path-provisioner is a hostPath-backed StorageClass with no replication whatsoever - a PersistentVolume lives on exactly one node's disk, and if that node dies, the data goes with it unless you've backed it up separately. Longhorn, Rancher's distributed block storage system, solves this by replicating volumes across nodes, but it has a real resource cost of its own - extra CPU, RAM, and disk I/O per node - that can be a poor trade on a two-node cluster where each node is already tight on headroom. For many small deployments, the pragmatic answer is to skip in-cluster distributed storage entirely and use the VPS provider's own attachable block storage (Hetzner Volumes, DigitalOcean's Block Storage, and equivalents from other providers) mounted directly to the node running a given stateful workload, accepting that it's tied to that node the same way local-path is, but with the provider handling the underlying disk redundancy.
Certificates and backups round out the operational basics. cert-manager, installed as a standard Helm-deployed workload, handles Let's Encrypt issuance via either HTTP-01 (simplest, requires the ingress to be reachable on port 80) or DNS-01 challenges (needed for wildcard certificates or when the cluster isn't directly internet-facing). Backups deserve a distinction that's easy to miss: k3s's built-in snapshot and restore commands apply specifically to the embedded-etcd datastore used in multi-server HA configurations - a single-server setup running the default SQLite backend needs its own backup strategy, typically a periodic copy of the /var/lib/rancher/k3s/server/db directory, or a cluster-level tool like Velero for backing up both resource manifests and PersistentVolume data together.
Trade-offs and Pitfalls
The most common failure mode on budget clusters is straightforward resource exhaustion, and it tends to arrive quietly. A control plane sized for a 1 GB node, plus a handful of application Pods without memory limits set, plus a spike in traffic, is a recipe for the kernel's OOM killer terminating processes - sometimes the workload, occasionally a system component - with a log line that's easy to miss if you're not watching for it. Shared-vCPU VPS instances compound this with a second, less obvious problem: CPU steal from noisy neighbors on the same physical host, which shows up as intermittent latency spikes that look like application bugs until you check cpu.steal in your monitoring and realize the VM simply didn't get its allotted cycles that second. Neither problem is unique to Kubernetes, but Kubernetes' scheduler and the kubelet's eviction behavior interact with both in ways that are worth understanding before they show up in production - a Pod evicted for memory pressure behaves very differently from a Pod that was never scheduled in the first place.
The second category of pitfalls is operational rather than architectural. Exposing the Kubernetes API server (port 6443) directly to the public internet is a meaningfully larger attack surface than most people account for when they first spin up a VPS cluster, and the fix - a provider firewall restricting access to known IPs, or tunneling API access over WireGuard or a service like Tailscale - is easy to skip under time pressure and easy to regret later. Upgrades carry their own quiet risk: k3s tracks upstream Kubernetes releases closely, and skipping several minor versions at once is far more likely to surface a breaking change than upgrading incrementally, a lesson that applies to Kubernetes generally but bites harder when there's no staging cluster to test the upgrade against first. And running control planes on odd numbers of nodes for HA (three, not two) is a rule that's easy to internalize in theory and easy to violate in practice when budget pressure pushes toward "just one more node" instead of "one more pair."
Best Practices
Resource requests and limits are the single highest-leverage habit on a resource-constrained cluster, and they matter more here than almost anywhere else Kubernetes runs. Every container should declare both a request (what the scheduler reserves) and a limit (the hard ceiling the kubelet enforces), because on a cluster with no spare capacity, an unbounded Pod isn't a theoretical risk - it's the mechanism by which one misbehaving service takes down its neighbors. Pairing this with lightweight observability closes the feedback loop: metrics-server plus kubectl top covers the basics for free, and for anything more persistent, a small Prometheus instance with reduced retention, or an even lighter option like Netdata, gives visibility without itself becoming a meaningful resource cost on a small cluster.
Treating the cluster as code matters more, not less, at this scale, precisely because there's no managed service quietly handling reproducibility on your behalf. Provisioning the VPS itself through Terraform, and the OS-level configuration and k3s install through the cloud-init script (or an Ansible playbook, for anything more involved than the one-shot example above), means a wiped node is a scripted redeploy rather than a debugging session built on memory of what you did six months ago. GitOps tooling - Flux or Argo CD, both CNCF projects with solid track records - extends the same principle to the workloads themselves: the cluster's actual state should always be one git log away from being explainable, which matters enormously when you're the only person who's ever touched the cluster and won't remember the manual kubectl apply you ran at 11 p.m. three weeks ago.
Security hygiene rounds out the list, and none of it is exotic. Restrict the API server to known IP ranges or a WireGuard tunnel rather than leaving it open to the internet; keep both the OS and k3s itself on a regular patch cadence rather than "whenever something breaks"; run containers as non-root wherever the image supports it; and apply NetworkPolicies even on a two-node cluster, since limiting which Pods can talk to which is one of the few controls that meaningfully reduces blast radius when - not if - something eventually goes wrong.
The 80/20 Insight
Strip away the long list of tools and options, and a small number of decisions account for most of the reliability difference between a budget cluster that works and one that becomes a liability. Choosing a distribution built for this footprint - k3s over vanilla kubeadm - removes an entire category of resource-exhaustion problems before you've written a single manifest. Setting resource requests and limits on every workload converts silent, cascading failures into predictable, contained ones. And treating the cluster's configuration as version-controlled code, backed by actual backups of both cluster state and persistent data, is what separates "annoying to rebuild" from "we lost three weeks of data."
Everything past that point - service meshes, multi-cluster federation, exotic CNI configurations, elaborate autoscaling policies - is generally over-engineering for the workload class this pattern is meant for. The improvement most people skip, because it doesn't feel urgent until the moment it desperately is, is making sure a completely wiped VPS can be turned back into a working cluster in minutes through scripts and Git history rather than institutional memory. That single habit does more for the practical reliability of a budget cluster than almost any tool on this list.
Key Takeaways
For anyone about to provision their first budget Kubernetes cluster, these five steps capture most of what matters:
- Choose k3s (or a similarly lightweight distribution) over vanilla kubeadm - the control-plane resource savings alone often determine whether the cluster is usable.
- Set memory and CPU requests/limits on every single workload before it ever reaches a shared node - this is the cheapest insurance against cascading OOM failures.
- Decide your storage strategy deliberately - local-path for genuinely disposable data, provider block storage or Longhorn for anything you can't afford to lose, never the default by accident.
- Lock down the API server behind a firewall or a WireGuard/Tailscale tunnel before exposing any workload publicly.
- Provision and configure through code from day one - Terraform, cloud-init, and GitOps aren't optional polish here; they're what makes a wiped node a non-event.
None of these five require an afternoon each - most are a config flag or a one-time script - which is exactly why skipping them tends to be a matter of not knowing they mattered rather than a real time trade-off.
Conclusion
Running Kubernetes on a cheap VPS isn't a compromise dressed up as a strategy - it's a genuinely useful pattern for a well-defined set of use cases: learning the platform's real behavior, running staging environments that mirror production without production's price tag, and hosting small workloads where the economics of a managed control plane simply don't make sense. The technology underneath is the same Kubernetes API that runs the largest clusters in the world; what changes is the margin for error, and that margin can be managed deliberately rather than discovered accidentally.
The practical path here is unglamorous but reliable: start with a single well-sized node running k3s, get resource limits, backups, and infrastructure-as-code right before adding complexity, and only reach for a second or third node - and the HA patterns that come with it - once an actual reliability requirement justifies the added cost and operational surface. Most budget clusters that fail do so not because Kubernetes was the wrong tool, but because the operator borrowed assumptions from a fully managed environment without doing the work those assumptions were quietly standing in for.
References
- Kubernetes documentation - kubeadm control-plane requirements and installation guide: https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/
- k3s official documentation: https://docs.k3s.io/
- k0s official documentation: https://docs.k0sproject.io/
- MicroK8s documentation (Canonical): https://microk8s.io/docs
- Longhorn documentation (CNCF, distributed block storage): https://longhorn.io/docs/
- MetalLB documentation (bare-metal LoadBalancer implementation): https://metallb.io/
- Traefik Proxy documentation: https://doc.traefik.io/traefik/
- cert-manager documentation: https://cert-manager.io/docs/
- Flux (GitOps toolkit, CNCF): https://fluxcd.io/flux/
- Argo CD documentation (CNCF): https://argo-cd.readthedocs.io/
- Velero documentation (cluster backup/restore): https://velero.io/docs/
- Official Kubernetes Python client: https://github.com/kubernetes-client/python
- Official Kubernetes JavaScript/TypeScript client: https://github.com/kubernetes-client/javascript
- AWS EKS pricing (control plane cost reference): https://aws.amazon.com/eks/pricing/
- Kubernetes NodeSwap feature gate documentation: https://kubernetes.io/docs/concepts/architecture/nodes/#swap-memory