One VPS, old machines, and my whole stack runs on it
How I use a VPS as a k3s control plane and my own machines as workers, tied together by WireGuard — including the traps that cost me entire evenings.
The math that made up my mind
A decent VPS costs a few euros a month. Two VPSs cost twice that. And when you want to run a Docker registry, a Keycloak, a PostgreSQL, a portfolio and two or three ongoing projects, you quickly end up asking yourself: am I paying for RAM I already have at home?
Because I did have the RAM. An old machine gathering dust, more than enough to run containers. What it lacked wasn’t power: it was a public IP address, a valid certificate, and a guarantee of staying reachable.
Hence the architecture I use today: a VPS as the control plane and front door, my personal machines as the muscle, all tied together with WireGuard.
Internet
| 80/443 (HTTP/S) 51820/udp (WireGuard)
v
+-------------------------+
| VPS - 10.10.0.1 | k3s server, Traefik, cert-manager
| public IP, DNS, TLS |
+------------+------------+
| wg0 tunnel (initiated from home)
+------------+------------+
| Home - 10.10.0.2 | k3s agent, heavy workloads
| behind router / CGNAT |
+-------------------------+
What each machine does
| VPS | Home machine | |
|---|---|---|
| k3s role | control plane | worker |
| Resources | 8 GB RAM, 3 vCPU | whatever it has, and that’s already a lot |
| What runs there | Traefik, cert-manager, the k3s API | the resource-hungry workloads |
| What it brings | public IP, availability | free RAM and CPU |
| Ports open to the internet | 80, 443, 51820/udp | none |
The key point: traffic always comes in through the VPS. It holds the domain name, terminates TLS and routes to services — no matter which node the pod actually runs on. The home machine exposes nothing: no port forwarding on the router, no IP to publish.
Why WireGuard isn’t optional
Kubernetes assumes every node can talk to every other node directly. But my machine sits behind a home router, with no public IP, often behind CGNAT. The VPS can’t reach it.
WireGuard flips the problem around: the home machine initiates the connection to the VPS, and the tunnel stays open. Once it’s up, both nodes see each other on a stable private network — 10.10.0.1 and 10.10.0.2 — that doesn’t change when my ISP hands me a new IP.
Start by generating a key pair on each machine:
sudo apt install wireguard
wg genkey | tee privatekey | wg pubkey > publickey
chmod 600 privatekey
Each machine keeps its private key and gives its public key to the other. Nothing else is exchanged.
On the VPS (/etc/wireguard/wg0.conf):
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <VPS private key>
[Peer]
# The home machine
PublicKey = <worker public key>
AllowedIPs = 10.10.0.2/32
On the home machine:
[Interface]
Address = 10.10.0.2/24
PrivateKey = <worker private key>
[Peer]
PublicKey = <VPS public key>
Endpoint = <vps.public.ip>:51820
AllowedIPs = 10.10.0.0/24
# Without this line, the tunnel dies as soon as traffic stops:
# the router's NAT forgets the mapping after a few minutes.
PersistentKeepalive = 25
Enable the tunnel at boot, on both sides:
sudo systemctl enable --now wg-quick@wg0
sudo wg show
The line to look at in wg show is latest handshake. If it shows a few seconds, the tunnel is alive. If it’s missing, the keys or the UDP port are to blame — no point going further until that’s fixed.
PersistentKeepalive is the kind of line you forget and then regret. Without it, everything works — until the node goes NotReady overnight for no apparent reason.
Install k3s on the tunnel, not next to it
This is where the setup succeeds or fails. By default, k3s detects the main network interface and advertises that address to other nodes. On the VPS, that would be the public IP; on the home machine, a private router address nobody can reach.
So you have to tell it explicitly to live inside the tunnel.
On the VPS:
curl -sfL https://get.k3s.io | sh -s - server \
--node-ip 10.10.0.1 \
--advertise-address 10.10.0.1 \
--flannel-iface wg0 \
--tls-san <vps.public.ip>
On the home machine:
curl -sfL https://get.k3s.io | K3S_URL=https://10.10.0.1:6443 \
K3S_TOKEN=<the server token> sh -s - agent \
--node-ip 10.10.0.2 \
--flannel-iface wg0
The token lives on the VPS in /var/lib/rancher/k3s/server/node-token.
--flannel-iface wg0 is the heart of the matter: without it, pod networking tries to go through the public interface and nothing communicates between nodes. Pods start, the cluster looks healthy, and yet a service on one node never answers from the other.
To check that every node advertises the right address:
kubectl get nodes -o wide
The INTERNAL-IP column must show 10.10.0.1 and 10.10.0.2. Any other value means a node is living outside the tunnel.
Boot order
A subtle trap: when the machine reboots, nothing guarantees wg0 exists by the time k3s starts. k3s then tries to bind to an address that doesn’t exist yet, and the node stays broken. A simple systemd override fixes it:
sudo systemctl edit k3s-agent # "k3s" on the VPS
[Unit]
After=wg-quick@wg0.service
Requires=wg-quick@wg0.service
What about k3s’s built-in WireGuard?
k3s offers a --flannel-backend=wireguard-native backend that encrypts pod-to-pod traffic on its own. I don’t use it here, for a simple reason: it encrypts the pod network, but the agent must first be able to reach the server’s API, and that’s precisely what CGNAT prevents. The explicit tunnel, initiated from home with a keepalive, solves both problems at once and stays easy to debug with wg show.
The MTU trap
This one cost me an evening, so I’ll go into detail.
WireGuard encapsulates: it adds its own headers, which reduces the usable packet size. Flannel encapsulates on top of that, with VXLAN. The result: large packets exceed the maximum size and are silently dropped.
The symptom is baffling: ping works, curl on a small response works, but a large response freezes halfway. A kubectl logs that cuts off, an image that never finishes downloading, a git clone stuck at 47%.
The rule: flannel’s MTU must be lower than WireGuard’s, which must itself be lower than the physical link’s.
| Layer | Overhead | Typical MTU |
|---|---|---|
| Physical link | — | 1500 (1492 with PPPoE) |
| WireGuard | 60 to 80 bytes | 1420 |
| Flannel VXLAN | 50 bytes | 1370 |
The problem is that these defaults assume a 1500 link. If your router uses PPPoE, or if your ISP shrinks packets somewhere along the path, WireGuard at 1420 is already too big.
You measure it with ping while forbidding fragmentation. The size passed to -s is the payload: add 28 bytes of IP and ICMP headers to get the real MTU.
# From home, to the VPS public IP: what's the path MTU?
ping -M do -s 1472 <vps.public.ip> # 1472 + 28 = 1500
# "message too long" or no reply? Go down until it passes.
Once you know the value, set WireGuard’s MTU explicitly (path MTU minus 80) in [Interface]:
MTU = 1412
Flannel computes its own from the interface you pointed it at. Check what it picked:
cat /run/flannel/subnet.env # FLANNEL_MTU=...
Don’t copy a number you found on the internet, mine included: measure your own.
Deciding where things run
A heterogeneous cluster requires being explicit. The VPS must not get filled with heavy workloads, and some services absolutely must stay on it.
So I label nodes according to what they are:
kubectl label node <vps> location=vps
kubectl label node <worker> node-role.kubernetes.io/worker=true
kubectl label node <worker> location=home
Then I place the heavy workloads at home with a nodeSelector:
spec:
nodeSelector:
location: home
Keeping Traefik on the VPS
By default, k3s may run Traefik on any node. If it lands at home, traffic comes in through the VPS, crosses the tunnel to Traefik, then maybe comes back to a pod on the VPS: a pointless round trip, and a site that goes down with my router. k3s lets you override the configuration of its built-in Traefik:
# /var/lib/rancher/k3s/server/manifests/traefik-config.yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
nodeSelector:
location: vps
Same logic for k3s’s built-in load balancer (ServiceLB): by default it deploys a pod on every node to listen on 80 and 443. By labeling the VPS, you tell it to use only that node:
kubectl label node <vps> svccontroller.k3s.cattle.io/enablelb=true
As soon as one node carries this label, ServiceLB restricts itself to labeled nodes.
Securing the front door
The VPS is the only exposed machine, so that’s the one you lock down. The minimum:
sudo ufw default deny incoming
sudo ufw allow 22/tcp
sudo ufw allow 80,443/tcp
sudo ufw allow 51820/udp
sudo ufw allow in on wg0 # everything coming through the tunnel is trusted
sudo ufw enable
Note what’s deliberately missing: port 6443 of the Kubernetes API isn’t open to the internet. To use kubectl from my laptop, I add it as a third WireGuard peer (10.10.0.3) and point my kubeconfig at https://10.10.0.1:6443. The API is then only reachable from inside the tunnel. If you make the same choice, the --tls-san option with the public IP becomes unnecessary.
What this architecture doesn’t do
You have to be honest about the limits, otherwise the demo becomes a lie.
There is no high availability. A single control plane: if it goes down, nothing gets scheduled anymore. Pods already running keep running, but the cluster no longer reacts. With a single server, k3s stores cluster state in SQLite, under /var/lib/rancher/k3s/server/db/: that folder, along with the token, is what you need to back up to be able to rebuild. (The next section shows how to lift this limit.)
The home machine isn’t reliable. Power outage, router reboot, system update: the node disappears. Kubernetes waits about five minutes before considering its pods lost and trying to restart them elsewhere. Anything that must always answer has no business being there.
Storage doesn’t follow. With k3s’s local-path provisioner, a volume lives on the disk of the node that created it, and it’s bound to that node. If the machine goes down, the pod using it won’t be restarted elsewhere: it will stay Pending until the node comes back. And if the machine dies, the data dies with it. Any data that matters must be backed up elsewhere, explicitly — a scheduled pg_dump to object storage, for example.
Latency is real. The tunnel crosses the internet. For containers that chat a lot with each other, better keep them on the same node.
Scaling out: several VPSs, several machines
Everything so far fits on two nodes, but nothing limits it to two. The same principle — a private WireGuard network, with k3s living inside it — extends to a real cluster: several control planes on VPSs for high availability, and as many personal machines as you have on hand for raw power.
Internet
|
+-------------------+-------------------+
v v v
+------------+ +------------+ +------------+
| VPS 1 |<---->| VPS 2 |<---->| VPS 3 | control planes + etcd
| 10.10.0.1 | | 10.10.0.2 | | 10.10.0.3 | Traefik on each
+-----+------+ +-----+------+ +-----+------+ (VPS 1 <-> VPS 3 too)
| | |
========+===================+===================+=== every home -> every VPS
| | |
+-----+------+ +-----+------+ +-----+------+
| Home A | | Home B | | Home C | workers
| 10.10.0.11 | | 10.10.0.12 | | 10.10.0.13 |
+------------+ +------------+ +------------+
Plan your addresses from the start
A simple addressing plan saves you from renumbering everything later:
| Range | Use |
|---|---|
10.10.0.1 – 10.10.0.9 |
VPSs (control planes) |
10.10.0.10 – 10.10.0.99 |
personal machines (workers) |
10.10.0.100 and up |
admin devices (laptop, etc.) |
Three control planes, not two
k3s can run several servers with an embedded etcd database. etcd works by quorum: the cluster stays available as long as a majority of servers respond. With two servers, the majority is… two: losing one blocks everything, exactly like with a single server. That’s why you go straight to three — you can then lose one without any downtime.
The rule is easy to formalize. For a cluster of servers, the quorum is:
And the number of failures the cluster tolerates without stopping is:
| Servers ( ) | Quorum ( ) | Tolerated failures ( ) |
|---|---|---|
| 1 | 1 | 0 |
| 2 | 2 | 0 |
| 3 | 2 | 1 |
| 4 | 3 | 1 |
| 5 | 3 | 2 |
The table shows two things. Going from 1 to 2 servers buys no tolerance at all: you pay for one more VPS for nothing. And an even number is never useful: 4 servers tolerate as many failures as 3, while adding one more member whose agreement etcd has to wait for. So stick to odd numbers — 3 is plenty here, 5 if you want to survive two simultaneous failures.
The first server initializes the cluster:
curl -sfL https://get.k3s.io | K3S_TOKEN=<a shared secret> sh -s - server \
--cluster-init \
--node-ip 10.10.0.1 \
--advertise-address 10.10.0.1 \
--flannel-iface wg0
The next ones join it:
curl -sfL https://get.k3s.io | K3S_TOKEN=<the same secret> sh -s - server \
--server https://10.10.0.1:6443 \
--node-ip 10.10.0.2 \
--advertise-address 10.10.0.2 \
--flannel-iface wg0
Workers only need a server address for their first connection. Once registered, the k3s agent knows the list of all servers and balances its connections itself: if 10.10.0.1 goes down, it switches to the others with no intervention.
The golden rule: etcd doesn’t go home. etcd is very sensitive to network latency and stability. Control planes behind a home router, linked by a tunnel across the internet, are a recipe for a cluster that loses quorum at every outage. Control planes go on VPSs, ideally with the same provider and in the same region to keep latency between them low. Home only provides workers.
If three VPSs are over budget, k3s also accepts an external datastore (--datastore-endpoint pointing at a PostgreSQL) with only two servers. But high availability then moves to that database: if there’s only one, you’ve just moved the single point of failure.
The WireGuard topology
With two nodes, the tunnel was obvious. With six, the temptation is to connect everything to everything: a full mesh. That’s the right idea, with one physical limit.
What’s impossible: connecting two homes directly. WireGuard doesn’t do NAT traversal. For a tunnel to come up, at least one of the two peers must be reachable from outside. Two machines behind CGNAT, in two different places, therefore can’t talk without an intermediary.
What’s possible: connecting every home to every VPS. The home always initiates the connection, and every VPS is reachable. That gives you a full mesh everywhere the network allows it:
| Link | Direct? | How |
|---|---|---|
| VPS ↔ VPS | yes | each VPS declares the others with an Endpoint |
| Home ↔ VPS | yes | each home initiates to each VPS, with keepalive |
| Home ↔ home, same LAN | yes | Endpoint set to the local address |
| Home ↔ home, different places | no | relayed by a VPS |
This is what makes high availability real. A home attached to a single VPS loses the whole cluster when that VPS goes down. Connected to all three, the k3s agent can switch to another server without the tunnel getting in the way.
Every VPS that relays needs IP forwarding enabled:
echo 'net.ipv4.ip_forward=1' | sudo tee /etc/sysctl.d/99-wg.conf
sudo sysctl --system
On home A, one peer per VPS. One constraint drives everything else: in WireGuard, an address can only belong to one peer. So each VPS carries its own address, and you pick a relay VPS (VPS 1 here) for the other homes’ addresses:
# Home A — 10.10.0.11
[Peer] # VPS 1, which also relays to the other homes
PublicKey = <VPS 1 public key>
Endpoint = <vps1.public.ip>:51820
AllowedIPs = 10.10.0.1/32, 10.10.0.12/32, 10.10.0.13/32
PersistentKeepalive = 25
[Peer] # VPS 2
PublicKey = <VPS 2 public key>
Endpoint = <vps2.public.ip>:51820
AllowedIPs = 10.10.0.2/32
PersistentKeepalive = 25
[Peer] # VPS 3
PublicKey = <VPS 3 public key>
Endpoint = <vps3.public.ip>:51820
AllowedIPs = 10.10.0.3/32
PersistentKeepalive = 25
On the VPS side it’s simpler: each peer carries only its own address, since every machine connects directly to every VPS:
# VPS 1 — 10.10.0.1 (VPS 2 and 3 follow the same structure)
[Peer] # VPS 2
PublicKey = <VPS 2 public key>
Endpoint = <vps2.public.ip>:51820
AllowedIPs = 10.10.0.2/32
[Peer] # VPS 3
PublicKey = <VPS 3 public key>
Endpoint = <vps3.public.ip>:51820
AllowedIPs = 10.10.0.3/32
[Peer] # Home A (no Endpoint: it's the one calling)
PublicKey = <home A public key>
AllowedIPs = 10.10.0.11/32
# … an identical block for homes B and C
Traffic from home A to home B therefore goes to VPS 1, which sends it back down home B’s tunnel. If VPS 1 goes down, only that home-to-home traffic is cut: each home stays connected to the other two VPSs, and therefore to the cluster. To limit the impact, keep chatty services within a single home.
Machines in the same house talk directly. If two machines sit on the same local network, declare them as each other’s peers with their local address as Endpoint (192.168.1.x:51820), and remove their address from the relay VPS’s list. Traffic between them stays on the LAN, at full speed.
The cost is the number of links. With VPSs and home machines, you have to maintain:
With 3 VPSs and 3 homes, that’s 3 + 9 = 12 tunnels, and every new machine touches the config of every VPS. A single AllowedIPs mistake is enough to isolate a node.
Beyond five or six nodes, stop doing it by hand. Headscale (the self-hosted version of Tailscale) or Netmaker handle the mesh and key distribution for you. Headscale even goes further than plain WireGuard: it attempts NAT traversal (hole punching) to connect two homes directly, and only falls back to a relay if that fails. k3s offers an experimental Tailscale integration via the --vpn-auth option. The principle stays the same: k3s lives on the private network’s interface.
Describing the topology to Kubernetes
With several nodes of each kind, labels get more precise. Kubernetes has a convention for this:
kubectl label node vps-1 topology.kubernetes.io/zone=vps location=vps
kubectl label node home-a topology.kubernetes.io/zone=home-a location=home
kubectl label node home-b topology.kubernetes.io/zone=home-b location=home
You can then ask Kubernetes to spread a service’s replicas across different machines, so a single power outage doesn’t take them all down:
spec:
replicas: 3
template:
spec:
nodeSelector:
location: home
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: my-service
Several front doors
With three VPSs, you might as well have all three receive traffic. Keep the Traefik override shown earlier (nodeSelector: location: vps), label each VPS for ServiceLB, and run one Traefik per VPS:
spec:
valuesContent: |-
deployment:
kind: DaemonSet
nodeSelector:
location: vps
What’s left is distributing visitors. The simplest solution is to declare all three public IPs in DNS (several A records for the same name). It has a limit: DNS doesn’t check server health, and a failed VPS will keep receiving a share of visitors until you remove it. For real automatic failover, you need a provider load balancer, a floating IP, or DNS with health checks.
Storage, finally shareable
With several machines at home, the local storage problem changes nature: you can replicate. Longhorn, for example, keeps several copies of each volume on different nodes, and a pod can restart elsewhere and find its data. But replication is synchronous and chatty: keep it to machines on the same local network, never across the internet tunnel.
What changes, in short
| 1 VPS + 1 machine | 3 VPSs + N machines | |
|---|---|---|
| A VPS fails | cluster frozen | no visible impact |
| A machine fails | its pods stop | replicas restarted elsewhere |
| Storage | local, not replicated | replicated between machines on the same LAN |
| WireGuard | one tunnel | VPS mesh + every home to every VPS, then a dedicated tool |
| Cost | one VPS | three VPSs |
High availability has a price — two more VPSs. The whole point of this architecture is that you get to choose when you pay it: start with two nodes, and add control planes the day an outage actually becomes a problem.
When it doesn’t work
| Symptom | Likely cause | Check |
|---|---|---|
Node NotReady after a quiet night |
tunnel expired in the NAT | wg show → latest handshake |
| Pods can’t reach each other across nodes | flannel outside the tunnel | kubectl get nodes -o wide |
| Large responses freeze halfway | MTU too large | ping -M do -s ... |
| Agent fails after a reboot | k3s started before wg0 |
journalctl -u k3s-agent |
| Certificate never issued | Traefik off the VPS, or port 80 closed | kubectl get pods -n kube-system -o wide |
What it gives me
One VPS to pay for. Dozens of gigabytes of RAM available for my projects. And a cluster that behaves like what I run into in production — same manifests, same ingress, same certificates, same errors to diagnose.
This site runs on it. The “live” section, further down the home page, shows the commit actually deployed and how long the pod has been running. It’s not a screenshot: it’s the server answering. The commit is injected at build time, and Kubernetes can even tell the pod which node it lives on:
env:
- name: GIT_SHA
value: "<injected by CI>"
- name: NODE_NAME
valueFrom:
fieldRef:
fieldPath: spec.nodeName
If you have an old machine sitting in a closet, it’s worth more than the line item for a second server.