← The Vision

How to Build a Self-Hosted Private Cloud on Your VPS with Docker and Kubernetes

From bare metal to a production-grade Kubernetes cluster: the definitive guide to running your own sovereign cloud infrastructure on a single VPS—complete with GitOps, observability, security, backup, and disaster recovery

·
AI Generated Featured Image

Why Build Your Own Private Cloud?

Close-up of server racks in a data center highlighting modern technology infrastructure.
Photo by panumas nikhomkhai on Pexels

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
Who This Guide Is For
  • 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

LayerTechnologyPurposeAlternative
OSUbuntu 24.04 LTSBase system, long-term supportDebian 12, Rocky Linux 9
Container RuntimeDocker + containerdImage building, development workflowPodman, nerdctl
Kubernetesk3s (embedded SQLite etcd)Control plane, single binaryk0s, Talos, kubeadm
StorageLonghornBlock storage, snapshots, backupOpenEBS, Rook-Ceph, local-path
IngressTraefikL7 routing, TLS termination, metricsNGINX Ingress, Caddy, Envoy
Certificatescert-manager + Let's EncryptAutomated TLS lifecycleTraefik built-in ACME, Caddy
GitOpsFlux CDDeclarative cluster reconciliationArgoCD, Fleet
ObservabilityPrometheus, Grafana, Loki, TempoMetrics, logs, traces, dashboardsDatadog, New Relic, Elastic
SecurityKyverno, Sealed Secrets, TrivyPolicy, secret encryption, scanningOPA/Gatekeeper, Vault, Cosign
BackupVelero + Longhorn + rcloneK8s resources, volumes, etcdKopia, Restic, Kasten
Image RegistryHarbor / local registryPrivate images, vulnerability scanGHCR, Docker Hub, Quay
Secrets ManagementExternal Secrets Operator + VaultSync secrets from external storesSealed 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.com pointing 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:

bash
#!/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)

bash
# 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:

bash
# 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:

bash
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
Pro Tip: Kubeconfig Management

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.

bash
# 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):

bash
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.

yaml
# 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:

yaml
# 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

bash
# 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

yaml
# 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

bash
# 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)

bash
# k3s writes SQLite snapshots to /var/lib/rancher/k3s/server/db/snapshots every 5 minutes
# Sync to S3 via rclone (systemd timer)

cat     
SPREAD THE SIGNAL

Know someone who would love this?

Share this journey and keep the conversation moving.

YOUR TAKE

What did this spark in you?

Every perspective adds another layer to the story.

Select an echo above to share your reflection.
Join 1 reader who shared their reaction. Top echo: Learned Something (1)
CONTINUE THE CONVERSATION

Want to say more?

Tell us which idea stayed with you.

Comments (0)

Leave a Comment

Security Verification
Loading code…
Type the random letters and numbers shown above.

Comments are moderated and will appear after approval.

Album artwork
NOW PLAYING:
HK Media Radio
Tap play to tune in
PREV: HK Broadcast Stream • UP NEXT: Live Music Mix