Skip to content

GitOps pipeline

Two paths share one repository. Pulumi changes infrastructure and runs only from a laptop; workload changes go through CI and Argo CD. Why the split: GitOps with Pulumi and CDK8s.

flowchart LR
    subgraph L["Laptop"]
        SOPS["SOPS + age<br/>bootstrap.sops.yaml"]
        PU["just core STACK up"]
    end
    SOPS -->|sops exec-env| PU
    PU --> INF["Proxmox VMs · Talos<br/>Cilium · cert-manager · Argo CD<br/>Bifrost · Authentik · DNS"]

    DEV["git push vX.Y.Z<br/>workloads/**"] --> CI["GitHub Actions<br/>go run . → app/<br/>secret guard"]
    CI --> MB["vX.Y.Z-manifests<br/>branch"]
    CFG["config.yml<br/>manifests_branch"] -.->|just core platform up| AS
    MB --> AS["Argo CD ApplicationSet<br/>one app per directory"]
    AS --> K8S["Workloads in the cluster"]

just core <stack> up wraps pulumi up in sops exec-env, so bootstrap secrets exist only as environment variables for that one command. It never runs in CI. Source: main.go, justfile.

StackManages
talosProxmox VMs, Talos machine configs, cluster bootstrap
platformGateway API CRDs, Cilium, the Gateway, cert-manager, Argo CD and its ApplicationSet, the LB IP pool
hetznerBifrost VPS, its env files and bootstrap.sh
authentikAuthentik OIDC apps, GitHub OAuth, ForwardAuth outpost
cloudflareDNS records

CDK8s turns Go in workloads/ into YAML: just synth writes one directory per app under app/ (gitignored). On a push that touches workloads/**, cdk8s-seal-publish.yml:

  1. Runs go run . in workloads/ and builds the Kubeflow kustomization into app/kubeflow/.
  2. Runs manifest-secret-guard.sh, which fails on any rendered Secret payload not on the allowlist.
  3. Force-publishes app/ to the branch <source branch>-manifests.

The Argo CD ApplicationSet cots-applications (argocd.go) uses a git directory generator on that branch: each top-level directory becomes an Application of the same name, deployed to the namespace of the same name.

Sync settingValueWhy
ServerSideApply=truealwaysLarge CRDs exceed the client-side last-applied-configuration annotation limit
CreateNamespace=truealwaysThe directory name is the namespace
automated: prune, selfHealunless argocd_manual_sync: trueManual mode lets a release roll out one app at a time with just argocd-sync <app>

The cluster deploys from a release branch, not main.

StepWhat happens
Cut vX.Y.ZCreating the branch triggers CI even without a workloads/ change, so vX.Y.Z-manifests exists before anything points at it
Set manifests_branch: vX.Y.Z-manifests in config.ymlThe single switch for what the cluster runs
just core platform upRepoints the ApplicationSet; Argo CD syncs the new branch

Pushes to main also publish main-manifests, but nothing syncs it unless manifests_branch names it.

ChangeEditApply
App version or configworkloads/<group>/<app>.gopush to the release branch
Public exposurepublicServices in cloudflare.gojust core cloudflare up, then just core hetzner up
Node count or sizeconfig.ymljust core talos up
Cilium, Gateway, Argo CDcore/platform/*.gojust core platform up
Authentik appsauthentik.gojust core authentik up