Volver al blog
ArgoCD con las aplicaciones del clúster sincronizadas y sanas: Cilium, CloudNativePG, External Secrets, runners de CI, k8up y el stack de monitorización

Montar un homelab desde cero: Kubernetes y GitOps - Homelab (03/06)

Caso realHomelabKubernetesGitOps

En el Post 2 montamos la red: WireGuard entre sitios, Tailscale para la nube, Cloudflare y Caddy en el borde. Ahora falta algo que corra encima.

Este post va sobre la capa de despliegue — cómo las aplicaciones pasan de un commit en Git a un contenedor corriendo, tanto en clústeres Kubernetes como en hosts Docker convencionales. Veremos Talos Linux, ArgoCD, sync waves, ApplicationSets y un agente de CD ligero llamado ptolomeo que gestiona stacks de Docker Compose.

La idea de fondo: todo pasa por Git. Push a main y está en producción. Sin SSH, sin kubectl apply a mano, sin "ejecuta esto en el servidor". Todo declarado en YAML, versionado y auditable. Eso es GitOps.

¿Por qué Kubernetes?

Si solo has trabajado con Docker Compose, Kubernetes (abreviado K8s) puede parecer excesivo para un homelab. Y para stacks sencillos, lo es. Pero cuando necesitas varios entornos (staging y producción) desde el mismo código, rollbacks automáticos si un despliegue falla, secretos declarativos sincronizados desde un vault externo, u operadores que gestionen bases de datos, backups y certificados por ti... entonces Kubernetes empieza a compensar. La curva de aprendizaje es real, pero una vez que encaja, cuesta imaginar cómo te las arreglabas sin él.

Tengo dos clústeres: pizarro (la plataforma — Infisical, Harbor y observabilidad) y cortes (zetesis.xyz). Este post se centra sobre todo en cortes, que tiene los patrones de despliegue más interesantes.

Talos Linux: el sistema operativo que se quita de en medio

Ambos clústeres corren Talos Linux — un sistema operativo mínimo e inmutable diseñado específicamente para Kubernetes. No tiene SSH, ni shell, ni gestor de paquetes. Se administra íntegramente a través de una API.

¿Por qué? Porque un nodo de Kubernetes debería ser ganado, no una mascota. Si se rompe, lo reconstruyes desde un fichero de configuración. Talos convierte eso en la única opción posible.

Todo el clúster queda definido en un único fichero, talconfig.yaml:

clusterName: cortes
talosVersion: v1.12.4
kubernetesVersion: v1.35.0
endpoint: https://10.0.0.151:6443
allowSchedulingOnControlPlanes: true

cniConfig:
  name: none  # Cilium se instala aparte

nodes:
  - hostname: cortes
    ipAddress: 10.0.0.151
    controlPlane: true
    installDisk: /dev/sda

Algunos detalles. allowSchedulingOnControlPlanes: true permite que el nodo del plano de control también ejecute cargas de trabajo — algo habitual en clústeres de un solo nodo como los de un homelab. cniConfig: none omite el plugin de red integrado e instala Cilium aparte, un CNI (Container Network Interface) basado en eBPF que proporciona red, observabilidad y seguridad. Cilium es necesario para la compatibilidad con Tailscale, con el ajuste socketLB.hostNamespaceOnly=true. Y todo es declarativo — para cambiar el clúster, editas este fichero, ejecutas talhelper genconfig para generar las configuraciones de máquina, y las aplicas con talosctl apply-config. Sin comandos imperativos.

ArgoCD: el motor GitOps

ArgoCD vigila un repositorio Git y sincroniza continuamente el estado del clúster Kubernetes con lo declarado en el repo. Si alguien cambia algo a mano en el clúster, ArgoCD lo revierte. Si haces push de un cambio a Git, ArgoCD lo aplica.

App-of-Apps: el patrón de arranque

El reto: cuando montas un clúster por primera vez, ¿cómo instalas la propia configuración de ArgoCD, más todos los operadores, secretos y aplicaciones? Podrías aplicarlos uno a uno, pero eso anula el propósito de GitOps.

