Back to 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

Building a homelab from scratch: Kubernetes and GitOps - Homelab (03/06)

Case studyHomelabKubernetesGitOps

In Post 2 we set up the network: WireGuard between sites, Tailscale for the cloud, Cloudflare and Caddy at the edge. Now we need something to run on top.

This post is about the deployment layer — how applications go from a Git commit to a running container, both on Kubernetes clusters and on conventional Docker hosts. We'll look at Talos Linux, ArgoCD, sync waves, ApplicationSets, and a lightweight CD agent called ptolomeo that manages Docker Compose stacks.

The underlying idea: everything goes through Git. Push to main and it's in production. No SSH, no kubectl apply by hand, no "run this on the server". Everything declared in YAML, versioned and auditable. That's GitOps.

Why Kubernetes?

If you've only worked with Docker Compose, Kubernetes (K8s for short) can seem like overkill for a homelab. And for simple stacks, it is. But when you need multiple environments (staging and production) from the same codebase, automatic rollbacks if a deployment fails, declarative secrets synced from an external vault, or operators that manage databases, backups, and certificates for you... then Kubernetes starts to pay off. The learning curve is real, but once it clicks, it's hard to imagine how you managed without it.

I have two clusters: escipion (the platform — Infisical, Harbor, and observability) and alejandro (zetesis.xyz). This post focuses mainly on alejandro, which has the most interesting deployment patterns.

Talos Linux: the operating system that gets out of the way

Both clusters run Talos Linux — a minimal, immutable operating system designed specifically for Kubernetes. It has no SSH, no shell, no package manager. It is managed entirely through an API.

Why? Because a Kubernetes node should be cattle, not a pet. If it breaks, you rebuild it from a configuration file. Talos makes that the only possible option.

The entire cluster is defined in a single file, talconfig.yaml:

clusterName: alejandro
talosVersion: v1.12.4
kubernetesVersion: v1.35.0
endpoint: https://10.0.100.10:6443
allowSchedulingOnControlPlanes: true

cniConfig:
  name: none  # Cilium se instala aparte

nodes:
  - hostname: alejandro
    ipAddress: 10.0.100.10
    controlPlane: true
    installDisk: /dev/sda

A few details. allowSchedulingOnControlPlanes: true lets the control plane node also run workloads — common in single-node clusters like homelab ones. cniConfig: none skips the built-in network plugin and installs Cilium separately, an eBPF-based CNI (Container Network Interface) that provides networking, observability, and security. Cilium is required for Tailscale compatibility, with the setting socketLB.hostNamespaceOnly=true. And everything is declarative — to change the cluster, you edit this file, run talhelper genconfig to generate the machine configurations, and apply them with talosctl apply-config. No imperative commands.

ArgoCD: the GitOps engine

ArgoCD watches a Git repository and continuously syncs the state of the Kubernetes cluster with what is declared in the repo. If someone changes something by hand in the cluster, ArgoCD reverts it. If you push a change to Git, ArgoCD applies it.

App-of-Apps: the bootstrap pattern

The challenge: when you set up a cluster for the first time, how do you install ArgoCD's own configuration, plus all the operators, secrets, and applications? You could apply them one by one, but that defeats the purpose of GitOps.

The solution is the App-of-Apps pattern. You manually apply a single "bootstrap" Application that points ArgoCD at a directory of Application manifests. ArgoCD recursively discovers and syncs everything inside:

# 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: alejandro-bootstrap
  source:
    repoURL: https://github.com/Zetesis-Labs/Mileto-Infra-GitOps.git
    targetRevision: main
    path: px-platon/alejandro/apps
    directory:
      recurse: true
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

The directory px-platon/alejandro/apps contains:

apps/
project.yamlArgoCD AppProject (permissions)
operators/
external-secrets-operator.yamlESO (sync-wave -1)
cloudnativepg-operator.yamlCNPG (sync-wave -1)
tailscale-operator.yamlTailscale (sync-wave -1)
traefik-config.yamlTraefik ingress (sync-wave -1)
k8up-operator.yamlBackup operator (sync-wave -1)
local-path-provisioner.yamlStorage (sync-wave -1)
zetesis-portal/
applicationset.yamlStaging + Prod (sync-wave 0, 1)
pr-applicationset.yamlPreview environments per PR

A single kubectl apply -f bootstrap.yaml and ArgoCD takes over. From there on, the entire cluster state is managed from Git.

Sync waves: ordering deployments

Not everything can be deployed at once. Operators must be installed before the CRDs (Custom Resource Definitions) they provide can be used. Secrets must be synced before the applications that reference them start up.

ArgoCD solves this with sync waves — numeric annotations that control the order:

Loading diagram...

Wave -1 deploys the operators. Once they are healthy, wave 0 deploys the secrets (via ESO) and the infrastructure (databases, search engines). Finally, wave 1 deploys the applications that depend on all of the above.

This is what it looks like on an operator:

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.28.3   # exacta a propósito: un comodín actualiza solo el operador de la BD

And on the application:

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

