Why Build Your Own Private Cloud?
The allure of managed Kubernetes services—EKS, GKE, AKS—is undeniable. They handle control plane upgrades, etcd backups, and node lifecycle management. But they also come with vendor lock-in, unpredictable pricing, data sovereignty concerns, and the quiet erosion of operational autonomy. For developers, researchers, and small teams who value independence, a self-hosted private cloud on a single VPS offers a compelling alternative: full root access, predictable costs, zero egress fees, and the satisfaction of owning your stack from metal to manifest.
"True infrastructure sovereignty isn't about avoiding the cloud—it's about owning the abstraction layer so the cloud serves you, not the other way around."
— Hesham Kholef
- Developers wanting production-grade Kubernetes without managed service costs
- Teams requiring data residency, compliance, or air-gapped environments
- Engineers learning Kubernetes internals by operating a real cluster
- Startups needing predictable infrastructure costs at scale
- Anyone who believes you don't truly understand a system until you've operated it
Architecture Overview: Minimal, Resilient, Extensible
We'll build a single-node control plane using k3s (lightweight Kubernetes), containerized with Docker for workload isolation and local image building, backed by Longhorn for persistent block storage with S3 backups, exposed via Traefik ingress with automatic TLS via cert-manager and Let's Encrypt, managed declaratively through Flux GitOps, observed with Prometheus/Grafana/Loki/Tempo, and secured with Kyverno, Sealed Secrets, and Trivy. This architecture runs comfortably on a 4 vCPU / 8 GB RAM / 160 GB SSD VPS—costing roughly $20–40/month—while leaving headroom for actual workloads.
Component Map
| Layer | Technology | Purpose | Alternative |
|---|---|---|---|
| OS | Ubuntu 24.04 LTS | Base system, long-term support | Debian 12, Rocky Linux 9 |
| Container Runtime | Docker + containerd | Image building, development workflow | Podman, nerdctl |
| Kubernetes | k3s (embedded SQLite etcd) | Control plane, single binary | k0s, Talos, kubeadm |
| Storage | Longhorn | Block storage, snapshots, backup | OpenEBS, Rook-Ceph, local-path |
| Ingress | Traefik | L7 routing, TLS termination, metrics | NGINX Ingress, Caddy, Envoy |
| Certificates | cert-manager + Let's Encrypt | Automated TLS lifecycle | Traefik built-in ACME, Caddy |
| GitOps | Flux CD | Declarative cluster reconciliation | ArgoCD, Fleet |
| Observability | Prometheus, Grafana, Loki, Tempo | Metrics, logs, traces, dashboards | Datadog, New Relic, Elastic |
| Security | Kyverno, Sealed Secrets, Trivy | Policy, secret encryption, scanning | OPA/Gatekeeper, Vault, Cosign |
| Backup | Velero + Longhorn + rclone | K8s resources, volumes, etcd | Kopia, Restic, Kasten |
| Image Registry | Harbor / local registry | Private images, vulnerability scan | GHCR, Docker Hub, Quay |
| Secrets Management | External Secrets Operator + Vault | Sync secrets from external stores | Sealed Secrets only, SOPS |
Prerequisites: The Foundation
Before we begin, ensure your VPS meets these baselines:
- OS: Ubuntu 24.04 LTS (clean install, minimal)
- Resources: Minimum 2 vCPU / 4 GB RAM / 80 GB disk; recommended 4 vCPU / 8 GB RAM / 160 GB+ NVMe SSD; production 8 vCPU / 16 GB RAM / 320 GB+
- Access: Root or sudo user with SSH key authentication (password auth disabled)
- Domain: A registered domain with DNS control (for TLS and ingress)
- DNS: Wildcard A record
*.yourdomain.compointing to VPS IP - Firewall: UFW or cloud provider firewall allowing ports 22, 80, 443, 6443, 8472 (Flannel VXLAN), 9000–9001 (Longhorn), 30000–32767 (NodePort range)
- S3-compatible storage: Bucket for backups (Wasabi, Backblaze B2, Cloudflare R2, MinIO, Scaleway)
- Git provider: GitHub, GitLab, Gitea, or Forgejo for Flux bootstrap
System Hardening Baseline
Apply these before installing any cluster components:
#!/usr/bin/env bash
# system-hardening.sh — Run as root on fresh Ubuntu 24.04
set -euo pipefail
# 1. Update and install essentials
apt update && apt upgrade -y
apt install -y curl gnupg2 software-properties-common apt-transport-https ca-certificates lsb-release ufw \
jq yq git vim htop iotop ncdu tree unzip rsync cron logrotate \
apparmor apparmor-utils auditd systemd-resolved
# 2. Kernel parameters for Kubernetes and security
cat > /etc/sysctl.d/99-k8s-hardening.conf /dev/null
sudo sed -i 's/SystemdCgroup = false/SystemdCgroup = true/' /etc/containerd/config.toml
sudo systemctl restart containerd
Step 2: Deploy k3s — Lightweight Kubernetes
k3s packages the Kubernetes control plane, SQLite-backed etcd, CoreDNS, Traefik ingress, and a local-path provisioner into a single binary under 100 MB. It's perfect for single-node and edge clusters. We'll disable the built-in Traefik and local-path provisioner—we'll install our own managed versions via Helm for full GitOps control.
Single-Node Installation (Development/Small Production)
# Install k3s without Traefik and local-path (we'll manage them via Helm)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable=traefik --disable=local-path-provisioner --write-kubeconfig-mode=644 --cluster-init" sh -s -
# Verify installation
sudo systemctl status k3s
kubectl get nodes -o wide
kubectl get pods -A
High-Availability Control Plane (3 Server Nodes) — For Production
When uptime SLAs demand it, run k3s with embedded etcd HA across 3 server nodes:
# On first server (bootstrap)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable=traefik --disable=local-path-provisioner --write-kubeconfig-mode=644 --cluster-init --tls-san=yourdomain.com" sh -s -
# Get the token
sudo cat /var/lib/rancher/k3s/server/node-token
# On additional servers (repeat for each)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--disable=traefik --disable=local-path-provisioner --write-kubeconfig-mode=644 --server=https://:6443 --token= --tls-san=yourdomain.com" sh -s -
# On worker nodes (agent only)
curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="--server=https://:6443 --token=" sh -s -
The installer writes a kubeconfig at /etc/rancher/k3s/k3s.yaml. For your user:
mkdir -p ~/.kube
sudo cp /etc/rancher/k3s/k3s.yaml ~/.kube/config
sudo chown $USER:$USER ~/.kube/config
chmod 600 ~/.kube/config
# Test access
kubectl cluster-info
kubectl get nodes
kubectl api-resources | head -20
Use kubectl config view --flatten > ~/.kube/config.flat to merge multiple contexts. For CI/CD, create a dedicated service account with limited RBAC and generate a long-lived token via kubectl create token (v1.24+) instead of embedding kubeconfig.
Step 3: Persistent Storage with Longhorn
Kubernetes workloads need persistent volumes—databases, object storage, ML model caches, CI/CD artifacts. Longhorn provides cloud-native distributed block storage that replicates across nodes (even on a single node, it gives you snapshots, backups to S3/NFS, and a beautiful UI). We'll install it via Helm with production settings.
# Install Helm if not present
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Add Longhorn repo and install
helm repo add longhorn https://charts.longhorn.io
helm repo update
# Create namespace and backup credentials first
kubectl create namespace longhorn-system
kubectl create secret generic longhorn-backup-credentials \
--namespace longhorn-system \
--from-literal=AWS_ACCESS_KEY_ID=your-access-key \
--from-literal=AWS_SECRET_ACCESS_KEY=your-secret-key \
--from-literal=AWS_ENDPOINTS=https://s3.your-region.amazonaws.com \
--dry-run=client -o yaml | kubectl apply -f -
helm upgrade --install longhorn longhorn/longhorn \
--namespace longhorn-system \
--create-namespace \
--version 1.7.2 \
--set defaultSettings.defaultReplicaCount=1 \
--set defaultSettings.backupTarget=s3://your-bucket@your-region/longhorn-backups \
--set defaultSettings.backupTargetCredentialSecret=longhorn-backup-credentials \
--set defaultSettings.replicaSoftAntiAffinity=false \
--set defaultSettings.replicaHardAntiAffinity=false \
--set defaultSettings.backupConcurrentLimit=2 \
--set defaultSettings.restoreConcurrentLimit=2 \
--set defaultSettings.cleanupPeriod=24h \
--set defaultSettings.defaultDataPath=/var/lib/longhorn \
--set defaultSettings.defaultEngineImage=longhornio/longhorn-engine:v1.7.2 \
--set defaultSettings.defaultInstanceManagerImage=longhornio/longhorn-instance-manager:v1_20240729 \
--set defaultSettings.defaultShareManagerImage=longhornio/longhorn-share-manager:v1_20240729 \
--set defaultSettings.backupstorePollInterval=300 \
--set persistence.defaultClassReplicaCount=1 \
--set ingress.enabled=true \
--set ingress.className=traefik \
--set ingress.host=longhorn.yourdomain.com \
--set ingress.tls.enabled=true \
--set ingress.tls.secretName=longhorn-tls \
--wait --timeout 15m
Create recurring backup schedules (run after Longhorn is ready):
cat pub-cert.pem
# Seal a secret (run locally, commit SealedSecret to Git)
kubectl create secret generic db-credentials \
--namespace=database \
--from-literal=username=appuser \
--from-literal=password=supersecret \
--dry-run=client -o yaml | \
kubeseal --cert pub-cert.pem --format yaml > sealed-db-credentials.yaml
External Secrets Operator — Sync from Vault, AWS Secrets Manager, etc.
# infrastructure/security/external-secrets/helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: external-secrets
namespace: external-secrets
spec:
interval: 1h
chart:
spec:
chart: external-secrets
version: "0.10.0"
sourceRef:
kind: HelmRepository
name: external-secrets
namespace: flux-system
values:
installCRDs: true
serviceMonitor:
enabled: true
Configure a SecretStore for HashiCorp Vault:
# apps/your-app/secretstore.yaml
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "https://vault.yourdomain.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "external-secrets"
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: app-secrets
namespace: your-app
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault-backend
target:
name: app-secrets
creationPolicy: Owner
data:
- secretKey: database-url
remoteRef:
key: apps/your-app
property: database_url
- secretKey: api-key
remoteRef:
key: apps/your-app
property: api_key
Trivy — Container Image Vulnerability Scanning
# Install Trivy operator (scans images in cluster)
helm repo add aqua https://aquasecurity.github.io/helm-charts/
helm repo update
helm upgrade --install trivy-operator aqua/trivy-operator \
--namespace trivy-system \
--create-namespace \
--version 0.22.0 \
--set operator.scanJobTimeout=10m \
--set operator.batchDeleteLimit=10 \
--set operator.batchDeleteDelay=10s \
--set trivy.ignoreUnfixed=true \
--set trivy.severity=CRITICAL,HIGH \
--wait --timeout 5m
# Or run Trivy in CI/CD pipeline (GitHub Actions example)
# - uses: aquasecurity/trivy-action@master
# with:
# scan-type: 'fs'
# scan-ref: '.'
# format: 'sarif'
# output: 'trivy-results.sarif'
Network Policies — Default Deny
# bootstrap/network-policies.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny-all
namespace: your-app
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: your-app
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-to-database
namespace: your-app
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: database
ports:
- protocol: TCP
port: 5432
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-from-ingress
namespace: your-app
spec:
podSelector: {}
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: traefik
ports:
- protocol: TCP
port: 8080
Step 9: Backup and Disaster Recovery — The Insurance Policy
A private cloud without tested backups is a time bomb. Layer your strategy across multiple dimensions:
Layer 1: Velero — Kubernetes Resource Backups
# Install Velero with S3 backend
velero install \
--provider aws \
--plugins velero/velero-plugin-for-aws:v1.9.0 \
--bucket velero-backups \
--secret-file ./credentials-velero \
--backup-location-config region=your-region,s3ForcePathStyle=true,s3Url=https://s3.your-region.amazonaws.com \
--snapshot-location-config region=your-region \
--use-volume-snapshots=false \
--default-volumes-to-fs-backup \
--wait
# Create daily backup schedule
velero schedule create daily-backup \
--schedule "0 3 * * *" \
--include-namespaces '*' \
--exclude-namespaces kube-system,flux-system,cert-manager,traefik,monitoring \
--ttl 720h
# Create weekly backup schedule
velero schedule create weekly-backup \
--schedule "0 4 * * 0" \
--include-namespaces '*' \
--ttl 2160h
Layer 2: Longhorn Volume Backups (Already Configured in Step 3)
Recurring jobs for daily/weekly/monthly snapshots to S3 with retention policies.
Layer 3: Etcd Snapshots (k3s Built-in)
# k3s writes SQLite snapshots to /var/lib/rancher/k3s/server/db/snapshots every 5 minutes
# Sync to S3 via rclone (systemd timer)
cat What did this spark in you?
Every perspective adds another layer to the story.
Want to say more?
Tell us which idea stayed with you.
Comments (0)