La solución es el patrón App-of-Apps. Aplicas manualmente una única Application "bootstrap" que apunta a ArgoCD a un directorio de manifiestos de Application. ArgoCD descubre y sincroniza recursivamente todo lo que hay dentro:

# bootstrap.yaml — el único manifiesto que se aplica a mano
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: zetesis-portal-bootstrap
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/Zetesis-Labs/Mileto-Infra-GitOps.git
    targetRevision: main
    path: px-platon/cortes/apps
    directory:
      recurse: true
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

El directorio px-platon/cortes/apps contiene:

apps/
  project.yaml                    # AppProject de ArgoCD (permisos)
  operators/
    external-secrets-operator.yaml  # ESO (sync-wave -1)
    cloudnativepg-operator.yaml     # CNPG (sync-wave -1)
    tailscale-operator.yaml         # Tailscale (sync-wave -1)
    traefik-config.yaml             # Traefik ingress (sync-wave -1)
    k8up-operator.yaml              # Operador de backup (sync-wave -1)
    local-path-provisioner.yaml     # Almacenamiento (sync-wave -1)
  zetesis-portal/
    applicationset.yaml             # Staging + Prod (sync-wave 0, 1)
    pr-applicationset.yaml          # Entornos de preview por PR

Un kubectl apply -f bootstrap.yaml y ArgoCD toma el control. A partir de ahí, todo el estado del clúster se gestiona desde Git.

Sync waves: ordenar los despliegues

No todo puede desplegarse a la vez. Los operadores tienen que estar instalados antes de que se puedan usar los CRDs (Custom Resource Definitions) que proporcionan. Los secretos tienen que estar sincronizados antes de que arranquen las aplicaciones que los referencian.

ArgoCD resuelve esto con sync waves — anotaciones numéricas que controlan el orden:

Loading diagram...

La wave -1 despliega los operadores. Cuando están sanos, la wave 0 despliega los secretos (vía ESO) y la infraestructura (bases de datos, motores de búsqueda). Por último, la wave 1 despliega las aplicaciones que dependen de todo lo anterior.

Así queda en un operador:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: cloudnativepg-operator
  annotations:
    argocd.argoproj.io/sync-wave: "-1"
spec:
  source:
    repoURL: https://cloudnative-pg.github.io/charts
    chart: cloudnative-pg
    targetRevision: 0.*

Y en la aplicación:

metadata:
  name: "zetesis-portal-prod"
  annotations:
    argocd.argoproj.io/sync-wave: "1"

ArgoCD espera a que cada wave esté sana antes de pasar a la siguiente. Si un operador falla, las aplicaciones que dependen de él ni siquiera intentan desplegarse.

ApplicationSets: una configuración, varios entornos

Donde ArgoCD muestra su verdadera potencia es con los ApplicationSets — plantillas que generan varias Applications a partir de una fuente de datos. Para zetesis.xyz uso el Git Files Generator: ArgoCD escanea un directorio en busca de ficheros JSON y crea una Application por cada uno.

Los ficheros de entorno:

// envs/prod/env.json
{
  "env": "prod",
  "namespace": "zetesis-portal-prod",
  "helmValuesFile": "values-prod.yaml",
  "chartVersion": "0.1.4",
  "webImageTag": "v0.1.6",
  "mcpImageTag": "v0.1.0",
  "keycloakImageTag": "v0.1.0"
}
// envs/staging/env.json
{
  "env": "staging",
  "namespace": "zetesis-portal-staging",
  "helmValuesFile": "values-staging.yaml",
  "chartVersion": "0.1.4",
  "webImageTag": "latest",
  "mcpImageTag": "latest",
  "keycloakImageTag": "latest"
}