ArgoCD waits for each wave to be healthy before moving on to the next. If an operator fails, the applications that depend on it don't even attempt to deploy.

ApplicationSets: one configuration, multiple environments

Where ArgoCD really shows its power is with ApplicationSets — templates that generate multiple Applications from a data source. For zetesis.xyz I use the Git Files Generator: ArgoCD scans a directory for JSON files and creates one Application per file.

The environment files:

// envs/prod/env.json
{
  "env": "prod",
  "namespace": "zetesis-portal-prod",
  "helmValuesFile": "values-prod.yaml",
  "chartVersion": "0.4.43",
  "webImageTag": "v0.8.25",
  "mcpImageTag": "v0.4.11",
  "agentRuntimeImageTag": "v0.4.28",
  "documentsWorkerImageTag": "v0.1.8"
}
// envs/staging/env.json
{
  "env": "staging",
  "namespace": "zetesis-portal-staging",
  "helmValuesFile": "values-staging.yaml",
  "chartVersion": "0.1.4",
  "webImageTag": "latest",
  "mcpImageTag": "latest"
}

The ApplicationSet template uses Go templating to generate Apps from these files:

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/alejandro/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/alejandro/helm/values.yaml
              - $values/px-platon/alejandro/helm/{{ .helmValuesFile }}
            parameters:
              - name: web.image.tag
                value: "{{ .webImageTag }}"
        - repoURL: "{{ .repoURL }}"
          targetRevision: main
          ref: values
      destination:
        namespace: "{{ .namespace }}"

To deploy a new version of the website to production, you change one line in envs/prod/env.json:

"webImageTag": "v0.1.7"

Push to main and ArgoCD picks up the change within seconds.

To deploy to staging first and test, you update envs/staging/env.json with "latest" (which tracks the latest image build). Staging and production are completely independent — different namespaces, different image tags, different Helm values.

Three ApplicationSets per environment

Each environment actually generates three ArgoCD Applications via three separate ApplicationSets, aligned with the sync waves:

  1. Secrets (wave 0) — ExternalSecrets that sync credentials from Infisical
  2. Infrastructure (wave 0) — Namespace, PostgreSQL cluster (via CNPG), Typesense, Tailscale exposure
  3. Application (wave 1) — The Helm chart with the web, MCP, and agent-runtime containers

This means a new environment gets its own database, its own secrets, and its own application deployment — all generated from a single JSON file.

Per-PR preview environments

This is one of my favorite features. When a developer opens a Pull Request in the application repo with the preview label, ArgoCD automatically creates a full preview environment:

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

ArgoCD's Pull Request Generator polls GitHub every 60 seconds. When it finds a PR with the preview label, it creates a dedicated namespace (zetesis-portal-pr-42), its own PostgreSQL database (via CNPG), its own Typesense instance, its own secrets (from Infisical), and the application deployed with PR-specific image tags and domain.

The preview is accessible at pr-42.staging.zetesis.xyz. The Caddy configuration on von-braun already has a wildcard for *.staging.zetesis.xyz, so it works without touching anything else.

When the PR is merged or closed, ArgoCD automatically deletes everything. No cleanup scripts, no orphaned databases.

Kustomize: base, components, and overlays

The infrastructure manifests (databases, search engines, Tailscale exposure) use Kustomize — a tool built into kubectl that lets you customize YAML without templating. The structure follows a base/components/overlays pattern:

manifests/infrastructure/
base/
kustomization.yamlNamespace only
namespace.yaml
components/
postgres/CNPG Cluster definition
typesense/Typesense StatefulSet
tailscale-expose/Tailscale Services + Ingresses
overlays/
prod/kustomization.yamlIncludes all components + prod patches
staging/kustomization.yamlIncludes all components + staging patches

The overlays import the base and add components. Differences between environments are managed with patches:

# overlays/prod/kustomization.yaml
resources:
  - ../../base
# Los backends de datos ya no viven aquí: prod corre sobre el CNPG y el
# Typesense compartidos del namespace de datos. Aquí queda el namespace,
# la exposición por Tailscale y sus parches.
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 uses the same components but with different hostnames (zetesis-staging-postgres). The base components use placeholder values like ENVIRONMENT that get patched in each overlay.

Helm: packaging the application

The application itself (zetesis.xyz) is packaged as a Helm chart — stored in Harbor (our self-hosted image registry on escipion) as an OCI artifact. The chart defines the Kubernetes resources for the web, the MCP server, the agent runtime, and the LiteLLM gateway.

The Helm values are split into layers:

# 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: false   # prod respalda desde el Schedule de K8up del namespace de datos
  s3:
    bucket: zetesis-prod-restic
    endpoint: http://10.1.0.11:9000

The ApplicationSet merges these files: first values.yaml, then values-prod.yaml, and finally the per-deployment parameters (the image tags from env.json). With this layered approach you only override what changes.

ptolomeo: CD for Docker Compose

Not everything runs on Kubernetes. The VPSes and local VMs use Docker Compose, and they need their own CD system.

