A personal Kubernetes homelab built with Ansible (IaC) and Argo CD (GitOps), running on VPS infrastructure.
It's designed to be rebuilt, not to never fail.
I wanted to understand how a Kubernetes platform actually fits together. Every component here was picked, configured and wired up precisely so I know what it does and why it's there.
It runs on public VPS because of no upfront hardware cost, fixed monthly pricing and availability from anywhere. But nothing here is VPS-specific: the same setup works on Proxmox VMs, bare metal or any Debian machines with SSH access.
| Component | Status | |
|---|---|---|
| ⚙️ | CI Linting — YAML, Ansible | ✅ |
| 🛡️ | CI Security — Gitleaks, Kubescape | ✅ |
| 🖥️ | 3 public VPS — 1 control plane, 2 workers (connected via vLAN) | ✅ |
| 🔧 | Ansible — Idempotent K3s cluster bootstrapping | ✅ |
| 🧱 | nftables — Adjustable IP allowlisting | ✅ |
| 🔄 | nftables auto-update — Automatic re-resolve on dynamic IP change | 🔜 |
| 🚀 | ArgoCD — App-of-Apps GitOps from this repo | ✅ |
| 🌐 | Traefik + Gateway API — HTTPS-only, IP-allowlisted | ✅ |
| 🔒 | cert-manager — Let's Encrypt wildcard TLS via Cloudflare DNS-01 | ✅ |
| 📦 | Image Registry — Pull-through cache + private images | 🔜 |
| 📊 | Monitoring — VictoriaMetrics + Grafana + email alerting | ✅ |
| 📝 | Logging — VictoriaLogs + Vector | ✅ |
| 🔍 | Visibility — Headlamp | ✅ |
| 💾 | Backup — For all stateful workloads | 🔜 |
Workloads live in a separate, private repository, deployed as an independent ArgoCD App-of-Apps.
The Ansible argocd role is already prepared for this (argocd_workloads_enabled).
What's still missing for the allmighty production ready homelab (as time permits):
- 🏗️ High availability — Multi-CP with embedded etcd, replicated deployments, topology spread constraints
- 💾 Distributed storage — Replicated persistent volumes, replacing K3s local-path
- 🐝 eBPF-based CNI — Replace Flannel with Cilium for advanced networking
- 🛡️ Policy enforcement — Admission control (resource quotas, label standards, image policies)
- 🔐 Secret management — Move from Ansible Vault to a proper solution
- 📡 OpenTelemetry — Unified collection for metrics and logs
- 🔭 Tracing / APM — Distributed tracing for workloads
- 🪪 Identity provider — SSO for all cluster apps, OIDC-based RBAC
- 🔑 VPN — Secure tunnel to the cluster, complementing IP allowlisting
- 🛒 Self-services — Internal platform portal for deploying services on demand
- 📖 Docs — Write up decisions, guides, and lessons learned
Exposing a Kubernetes cluster to the public internet is an inherently risky operation. This setup includes IP-based access control via nftables. If no allowlist is configured, Ansible will prompt with an interactive warning before proceeding. Make sure you understand the implications before deploying, and only expose services intentionally.
SSH is not filtered by nftables. The bootstrap playbook enforces key-only authentication by disabling password login via sshd configuration, ensuring sufficient security.
If your IP allowlist uses a dynamic hostname (e.g. DDNS), re-run make cluster after an IP change to update the nftables rules (dynamic adjustment is planned).
First and foremost, it's a personal setup, but I've tried to balance two goals:
- Transparency: Showing how things work in my lab
- Reusability: Providing enough context for others to adapt it
Infrastructure
- At least 2 VMs with Debian 13 and root SSH access (for initial bootstrap)
- 1 control plane, 1+ workers — connected via private network (public IPs optional but currently intended)
- SSH key at
~/.ssh/id_ed25519(key-only auth, no password) - A domain managed via Cloudflare (for TLS certs using DNS-01 challenges)
Deployment — make bootstrap, make setup
- Ansible + collections:
ansible.posix,community.general,kubernetes.core
Local checks — make lint, make scan
- yamllint, ansible-lint, kustomize, Helm, gitleaks, kubescape
Copy the .example files and fill in your values:
ansible/inventory/hosts.iniansible/inventory/group_vars/k3s.ymlansible/inventory/group_vars/controlplane/vault.ymlansible/roles/argocd/defaults/main.yml
Replace the domain in the HTTPRoute and certificate manifests with your own:
argocd/kustomize/traefik/certificate.yaml— wildcard cert scopeargocd/manifests/argocd/httproute.yaml— ArgoCD UIargocd/kustomize/monitoring/httproute.yaml— Grafana UIargocd/kustomize/headlamp/httproute.yaml— Headlamp UI
Replace the repoURL in all ArgoCD Application manifests with your fork:
argocd/apps/*.yaml
Then encrypt the vault:
cd ansible && ansible-vault encrypt inventory/group_vars/controlplane/vault.ymlmake bootstrap # one-time as root: create deploy user, enforce key-only SSH
make setup # cluster + argocd + secrets (prompts for vault password)
make lint # yamllint + ansible-lint
make scan # secret detection (gitleaks) + IaC scanning (kubescape)If your provider doesn't pre-install an SSH key for root, use --ask-pass for the initial bootstrap.
cd ansible && ansible-playbook playbooks/bootstrap.yml --ask-passCopy the kubeconfig from the control plane:
scp ansible@<control-plane-ip>:/etc/rancher/k3s/k3s.yaml ~/.kube/config
# Then replace 127.0.0.1 with the control plane's public IP- ArgoCD —
admin/ password from:kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d - Grafana —
admin/ password set viagrafana_admin_passwordin vault - Headlamp — token from:
kubectl create token headlamp --namespace headlamp
Note: These access methods (static credentials, kubeconfig with full cluster-admin, manually created tokens) are sufficient for initial setup and personal use — especially behind an IP allowlist. For long-term operation, consider centralized authentication (e.g. OIDC via an identity provider), RBAC-scoped kubeconfigs, and short-lived tokens to reduce the blast radius of leaked credentials.