Building a Multi-Cloud Kubernetes Platform with eBPF & WireGuard
Table of Contents
This is a hands-on walkthrough of building an 8-node Kubernetes cluster that spans three independent infrastructure providers: two Fasthosts VPS servers running a high-availability control plane, three AWS EC2 instances, and three GCP Compute Engine instances - all connected by Cilium eBPF, with WireGuard encrypting every packet that crosses the public internet.
The layout is built for multi-tenant workloads: each tenant’s worker pool is isolated inside its own cloud provider’s VPC, so inter-node traffic never leaves that provider’s network and cross-cloud data-plane egress stays at zero. Everything in this post is the foundation for that - a production-shaped, fully encrypted, multi-cloud Kubernetes platform you can run yourself.
No kube-proxy. No cloud VPNs. Just the Linux kernel doing the heavy lifting.
Architecture
┌────────────────────────────────────────────────────────┐
│ FASTHOSTS (Management Hub) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ k8s-cp-01 (Primary) │ │ k8s-cp-02 (HA Sync) │ │
│ └──────────┬───────────┘ └──────────┬───────────┘ │
└─────────────┼───────────────────────────┼──────────────┘
│ │
====================================│===========================│======================
│ Cilium WireGuard Tunnel │ (Public Internet)
│ (UDP Port 51871) │
====================================│===========================│======================
│ │
┌──────────────────────┴─────────────┬─────────────┘
│ │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ AWS POOL │ │ GCP POOL │
│ (Tenant A Workers) │ │ (Tenant B Workers) │
│ • k8s-worker-aws-01 │ │ • k8s-worker-gcp-01 │
│ • k8s-worker-aws-02 │ │ • k8s-worker-gcp-02 │
│ • k8s-worker-aws-03 │ │ • k8s-worker-gcp-03 │
└─────────────────────────┘ └─────────────────────────┘
The control plane lives on cost-effective VPS infrastructure. Each tenant’s worker pool is isolated within its own cloud provider’s VPC - tenant traffic never leaves that provider’s network, so there are zero cross-cloud data-plane egress costs. The only traffic crossing the public internet is control-plane communication: API server <-> kubelet heartbeats, Cilium policy sync, Hubble telemetry. All of it encrypted.
Node inventory:
| Role | Provider | Count | Type | Labels |
|---|---|---|---|---|
| Control Plane | Fasthosts VPS | 2 | 2 vCPU / 4 GB | - |
| Tenant A Workers | AWS EC2 | 3 | t3.small (2 vCPU / 2 GB) | cloud=aws,tenant=tenant-a |
| Tenant B Workers | GCP Compute Engine | 3 | e2-small (2 vCPU / 2 GB) | cloud=gcp,tenant=tenant-b |
Port matrix:
| Port | Protocol | Scope | Purpose |
|---|---|---|---|
| 6443 | TCP | Control plane nodes | Kubernetes API |
| 2379–2380 | TCP | Fasthosts CP nodes | etcd HA sync |
| 51871 | UDP | All nodes | Cilium WireGuard |
| 10250 | TCP | All worker nodes | Kubelet API |
Prerequisites
Before we touch Kubernetes, eight machines need to exist. I provisioned them using the native cloud CLIs - here’s how:
Fasthosts (2 nodes): Manual provisioning via their console. Two standard VPS instances running Ubuntu 26.04 LTS, 2 vCPU / 4 GB each. Record both public IPs - we’ll need them throughout.
AWS (3 nodes):
$ SG_ID=$(aws ec2 create-security-group --group-name k8s-multicloud-workers \
$ --description "K8s multi-cloud workers" --query "GroupId" --output text)
$ aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 10250 --cidr 0.0.0.0/0
$ aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol udp --port 51871 --cidr 0.0.0.0/0
$ for i in {1..3}; do
$ aws ec2 run-instances --image-id <UBUNTU_26.04_AMI> --count 1 \
$ --instance-type t3.small --key-name k8s-multicloud-key \
$ --security-group-ids $SG_ID --associate-public-ip-address \
$ --tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=k8s-worker-aws-0$i},{Key=Tenant,Value=tenant-a}]"
$ done
Gotcha: Use
--security-group-ids(plural, with the ID), not--security-groups(singular, with the name). Mixing the two gives youInvalidParameterCombination.
GCP (3 nodes):
$ gcloud compute firewall-rules create k8s-allow-kubelet \
$ --direction=INGRESS --action=ALLOW --rules=tcp:10250 \
$ --source-ranges=0.0.0.0/0 --target-tags=k8s-multicloud-workers
$ gcloud compute firewall-rules create k8s-allow-wireguard \
$ --direction=INGRESS --action=ALLOW --rules=udp:51871 \
$ --source-ranges=0.0.0.0/0 --target-tags=k8s-multicloud-workers
$ PUB_KEY=$(cat ~/.ssh/k8s-multicloud-key.pub)
$ for i in {1..3}; do
$ gcloud compute instances create k8s-worker-gcp-0$i \
$ --machine-type=e2-small --image-family=ubuntu-2604-lts-amd64 \
$ --image-project=ubuntu-os-cloud --tags=k8s-multicloud-workers \
$ --labels=tenant=tenant-b,cloud=gcp --metadata=ssh-keys="ubuntu:$PUB_KEY"
$ done
Step 1: OS Preparation (All 8 Nodes)
Every node needs the same kernel tweaks. SSH into each one and run:
# Kernel modules required by Cilium eBPF
$ cat <<EOF | sudo tee /etc/modules-load.d/k8s.conf
$ overlay
$ br_netfilter
$ EOF
$ sudo modprobe overlay
$ sudo modprobe br_netfilter
# IP forwarding - mandatory for pod networking
$ cat <<EOF | sudo tee /etc/sysctl.d/k8s.conf
$ net.bridge.bridge-nf-call-iptables = 1
$ net.bridge.bridge-nf-call-ip6tables = 1
$ net.ipv4.ip_forward = 1
$ EOF
$ sudo sysctl --system
# Kubernetes requires swap disabled
$ sudo swapoff -a
$ sudo sed -i '/swap/d' /etc/fstab
Step 2: Install containerd (All 8 Nodes)
$ sudo apt-get update -qq
$ sudo apt-get install -y -qq containerd
$ sudo mkdir -p /etc/containerd
$ containerd config default | sudo tee /etc/containerd/config.toml > /dev/null
# Cilium requires the systemd cgroup driver
$ sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
$ sudo systemctl restart containerd
$ sudo systemctl enable containerd
Step 3: Install kubeadm, kubelet, kubectl
On control plane nodes (kubectl is only needed here):
$ K8S_VERSION="1.36"
$ K8S_MAJOR=$(echo "$K8S_VERSION" | cut -d. -f1-2)
$ sudo mkdir -p /etc/apt/keyrings
$ curl -fsSL "https://pkgs.k8s.io/core:/stable:/v${K8S_MAJOR}/deb/Release.key" \
$ | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
$ echo "deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] \
$ https://pkgs.k8s.io/core:/stable:/v${K8S_MAJOR}/deb/ /" \
$ | sudo tee /etc/apt/sources.list.d/kubernetes.list > /dev/null
$ sudo apt-get update -qq
$ sudo apt-get install -y -qq kubelet kubeadm kubectl
$ sudo apt-mark hold kubelet kubeadm kubectl
On worker nodes (no kubectl needed):
# Same steps, but:
$ sudo apt-get install -y -qq kubelet kubeadm
Configure kubelet to advertise the public IP
On Fasthosts CP nodes, the public IP is on a local interface, so --node-ip works:
$ LOCAL_IP=$(curl -s ifconfig.me)
$ echo "KUBELET_EXTRA_ARGS=--node-ip=${LOCAL_IP}" | sudo tee /etc/default/kubelet
$ sudo systemctl restart kubelet
On cloud workers, the public IP is NAT-based - not on any local interface - so kubelet silently ignores --node-ip. This means kubectl get nodes -o wide will show <none> for INTERNAL-IP on AWS and GCP nodes. That’s fine. Cilium maintains its own node registry with the correct public IPs for WireGuard. The Kubernetes Node object’s INTERNAL-IP field is cosmetic.
Step 4: Initialize the Control Plane (k8s-cp-01)
Here’s where we diverge from every Kubernetes tutorial: we’re skipping kube-proxy entirely. Cilium eBPF replaces it at the socket level.
$ CP1_PUBLIC_IP=$(curl -s ifconfig.me)
$ sudo kubeadm init \
$ --apiserver-advertise-address="${CP1_PUBLIC_IP}" \
$ --control-plane-endpoint="${CP1_PUBLIC_IP}:6443" \
$ --pod-network-cidr=10.244.0.0/16 \
$ --kubernetes-version=v1.36.0 \
$ --skip-phases=addon/kube-proxy
Output includes the join commands we’ll need. Save everything.
Set up kubectl
$ mkdir -p $HOME/.kube
$ sudo cp /etc/kubernetes/admin.conf $HOME/.kube/config
$ sudo chown $(id -u):$(id -g) $HOME/.kube/config
Get join tokens
# Worker join command
$ kubeadm token create --print-join-command
# Certificate key for HA control plane join (needs sudo - this caught me out)
$ sudo kubeadm init phase upload-certs --upload-certs
My cluster’s values:
# Worker join:
kubeadm join 74.208.24.234:6443 --token <TOKEN> \
--discovery-token-ca-cert-hash sha256:<HASH>
# CP join cert key:
<64-CHAR-HEX-KEY>
Step 5: Install Helm
$ curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
$ helm version
Step 6: Install Cilium CNI with WireGuard
This is the centerpiece of the post. Cilium replaces kube-proxy for service routing, and WireGuard transparently encrypts ALL node-to-node traffic crossing the public internet.
There are two gotchas here that cost me a couple of hours.
Gotcha #1: On a single-node cluster, Cilium tries to reach the API server via the Kubernetes ClusterIP (10.96.0.1:443). But Cilium IS the CNI - it hasn’t routed that IP yet. Deadlock. The fix is --set k8sServiceHost and --set k8sServicePort, which tell Cilium to connect to the API server directly via the public IP until it’s ready to route the ClusterIP itself.
Gotcha #2: The default 2-operator-replica deployment can’t schedule on a single node. Scale to 1 immediately after install.
$ helm repo add cilium https://helm.cilium.io/
$ helm repo update
$ CP1_PUBLIC_IP=$(curl -s ifconfig.me)
$ helm install cilium cilium/cilium \
$ --namespace kube-system \
$ --set kubeProxyReplacement=true \
$ --set encryption.enabled=true \
$ --set encryption.type=wireguard \
$ --set encryption.nodeEncryption=true \
$ --set cluster.name=multi-cloud-k8s \
$ --set ipam.mode=cluster-pool \
$ --set ipam.operator.clusterPoolIPv4PodCIDRList[0]="10.244.0.0/16" \
$ --set k8sServiceHost="${CP1_PUBLIC_IP}" \
$ --set k8sServicePort=6443
# Scale operator for single-node
$ kubectl -n kube-system scale deployment cilium-operator --replicas=1
Wait for Cilium to come up:
$ cilium status --wait
Why encryption.nodeEncryption=true matters: By default, Cilium’s WireGuard only encrypts pod-to-pod traffic. Node encryption ensures ALL traffic - kubelet health checks, host-network daemons, Hubble telemetry - gets encrypted when crossing the public internet.
Step 7: Join the Second Control Plane (k8s-cp-02)
On cp-02, run Steps 1–3 (OS prep, containerd, kubeadm/kubelet) first. Then:
$ sudo kubeadm join 74.208.24.234:6443 \
$ --token <TOKEN> \
$ --discovery-token-ca-cert-hash sha256:<HASH> \
$ --control-plane \
$ --certificate-key <CERT_KEY> \
$ --node-name k8s-cp-02
Both CP nodes have a control-plane taint that blocks regular pods from scheduling. Since we don’t have worker nodes yet, remove the taint from cp-02 so Prometheus and Grafana can land:
$ kubectl taint node k8s-cp-02 node-role.kubernetes.io/control-plane:NoSchedule-
Verify:
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
k8s-cp-02 Ready control-plane 10m v1.36.3
ubuntu Ready control-plane 142m v1.36.3
Step 8: Verify WireGuard Encryption
With two nodes, WireGuard has a peer to talk to:
# Install tools
$ sudo apt-get install -y wireguard-tools
# Check the WireGuard interface
$ sudo wg show cilium_wg0
interface: cilium_wg0
listening port: 51871
peer: jPHq6ZW1kD4i1zrbIaDbafi/SVtV7Yy3wrhZ5PWMAhg=
endpoint: 198.251.78.107:51871
allowed ips: 198.251.78.107/32
latest handshake: 32 seconds ago
transfer: 6.95 KiB received, 6.74 KiB sent
A peer with latest handshake: X seconds ago and actual transfer bytes - the encrypted tunnel is live. Now let’s prove it with tcpdump.
Find the network interface (Ubuntu 26.04 uses predictable names - ens6 in my case, not eth0):
$ ip link show | grep -E '^[0-9]+:' | grep -v lo | head -3
Then:
$ sudo tcpdump -n -i ens6 udp port 51871
00:01:12.292923 IP 198.251.78.107.51871 > 74.208.24.234.51871: UDP, length 144
00:01:12.293187 IP 74.208.24.234.51871 > 198.251.78.107.51871: UDP, length 144
00:01:24.709815 IP 198.251.78.107.51871 > 74.208.24.234.51871: UDP, length 144
00:01:24.710198 IP 74.208.24.234.51871 > 198.251.78.107.51871: UDP, length 144
Only encrypted UDP on port 51871. No plain-text pod traffic anywhere. Every packet between these two VPS instances - sitting on different physical hosts, crossing the open internet - is scrambled inside a WireGuard tunnel.
Step 9: Prometheus + Grafana Observability Stack
The kube-prometheus-stack Helm chart is powerful but heavy. With only 4 GB per CP node, we need to tune it way down. Learned this the hard way - the default install includes AlertManager (we don’t need it for a demo), admission webhooks (need TLS certs that break on small clusters), and a 10-day data retention policy that eats RAM for breakfast.
$ helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
$ helm repo update
$ helm install prometheus prometheus-community/kube-prometheus-stack \
$ --namespace monitoring \
$ --create-namespace \
$ --set alertmanager.enabled=false \
$ --set prometheus.prometheusSpec.retention=24h \
$ --set prometheus.prometheusSpec.resources.requests.memory=512Mi \
$ --set prometheus.prometheusSpec.resources.requests.cpu=150m \
$ --set prometheus.prometheusSpec.serviceMonitorSelectorNilUsesHelmValues=false \
$ --set grafana.adminPassword=admin \
$ --set grafana.resources.requests.memory=128Mi \
$ --set nodeExporter.resources.requests.memory=64Mi \
$ --set kube-state-metrics.resources.requests.memory=128Mi \
$ --set prometheusOperator.admissionWebhooks.enabled=false \
$ --set prometheusOperator.admissionWebhooks.patch.enabled=false \
$ --set prometheusOperator.tls.enabled=false
The last three flags - admissionWebhooks.enabled=false, admissionWebhooks.patch.enabled=false, and tls.enabled=false - prevent the operator from trying to start a TLS-secured webhook server that would need certificates we haven’t provisioned. For a demo cluster, this is fine.
Verify:
$ kubectl -n monitoring get pods
NAME READY STATUS RESTARTS AGE
prometheus-grafana-554f4fbc7b-ftbgq 3/3 Running 0 2m
prometheus-kube-prometheus-operator-644d8979cd-xrnft 1/1 Running 0 2m
prometheus-kube-state-metrics-8995b68d6-gb68d 1/1 Running 0 2m
prometheus-prometheus-kube-prometheus-prometheus-0 2/2 Running 0 2m
prometheus-prometheus-node-exporter-r9t6d 1/1 Running 0 2m
prometheus-prometheus-node-exporter-rqvqk 1/1 Running 0 2m
Access Grafana:
$ kubectl -n monitoring port-forward svc/prometheus-grafana 3000:80
# From your local machine: ssh -L 3000:localhost:3000 clusteradmin@<CP1_IP>
# Open http://localhost:3000, login admin/admin
Pre-built dashboards for Kubernetes Compute Resources and Networking are ready to go. For Cilium-specific eBPF metrics, import dashboard 16611 from the Grafana community library and patch the Cilium agent service to expose metrics:
$ kubectl -n kube-system patch svc cilium-agent \
$ -p '{"spec":{"ports":[{"name":"metrics","port":9962,"protocol":"TCP","targetPort":9962}]}}'
Step 10: Enable Hubble
Hubble gives you real-time eBPF flow visualization - pod-to-pod traffic, dropped packets, L4/L7 policy decisions, all rendered as a service map:
$ cilium hubble enable --ui
$ kubectl -n kube-system get pods | grep hubble
hubble-relay-6fb864ff5d-skgbf 1/1 Running 0 26s
hubble-ui-677bcd7f96-jg9d8 2/2 Running 0 26s
Access the UI:
$ cilium hubble ui
# Or: kubectl -n kube-system port-forward svc/hubble-ui 12000:80
Step 11: Join the Worker Nodes
Now for the real multi-cloud part. Each worker needs the OS prep (Step 1), containerd (Step 2), and kubeadm/kubelet (Step 3). Then:
AWS Workers (Tenant A)
On each AWS instance:
$ sudo kubeadm join 74.208.24.234:6443 \
$ --token <TOKEN> \
$ --discovery-token-ca-cert-hash sha256:<HASH> \
$ --node-name k8s-worker-aws-0X # Replace X with 1, 2, 3
Then on cp-01, label each:
$ kubectl label node k8s-worker-aws-01 cloud=aws tenant=tenant-a
$ kubectl label node k8s-worker-aws-02 cloud=aws tenant=tenant-a
$ kubectl label node k8s-worker-aws-03 cloud=aws tenant=tenant-a
GCP Workers (Tenant B)
On each GCP instance:
$ sudo kubeadm join 74.208.24.234:6443 \
$ --token <TOKEN> \
$ --discovery-token-ca-cert-hash sha256:<HASH> \
$ --node-name k8s-worker-gcp-0X # Replace X with 1, 2, 3
On cp-01:
$ kubectl label node k8s-worker-gcp-01 cloud=gcp tenant=tenant-b
$ kubectl label node k8s-worker-gcp-02 cloud=gcp tenant=tenant-b
$ kubectl label node k8s-worker-gcp-03 cloud=gcp tenant=tenant-b
Note:
--node-labelsis not akubeadm joinflag (despite what some docs suggest). Labels are applied withkubectl label nodeafter join.
Cilium DaemonSets auto-deploy to every new node. Node exporters appear automatically. The WireGuard mesh expands - each new node generates a keypair, registers its public key as a Kubernetes annotation, and every other node adds it as a peer.
Step 12: Full Validation
After all 6 workers join, let’s verify the complete platform:
# ── Nodes ──
$ kubectl get nodes -o wide
# Expected: 8 nodes, all Ready
# ── Cilium DaemonSets ──
$ cilium status | grep -E "Desired|Ready|Available"
# Expected: Desired: 8, Ready: 8/8, Available: 8/8
# ── WireGuard peers ──
$ sudo wg show cilium_wg0 | grep -c "latest handshake"
# Expected: 7 (one per other node)
# ── Tenant labels ──
$ kubectl get nodes -l cloud=aws --no-headers | wc -l # → 3
$ kubectl get nodes -l cloud=gcp --no-headers | wc -l # → 3
# ── Observability ──
$ kubectl -n monitoring get pods # all Running
$ kubectl -n kube-system get pods | grep hubble # relay + UI Running
My final cluster:
$ kubectl get nodes -o wide
NAME STATUS ROLES AGE VERSION INTERNAL-IP
k8s-cp-02 Ready control-plane 131m v1.36.3 198.251.78.107
k8s-worker-aws-01 Ready <none> 79m v1.36.3 <none>
k8s-worker-aws-02 Ready <none> 61m v1.36.3 <none>
k8s-worker-aws-03 Ready <none> 61m v1.36.3 <none>
k8s-worker-gcp-01 Ready <none> 38m v1.36.3 <none>
k8s-worker-gcp-02 Ready <none> 57m v1.36.3 <none>
k8s-worker-gcp-03 Ready <none> 58m v1.36.3 <none>
ubuntu Ready control-plane 4h33m v1.36.3 74.208.24.234
The <none> INTERNAL-IP on cloud workers is expected - their public IP is NAT-based, not on a local interface. The Kubernetes Node object doesn’t need it. Cilium manages its own node registry with the correct public IPs for WireGuard endpoints.
$ sudo wg show cilium_wg0 | grep -c "latest handshake"
7
Seven peers, all with recent handshakes and active data transfer. The control plane on Fasthosts, the AWS workers in us-east-1, and the GCP workers in us-east1-b - all part of one encrypted mesh.
What We Built
A single Kubernetes cluster spanning three independent infrastructure providers:
- HA control plane on Fasthosts VPS with etcd quorum across two nodes
- 3 AWS workers tagged for Tenant A, 3 GCP workers tagged for Tenant B
- Cilium eBPF replacing kube-proxy for all service routing and load balancing
- WireGuard encrypting every packet between every node - no plain-text traffic on the public internet
- Prometheus + Grafana monitoring all 8 nodes with per-node exporters
- Hubble providing real-time eBPF flow visualization
Every design decision was intentional. The control plane lives on commodity VPS. The data plane is isolated per cloud provider - Tenant A’s workload traffic stays inside AWS, Tenant B’s stays inside GCP. The only cross-cloud traffic is lightweight control-plane communication, and WireGuard ensures even that is encrypted.
Wrapping Up
Eight nodes, three providers, one cluster. The control plane runs on commodity VPS, the data plane is isolated per cloud provider, and the only traffic crossing the public internet is lightweight control-plane chatter - encrypted by WireGuard, routed by Cilium eBPF, with no kube-proxy anywhere in the picture.
The real payoff of this layout shows up the moment you put stateful workloads on it. The cloud=aws and cloud=gcp node labels are all a scheduler needs for topology-aware placement, so tenant data stays inside its own provider’s network and cross-cloud egress stays at zero. Prometheus and Grafana are already watching every node, and Hubble will show you exactly which pod is talking to which - which is precisely what you want the first time something breaks.
If you build this yourself, the gotchas are worth repeating: --node-ip is silently ignored on NAT-based public IPs, --security-group-ids is not --security-groups, and Cilium needs k8sServiceHost on a fresh single-node cluster or it deadlocks against its own ClusterIP. Those three cost me the most time. Everything else is just patience.