ptolomeo is a lightweight GitOps agent based on doco-cd. It runs as a Docker container on each host, polls the Git repository every 180 seconds, and runs docker compose up when it detects changes.

Each host has a .doco-cd.yaml that defines what to deploy:

# YAML multi-documento — una sección por servicio
name: trajano
reference: refs/heads/main
working_dir: vps-von-braun/trajano
external_secrets:
  CF_API_TOKEN: <project-id>:prod:/trajano/CF_API_TOKEN
  CROWDSEC_API_KEY: <project-id>:prod:/trajano/CROWDSEC_API_KEY
---
name: marco-polo
reference: refs/heads/main
working_dir: vps-von-braun/marco-polo
external_secrets:
  TUNNEL_TOKEN: <project-id>:prod:/marco-polo/TUNNEL_TOKEN
---
name: tolstoi
reference: refs/heads/main
working_dir: vps-von-braun/tolstoi
external_secrets:
  S3_ACCESS_KEY_ID: <project-id>:prod:/s3-backup/AWS_ACCESS_KEY_ID

Each section corresponds to a Docker Compose stack. ptolomeo does the following: it pulls the latest commit from the main branch, checks whether the files under working_dir have changed, fetches the secrets from Infisical (through the external_secrets mapping), writes them to .env, and runs docker compose up -d in the working directory.

It's simple, and that simplicity is the goal. No Kubernetes overhead for services that don't need it. A Caddy reverse proxy, a Cloudflare tunnel agent, and a Restic backup container don't need pod scheduling or health checks — they just need to run.

Two CD systems, one repo

This is how the two systems coexist:

Loading diagram...

The directory structure is the contract. Paths starting with px-*/alejandro/ or tl-escipion/ are managed by ArgoCD. Those starting with vps-*/ or px-*/vm-*/ are managed by ptolomeo. A single push to Git can modify files in both zones at once — ArgoCD and ptolomeo each react to the changes that concern them.

This allows updating a Kubernetes manifest and a Docker Compose file in the same commit, and both CD systems will deploy their respective changes independently.

The deployment flow

Let's see what happens when I publish a new version of zetesis.xyz:

  1. CI builds — GitHub Actions builds the container images, tags them (e.g. v0.1.7), and pushes them to Harbor
  2. I update env.json — I change "webImageTag": "v0.1.7" in envs/prod/env.json and push to main
  3. ArgoCD detects — The Git Files Generator sees the change within seconds
  4. The ApplicationSet regenerates — The Application zetesis-portal-prod receives the new image tag
  5. Sync — ArgoCD applies the change. Kubernetes performs a rolling update of the web pods
  6. Health check — ArgoCD waits for the new pods to be ready before marking the sync as healthy

If the new pods crash, the rolling update stops and the old pods keep running. I can see the failure in the ArgoCD dashboard and either fix forward or revert the commit.

For Docker Compose services, the flow is even simpler: you edit a docker-compose.yaml or a Caddyfile under vps-von-braun/, commit to main, and within 180 seconds ptolomeo detects the modified files and runs docker compose up -d.

CI inside the cluster itself: herschel-runners

One piece remains: where does the CI that builds those images run? Also at home. The GitHub Actions runners live inside the alejandro cluster itself, managed by ARC (Actions Runner Controller): a scale set called herschel-runners spins up between 0 and 3 runners with privileged Docker-in-Docker for the whole organization — it scales to zero when there's no work.

The runner image is our own (mileto-runner, built from the infrastructure repository itself) and has a quirk I find amusing: the image builds itself — its pipeline runs on runners executing the previous version. If the loop ever breaks, the emergency exit is to build it locally and push it by hand.

And CI doesn't just build: it validates. Every pull request to the infrastructure repository goes through kustomize build for all overlays, kubeconform in strict mode, actionlint and our own ArgoCD policies (wildcards forbidden in targetRevision). Plus GitGuardian scanning for leaked secrets, just in case.

With this, the circle is closed: the code is built, validated, and deployed without leaving the infrastructure it maintains itself.

Summary

Talos Linux eliminates operating system maintenance. No SSH, no patches to apply, no drift. The cluster configuration is a YAML file in Git. ArgoCD with App-of-Apps lets you bootstrap an entire cluster with a single kubectl apply; from there on, everything goes through Git. ApplicationSets with the Git Files Generator provide multi-environment deployments from a single template; adding an environment is adding a JSON file. Sync waves resolve the dependencies: operators before secrets, secrets before applications. Per-PR preview environments manage themselves with the Pull Request Generator — no manual cleanup. ptolomeo covers the Docker Compose world with the same GitOps philosophy — polling, not push. And one repo, two CD systems — the directory structure is the interface. ArgoCD and ptolomeo don't know the other exists, and they don't need to.

In the next post we'll look at the security and operations layer: how secrets flow from Infisical to the containers (in both systems), how backups work with Restic and K8up, and a quick look at home automation.


Next: Post 4 - Security and operations | Previous: Post 2 - Networking

The full series