Skip to main content

Centralized Secrets Management

Stacktic automates the full secret lifecycle: from generating secrets at build time, to seeding them into a vault, to syncing them into Kubernetes — all driven by component links.


Architecture

┌──────────────────┐     ┌──────────────┐     ┌──────────────────┐
│ Stacktic Build │────▶│ OpenBao │────▶│ External Secrets │
│ (generates │ │ (centralized │ │ Operator (ESO) │
│ secret values) │ │ vault) │ │ (syncs to K8s) │
└──────────────────┘ └──────────────┘ └──────────────────┘


┌──────────────┐
│ K8s Secrets │
│ (consumed by │
│ workloads) │
└──────────────┘

Components

ComponentRoleLink Type
OpenBaoCentralized secret store (open-source Vault fork)
External Secrets OperatorSyncs secrets from vault to K8sexternal_secrets-openbao
Seed JobPopulates OpenBao with all component secretsGenerated when ESO→OpenBao link exists

How It Works

Without ESO (Default)

Secrets are rendered as K8s Secrets via kustomize secretGenerator:

secretGenerator:
- name: rabbitmq-auth-secret
namespace: rabbitmq
envs:
- secret/rabbitmq.env

SOPS encryption applied if sops_enabled is set.

With ESO + OpenBao

When the external_secrets-openbao link exists:

StepWhat Happens
1. OpenBao init jobInitializes vault, creates unseal key + root token, enables KV v2, creates policies
2. Unseal CronJobChecks every 5 minutes, auto-unseals if sealed
3. Seed jobPopulates OpenBao with all component secrets (idempotent)
4. ClusterSecretStoreESO connects to OpenBao using root token
5. ExternalSecret CRsPer-component CRs fetch secrets from OpenBao paths
6. K8s SecretsESO creates K8s Secrets with same names as local mode

Workloads mount the same K8s Secret names regardless of which mode is active.


OpenBao Initialization

The init job runs once and performs:

StepAction
1Wait for OpenBao readiness (polls up to 30 times)
2Initialize with 1 key share / 1 threshold
3Store unseal-key + root-token in K8s Secret {name}-init-keys
4Unseal if sealed
5Enable KV v2 at secret/ path
6Enable userpass auth
7Create admin policy (full access)
8Create admin user with password from attributes
9Create eso-read policy (read/list on secret/data/* and secret/metadata/*)

Critical: The {name}-init-keys K8s Secret holds the unseal key and root token. Loss of this secret = vault inaccessible.


Seed Job — Components & Secrets

The seed job populates OpenBao with secrets for 13 component types. Uses seed_if_missing — idempotent, never overwrites existing secrets.

Secret Path Convention

secret/data/{system_name}/{component_name}[/{sub_component}]

Example: secret/data/stack-3/cnpg/backend-db

Seeded Components

ComponentSecretsPer Sub-Component
rabbitmqerlang-cookie, passwordNo
cnpgadmin-username, admin-passwordYes — per database: username, password
mongodb10 cluster secrets (5 user pairs)Yes — per database: database, username, password, connection_string
grafanaadmin-user, admin-passwordNo
prometheussmtp_smarthost, smtp_from, smtp_auth_username, smtp_auth_password, slack_api_urlNo
keycloak_operatorDB creds + admin creds + client secret (3 paths)No
seaweedfsaccess_key, secret_keyYes — per bucket: access_key, secret_key, bucket
opensearchadmin_passwordNo
postgresqladmin-passwordYes — per database: username, password
valkeypasswordNo
redispasswordNo
clickhouseadmin_passwordYes — per database: username, password
argo_cdadmin_passwordNo

Template Detection Pattern

Every component template that manages secrets checks for ESO in pre_gen_project.py:

{% for comp in cookiecutter.components.values() if comp.type == "external_secrets" %}
{{ cookiecutter.update({"__external_secrets": comp }) }}
{% endfor %}

Conditional Secret Generation

When __external_secrets is set:

What ChangesWithout ESOWith ESO
Secret sourcesecretGenerator in kustomization.yamlExternalSecret CR
Secret filesecret/database-secret.yaml includedRemoved from resources
ESO CRNot generatedexternal-secret.yaml included
K8s Secret nameSameSame (transparent to workloads)

Templates with ESO detection: cnpg, rabbitmq, mongodb, grafana, prometheus, keycloak_operator, kafka, opensearch, loki, langfuse, open-webui, fastapi, fastmcp.


ExternalSecret CR Structure

apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: {component}-superadmin
namespace: {namespace}
spec:
refreshInterval: 1h # Auto-sync frequency
secretStoreRef:
kind: ClusterSecretStore
name: external-secrets # References the ClusterSecretStore
target:
name: superadmin-secret # K8s Secret name (same as local mode)
creationPolicy: Owner
data:
- secretKey: username
remoteRef:
key: secret/data/{system}/{component}
property: admin-username
- secretKey: password
remoteRef:
key: secret/data/{system}/{component}
property: admin-password

ESO controller syncs secrets every refreshInterval (default 1h). Password rotations in OpenBao propagate automatically.


ClusterSecretStore Providers

ProviderAttribute ValueAuth Method
OpenBao / VaultvaultToken from K8s Secret
AWS Secrets ManagerawsAccess key + secret key
GCP Secret ManagergcpService account JSON
Azure Key VaultazureClient ID + client secret

Set via secret_store_provider attribute on the external_secrets component.


Key Attributes

External Secrets

AttributeDescription
secret_store_providerProvider type: vault, aws, gcp, azure
secret_store_hostVault/OpenBao URL
secret_store_pathKV mount path (default: secret)
secret_store_versionKV version (default: v2)
refresh_intervalSync frequency (default: 1h)

OpenBao

AttributeDescription
admin_passwordAdmin user password
portService port (default: 8200)
pvc_sizePersistent volume size
storageclassStorage class for PVC

No Circular Dependencies

OpenBao init-job (standalone, no ESO dependency)

ESO seed-job (needs OpenBao initialized + token)

ESO controller (reads ClusterSecretStore → OpenBao)

K8s Secrets created (workloads can start)

OpenBao initializes independently. ESO depends on OpenBao being ready. Workloads depend on K8s Secrets existing.