Volver al blog16 JUN 2026 · 13 min
HomelabProxmoxRKE2GitOpsArgo CD

Mi homelab al detalle: un clúster RKE2 GitOps sobre Proxmox

Este mismo portfolio y su blog se sirven desde un homelab que monté en casa. No es un servidor cualquiera con un Docker Compose: es un clúster Kubernetes de verdad, gestionado al 100% por GitOps. Aquí te cuento cómo está montado y, sobre todo, por qué tomé cada decisión.

La filosofía que lo guía todo: un laboratorio que puedo destruir y reconstruir entero sin miedo. La resiliencia no viene de replicar hardware (es un único host), sino de dos cosas: el estado deseado vive en Git y Argo CD lo reconcilia, y los datos se respaldan a disco redundante.

Arquitectura en tres capas

Todo se apoya en un único host Proxmox VE 9. Por encima, dos capas lógicas: la plataforma (GitLab como fuente de verdad y CI, en su propia VM) y el workload (el clúster RKE2 con todo lo demás). El borde de red vive fuera, en una Raspberry Pi.

Proxmox VE 9  (host único)
├─ Plataforma (VM)
│   └─ GitLab CE  ── fuente de verdad + CI
└─ Workload — clúster RKE2 (3× server, etcd apilado)
    ├─ Argo CD          (patrón app-of-apps)
    ├─ MetalLB + ingress-nginx
    ├─ proxmox-csi (RWO) + NFS (RWX)
    ├─ Sealed Secrets
    └─ Prometheus · Grafana · Loki

Borde: Raspberry Pi → NGINX Proxy Manager (TLS) → clúster

El hardware y la decisión de almacenamiento

El host tiene CPU y RAM de sobra, pero un detalle lo condiciona todo: ningún disco es SSD. Y el componente más sensible a la latencia de disco en Kubernetes es etcd — el cerebro del clúster, que hace fsync en cada escritura. Sobre un HDD, eso es veneno.

La solución fue un tiering deliberado: el sistema operativo y el etcd de los tres nodos van al par de discos SAS en RAID1 (lo más rápido y, además, redundante). Los discos grandes y lentos quedan para datos de aplicaciones y backups. GitLab, con su IO pesado, va aislado en su propio disco para no molestar a etcd. La mejora futura de mayor impacto sería un SSD dedicado solo a etcd.

El clúster: RKE2 endurecido

El workload corre sobre RKE2, la distribución de Kubernetes de Rancher orientada a seguridad. Tres nodos en rol server (control-plane + etcd apilado) dan quórum 3/3, así que el clúster sobrevive a la caída de un nodo. Cada nodo nace de una plantilla cloud-init, lo que hace que recrearlos sea cuestión de minutos.

Viene endurecido de serie: perfil CIS, cifrado de secretos en reposo (secrets-encryption) y protect-kernel-defaults. Eso implica que los namespaces nuevos aplican PodSecurity restricted por defecto — cada app tiene que declarar su securityContext o no arranca.

GitOps: el repo manda

Nadie toca el clúster a mano. El estado deseado vive en un repo Git (homelab, alojado en mi propio GitLab) y Argo CD lo reconcilia. El patrón es app-of-apps: una Application raíz descubre y sincroniza una Application por cada componente.

homelab/
├── bootstrap/root-app.yaml   # Application "root" (recurre apps/)
├── apps/                     # una Application por componente
│   ├── metallb.yaml
│   ├── sealed-secrets.yaml
│   ├── proxmox-csi.yaml
│   ├── kube-prometheus-stack.yaml
│   └── loki.yaml
└── manifests/                # recursos propios (pools, storageclasses…)

El día a día es un git push. Argo sondea cada pocos minutos y, si detecta deriva entre Git y lo que corre, la corrige (selfHeal). El efecto secundario bonito: un git revert es, literalmente, un rollback de infraestructura. La regla de oro es no pelearse con Argo — nada de kubectl rollout restart en apps gestionadas, porque selfHeal lo revierte.

Secretos: cifrados en Git con Sealed Secrets

Si todo vive en Git, ¿dónde van las contraseñas? Cifradas, también en Git. Sealed Secrets tiene un controlador con una clave privada en el clúster; kubeseal cifra con la pública, y el resultado se puede commitear sin riesgo: solo ese clúster puede descifrarlo.

kubectl create secret generic mi-secret -n mi-ns \
  --from-literal=clave=valor --dry-run=client -o yaml \
  | kubeseal --format yaml \
  > manifests/.../sealed-mi-secret.yaml
# commit + push → el controlador lo descifra dentro del clúster

Almacenamiento dinámico, con dos clases

Kubernetes necesita aprovisionar discos solo. Monté dos StorageClass: una RWO vía proxmox-csi sobre el disco grande (para discos de una sola app, como Prometheus o Loki) y una RWX vía NFS sobre el disco redundante (para datos compartidos o importantes, que se retienen aunque borres el PVC).

El borde: DNS y TLS en una Raspberry Pi

La puerta de entrada no está en el clúster, sino en una Raspberry Pi que corre NGINX Proxy Manager: termina el TLS y reenvía cada hostname a su backend (GitLab a su VM, todo lo de k8s al balanceador de MetalLB, que enruta por ingress).

El certificado es un comodín *.ilopez.dev emitido con el reto DNS-01, sin abrir puertos al exterior. Por defecto los servicios resuelven a una IP privada — son solo-LAN — y solo lo que quiero exponer (como este sitio) recibe un registro DNS público y port-forwarding explícito. El cerrojo es una access list en el proxy: aunque alguien llegue de fuera con la cabecera Host correcta, se rechaza si no viene de la LAN.

Observabilidad y backups

El clúster se vigila a sí mismo con kube-prometheus-stack (Prometheus, Grafana y Alertmanager) para métricas, y Loki + promtail para logs. Grafana corre stateless a propósito: sus dashboards son código, así que no necesita disco persistente y evito el clásico deadlock de un PVC RWO en un rolling update.

Para los datos: vzdump nocturno de todas las VMs al disco redundante (con retención) y snapshots automáticos de etcd cada 12 h. La recuperación es coherente con la filosofía GitOps: si pierdo el clúster, recreo las tres VMs desde la plantilla y reaplico la Application raíz — Argo reconstruye absolutamente todo lo demás.

Lecciones aprendidas (que pagué con tiempo)

Algunas cicatrices del montaje: MetalLB se quedaba en OutOfSync perpetuo porque su webhook inyecta un caBundle en caliente — se arregla con un ignoreDifferences. El NFS RWX no montaba hasta instalar nfs-common en los nodos. Los charts OCI exigen versión exacta, no comodín. Y Grafana con un PVC RWO + RollingUpdate entra en deadlock: el pod nuevo no puede montar el disco que aún tiene el viejo.

Lo que queda pendiente

Ningún homelab está terminado. En la lista: un webhook GitLab→Argo para sincronización instantánea (en vez del sondeo), NetworkPolicies default-deny por namespace, automatizar la renovación del certificado, backups off-site y el ansiado SSD para etcd.

La gracia de un homelab no es que nunca se rompa, sino que cuando se rompe puedas reconstruirlo entero desde un repo de Git y un par de backups. Eso es lo que de verdad aprendes montándolo.