La plantilla del ApplicationSet usa Go templating para generar Apps a partir de estos ficheros:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: zetesis-portal-app
spec:
  generators:
    - git:
        repoURL: https://github.com/Zetesis-Labs/Mileto-Infra-GitOps.git
        revision: main
        files:
          - path: px-platon/cortes/envs/*/env.json
  template:
    metadata:
      name: "{{ .argoProject }}-{{ .env }}"
    spec:
      sources:
        - repoURL: oci://gauss.zetesis.localhost/zetesis-portal/zetesis-portal
          chart: zetesis-portal
          targetRevision: "{{ .chartVersion }}"
          helm:
            valueFiles:
              - $values/px-platon/cortes/helm/values.yaml
              - $values/px-platon/cortes/helm/{{ .helmValuesFile }}
            parameters:
              - name: web.image.tag
                value: "{{ .webImageTag }}"
        - repoURL: "{{ .repoURL }}"
          targetRevision: main
          ref: values
      destination:
        namespace: "{{ .namespace }}"

Para desplegar una nueva versión de la web en producción, cambias una línea en envs/prod/env.json:

"webImageTag": "v0.1.7"

Push a main y ArgoCD recoge el cambio en segundos.

Para desplegar primero en staging y probar, actualizas envs/staging/env.json con "latest" (que sigue la última build de la imagen). Staging y producción son completamente independientes — namespaces distintos, tags de imagen distintos, valores Helm distintos.

Tres ApplicationSets por entorno

Cada entorno genera en realidad tres Applications de ArgoCD mediante tres ApplicationSets separados, alineados con las sync waves:

  1. Secretos (wave 0) — ExternalSecrets que sincronizan credenciales desde Infisical
  2. Infraestructura (wave 0) — Namespace, clúster PostgreSQL (vía CNPG), Typesense, exposición Tailscale
  3. Aplicación (wave 1) — El chart Helm con los contenedores web, MCP y agent-runtime

Esto significa que un entorno nuevo recibe su propia base de datos, sus propios secretos y su propio despliegue de aplicación — todo generado a partir de un único fichero JSON.

Entornos de preview por PR

Esta es de las funcionalidades que más me gustan. Cuando un desarrollador abre un Pull Request en el repo de la aplicación con la etiqueta preview, ArgoCD crea automáticamente un entorno de preview completo:

generators:
  - pullRequest:
      github:
        owner: Zetesis-Labs
        repo: ZetesisPortal
        labels:
          - preview

El Pull Request Generator de ArgoCD consulta GitHub cada 60 segundos. Cuando encuentra un PR con la etiqueta preview, crea un namespace dedicado (zetesis-portal-pr-42), su propia base de datos PostgreSQL (vía CNPG), su propia instancia de Typesense, sus propios secretos (desde Infisical), y la aplicación desplegada con tags de imagen y dominio específicos del PR.

El preview queda accesible en pr-42.staging.zetesis.xyz. La configuración de Caddy en von-braun ya tiene un wildcard para *.staging.zetesis.xyz, así que funciona sin tocar nada más.

Cuando el PR se mergea o se cierra, ArgoCD borra todo automáticamente. Sin scripts de limpieza, sin bases de datos huérfanas.

Kustomize: base, componentes y overlays

Los manifiestos de infraestructura (bases de datos, motores de búsqueda, exposición Tailscale) usan Kustomize — una herramienta integrada en kubectl que permite personalizar YAML sin templating. La estructura sigue un patrón de base/componentes/overlays:

manifests/infrastructure/
  base/
    kustomization.yaml        # Solo el namespace
    namespace.yaml
  components/
    postgres/                  # Definición del Cluster CNPG
    typesense/                 # StatefulSet de Typesense
    tailscale-expose/          # Services + Ingresses de Tailscale
  overlays/
    prod/kustomization.yaml    # Incluye todos los componentes + patches de prod
    staging/kustomization.yaml # Incluye todos los componentes + patches de staging

Los overlays importan la base y añaden componentes. Las diferencias entre entornos se gestionan con patches:

# overlays/prod/kustomization.yaml
resources:
  - ../../base
components:
  - ../../components/postgres
  - ../../components/typesense
  - ../../components/tailscale-expose
patches:
  # Hostname de Tailscale para producción
  - target:
      kind: Service
      name: postgres-tailscale
    patch: |
      - op: replace
        path: /metadata/annotations/tailscale.com~1hostname
        value: zetesis-prod-postgres

Staging usa los mismos componentes pero con hostnames distintos (zetesis-staging-postgres). Los componentes base usan valores de relleno como ENVIRONMENT que se parchean en cada overlay.

Helm: empaquetado de la aplicación

La aplicación en sí (zetesis.xyz) está empaquetada como chart de Helm — almacenado en Harbor (nuestro registro de imágenes autoalojado en pizarro) como artefacto OCI. El chart define los recursos de Kubernetes para la web, el servidor MCP, el runtime de agentes y el gateway LiteLLM.

Los values de Helm se dividen en capas:

# values.yaml (base) — valores por defecto compartidos entre entornos
web:
  replicaCount: 1
  image:
    repository: gauss.zetesis.localhost/zetesis-portal/web
    tag: "latest"
  resources:
    requests:
      cpu: 200m
      memory: 512Mi
    limits:
      memory: 2Gi
# values-prod.yaml — sobrecargas de producción
web:
  domain: zetesis.xyz
  authDomain: auth.zetesis.xyz
  existingSecret: web-secrets

ingress:
  enabled: true
  className: traefik

backup:
  enabled: true
  s3:
    bucket: zetesis-prod-restic
    endpoint: http://10.1.0.11:9000

El ApplicationSet fusiona estos ficheros: primero values.yaml, encima values-prod.yaml, y por último los parámetros por despliegue (los tags de imagen de env.json). Con este enfoque por capas solo sobrescribes lo que cambia.

ptolomeo: CD para Docker Compose

No todo corre en Kubernetes. Los VPS y las VMs locales usan Docker Compose, y necesitan su propio sistema de CD.

ptolomeo es un agente GitOps ligero basado en doco-cd. Corre como contenedor Docker en cada host, consulta el repositorio Git cada 180 segundos y ejecuta docker compose up cuando detecta cambios.

Cada host tiene un .doco-cd.yaml que define qué desplegar:

# YAML multi-documento — una sección por servicio
name: colon
reference: refs/heads/main
working_dir: vps-von-braun/colon
external_secrets:
  CF_API_TOKEN: 893d53fb-...:prod:/colon/CF_API_TOKEN
  CROWDSEC_API_KEY: 893d53fb-...:prod:/colon/CROWDSEC_API_KEY
---
name: marco-polo
reference: refs/heads/main
working_dir: vps-von-braun/marco-polo
external_secrets:
  TUNNEL_TOKEN: 893d53fb-...:prod:/marco-polo/TUNNEL_TOKEN
---
name: tolstoi
reference: refs/heads/main
working_dir: vps-von-braun/tolstoi
external_secrets:
  S3_ACCESS_KEY_ID: 893d53fb-...:prod:/s3-backup/AWS_ACCESS_KEY_ID

Cada sección corresponde a un stack de Docker Compose. ptolomeo hace lo siguiente: tira del último commit de la rama main, comprueba si los ficheros bajo working_dir han cambiado, obtiene los secretos de Infisical (a través del mapeo external_secrets) y los escribe en .env, y ejecuta docker compose up -d en el directorio de trabajo.

Es sencillo, y esa sencillez es el objetivo. Sin sobrecarga de Kubernetes para servicios que no lo necesitan. Un reverse proxy Caddy, un agente de túnel Cloudflare y un contenedor de backup Restic no necesitan scheduling de pods ni health checks — solo necesitan correr.

Dos sistemas de CD, un repo

Así conviven los dos sistemas:

Loading diagram...

La estructura de directorios es el contrato. Las rutas que empiezan por px-*/cortes/ o tl-pizarro/ las gestiona ArgoCD. Las que empiezan por vps-*/ o px-*/vm-*/ las gestiona ptolomeo. Un push a Git puede modificar ficheros de ambas zonas a la vez — ArgoCD y ptolomeo reaccionan cada uno a los cambios que les corresponden.

