Skip to content

Secrets model

Bootstrap secrets are what the platform needs before OpenBao exists; they live age-encrypted in git. Every other secret lives in OpenBao and reaches pods through the Secrets Store CSI driver. Why two tiers: Two-tier secrets.

flowchart LR
    subgraph BOOT["Bootstrap tier"]
        SOPS["secrets/bootstrap.sops.yaml<br/>age-encrypted, in git"]
        CS["just create-secrets"]
        PU["just core STACK up"]
        KS["k8s Secrets<br/>Prune=false"]
        ENV["Bifrost env files"]
    end
    subgraph RUN["Runtime tier"]
        OB["OpenBao<br/>KV v2 at secret/"]
        CSI["Secrets Store CSI<br/>+ OpenBao provider"]
        F["File at /mnt/secrets/KEY"]
        S["Synced k8s Secret"]
    end
    SOPS --> CS --> KS
    SOPS --> PU --> ENV
    KS -->|unseal key| OB
    OB -->|Kubernetes auth<br/>per-app role| CSI
    CSI --> F
    CSI -->|secretObjects,<br/>while mounted| S

secrets/bootstrap.sops.yaml holds the provider tokens, the Authentik and NetBird keys for Bifrost, and the OpenBao unseal key. Recipes decrypt it per command with sops exec-env; nothing decrypted is written to the repo. Pulumi passes the Bifrost keys into one env file per container (see Bifrost). create-bootstrap-secrets.sh, run by just create-secrets, creates the only two in-cluster bootstrap Secrets:

SecretNamespaceUsed by
openbao-unseal-keyopenbaoThe OpenBao unseal sidecar
cloudflare-api-tokencert-managerDNS-01 challenges for the wildcard certificate

Both carry argocd.argoproj.io/sync-options: Prune=false, so Argo CD never deletes Secrets it did not create.

Each app has a SecretProviderClass, built by kit.SecretProviderClass in kit.go. It reads keys from secret/data/<app> using the OpenBao Kubernetes auth role of the same name, bound to the app’s ServiceAccount. just openbao-setup creates the auth method, policies and roles once; see OpenBao and Secrets Store CSI Driver.

PatternWhenApps
File onlyThe app can read a value from a file; no Secret object existsGrafana admin password ($__file{} in an env var)
Synced SecretThe chart wants an existingSecret or the app reads only env varsGrafana OIDC client secret, Harbor, n8n, NetBird peer, Falco (Sidekick UI), CS2

Prefer file-only: the value never appears in kubectl get secret.

OpenBao seals itself on every restart. An unseal container in extraContainers (openbao.go) reads openbao-unseal-key and loops bao operator unseal against 127.0.0.1:8200. It is a sidecar, not an init container, because unseal requests need the server already running. The very first initialisation is manual: just openbao-init.

RuleEnforced by
Workload code declares no Secret objectsConvention in workloads/; secrets come from OpenBao
No new Secret payload in published manifestsCI manifest-secret-guard.sh; its allowlist of chart-rendered Secrets may only shrink
No root token at restjust openbao-token generates one on demand from the unseal key; just openbao-revoke revokes it
CI needs no repository secrets beyond its own GITHUB_TOKENSynthesis reads nothing secret