Esto permite actualizar un manifiesto de Kubernetes y un fichero Docker Compose en el mismo commit, y ambos sistemas de CD desplegarán sus cambios respectivos de forma independiente.

El flujo de despliegue

Veamos qué pasa cuando publico una nueva versión de zetesis.xyz:

  1. CI construye — GitHub Actions construye las imágenes de contenedor, les pone tag (p.ej. v0.1.7) y las sube a Harbor
  2. Actualizo env.json — Cambio "webImageTag": "v0.1.7" en envs/prod/env.json y hago push a main
  3. ArgoCD detecta — El Git Files Generator ve el cambio en segundos
  4. El ApplicationSet se regenera — La Application zetesis-portal-prod recibe el nuevo tag de imagen
  5. Sync — ArgoCD aplica el cambio. Kubernetes hace un rolling update de los pods web
  6. Health check — ArgoCD espera a que los nuevos pods estén listos antes de marcar el sync como sano

Si los nuevos pods crashean, el rolling update se detiene y los pods viejos siguen corriendo. Puedo ver el fallo en el dashboard de ArgoCD y corregir hacia adelante o revertir el commit.

Para servicios Docker Compose, el flujo es aún más simple: editas un docker-compose.yaml o un Caddyfile bajo vps-von-braun/, haces commit a main, y en menos de 180 segundos ptolomeo detecta los ficheros modificados y ejecuta docker compose up -d.

CI dentro del propio clúster: herschel-runners

Queda una pieza por cerrar: ¿dónde corre la CI que construye esas imágenes? También en casa. Los runners de GitHub Actions viven dentro del propio clúster cortes, gestionados por ARC (Actions Runner Controller): un scale set llamado herschel-runners levanta entre 0 y 3 runners con Docker-in-Docker privilegiado para toda la organización — escala a cero cuando no hay trabajo.

La imagen del runner es propia (mileto-runner, construida desde el propio repositorio de infraestructura) y tiene una curiosidad que me hace gracia: la imagen se construye a sí misma — su pipeline corre sobre runners que ejecutan la versión anterior—. Si el bucle se rompe algún día, la salida de emergencia es construirla en local y subirla a mano.

Y la CI no solo construye: valida. Cada pull request al repositorio de infraestructura pasa por kustomize build de todos los overlays, kubeconform en modo estricto, actionlint y las políticas propias de ArgoCD (prohibido usar wildcards en targetRevision). Más GitGuardian buscando secretos filtrados por si acaso.

Con esto el círculo queda cerrado: el código se construye, se valida y se despliega sin salir de la infraestructura que él mismo mantiene.

Resumen

Talos Linux elimina el mantenimiento del sistema operativo. Sin SSH, sin parches que aplicar, sin drift. La configuración del clúster es un fichero YAML en Git. ArgoCD con App-of-Apps permite arrancar un clúster entero con un solo kubectl apply; a partir de ahí, todo va por Git. Los ApplicationSets con el Git Files Generator dan despliegues multi-entorno desde una plantilla; añadir un entorno es añadir un fichero JSON. Las sync waves resuelven las dependencias: operadores antes que secretos, secretos antes que aplicaciones. Los entornos de preview por PR se gestionan solos con el Pull Request Generator — sin limpieza manual. ptolomeo cubre el mundo Docker Compose con la misma filosofía GitOps — polling, no push. Y un repo, dos sistemas de CD — la estructura de directorios es la interfaz. ArgoCD y ptolomeo no saben que el otro existe, y no lo necesitan.

En el último post veremos la capa de seguridad y operaciones: cómo fluyen los secretos desde Infisical hasta los contenedores (en ambos sistemas), cómo funcionan los backups con Restic y K8up, y un vistazo rápido a la domótica.


Siguiente: Post 4 - Seguridad y operaciones | Anterior: Post 2 - Redes