De eerste keer dat ik kubectl get secret myapp -o yaml draaide en de waarde base64-decodeerde, zakte mijn maag. Daar stond mijn database-wachtwoord, gewoon in etcd, leesbaar voor iedereen die met de juiste RBAC bij de API kon. Kubernetes Secrets zijn geen secrets. Het is base64-gecodeerde platte tekst met een mooie naam. Dat is de default, en het is precies het ding waar niemand je op dag één voor waarschuwt.

Elke cloud provider verkoopt een oplossing. AWS heeft Secrets Manager, Google heeft Secret Manager, Azure heeft Key Vault. Ze werken. De adder onder het gras komt later: de dag dat je moet migreren, de dag dat je precies wilt weten wat er met een secret gebeurt nadat je het wegschrijft, de dag dat je beseft dat je meest gevoelige data leeft in een systeem dat je niet kunt inspecteren.

HashiCorp Vault is het self-hosted antwoord. Jij draait het, jij controleert het, en als het om 2 uur ’s nachts stuk gaat kun je daadwerkelijk beredeneren waarom.

Deze post bouwt Vault laag voor laag op. Begin met het kleinste dat werkt, voeg dan echt gebruik toe, en daarna de geavanceerde stukken. Stop met lezen waar je genoeg hebt.

Waarom self-hosted secrets?

Een cloud KMS geeft je een paar problemen verpakt als features. Je secrets leven in een systeem dat je niet beheert, dus je kunt niet auditen wat er binnenin gebeurt. Migratie is bruut omdat secrets de meest plakkerige lock-in zijn die er bestaat. En je betaalt per secret, per request, voor altijd.

Self-hosted Vault draait elk van die punten om. Je ziet precies hoe secrets worden opgeslagen en benaderd. Er is geen vendor lock-in op je meest kritieke data. Het draait identiek over clouds, on-prem en de edge, dus dezelfde config werkt overal. Je secrets verlaten nooit infrastructuur die je controleert.

Dat is de soevereiniteitskant. Zoals ik schreef in Sovereign Infrastructure, black boxes voelen als splinters, en secrets zijn de ene plek waar een splinter je hele platform onderuit kan halen. De trade-off is echt: je draait nu Vault zelf, inclusief unsealen en backups. Die kosten benoem ik eerlijk, overal in deze post.

Vault-architectuur, de basis

Voor er ook maar één commando volgt: drie concepten. Zodra die klikken, voelt de rest van Vault niet meer als magie.

Secrets engines zijn waar secrets leven.

  • kv (key-value) voor statische secrets
  • database voor dynamische database credentials
  • pki voor certificaatgeneratie

Auth methods zijn hoe clients bewijzen wie ze zijn.

  • kubernetes voor pods
  • token voor directe toegang
  • userpass, ldap, oidc voor mensen

Policies bepalen wat een geauthenticeerde client mag aanraken.

flowchart TD
    subgraph vault["Vault"]
        subgraph auth["Auth Methods"]
            K8S["kubernetes"]
            USER["userpass"]
        end

        K8S --> POL["Policies"]
        USER --> POL

        subgraph engines["Secrets Engines"]
            KV["KV"]
            PKI["PKI"]
            DB["DB"]
        end

        POL --> KV
        POL --> PKI
        POL --> DB
    end

De simpelste versie: Vault installeren

Begin met de kleinste install die draait. De officiële Helm chart brengt je daar.

helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

helm install vault hashicorp/vault \
  --namespace vault \
  --create-namespace \
  --set server.ha.enabled=true \
  --set server.ha.replicas=3

Zelf installeer ik trouwens niks met de hand. Alles gaat via GitOps met ArgoCD, zodat Vault één keer wordt gedeclareerd en het cluster het reconcilet:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: vault
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://helm.releases.hashicorp.com
    chart: vault
    targetRevision: 0.27.0
    helm:
      values: |
        server:
          ha:
            enabled: true
            replicas: 3
            raft:
              enabled: true
          dataStorage:
            size: 10Gi
            storageClass: longhorn
        injector:
          enabled: true
  destination:
    server: https://kubernetes.default.svc
    namespace: vault
  syncPolicy:
    automated:
      prune: true
      selfHeal: true

Initialiseren en unsealen

Hier struikelt iedereen die nieuw is met Vault. Vault start sealed. Zelfs met draaiende pods serveert het geen enkele secret tot je het unsealt. Dat is met opzet: de encryptiesleutel die alles beschermt is zelf versleuteld, en je moet genoeg sleuteldelen aanleveren om hem te reconstrueren. Initialiseer eerst:

kubectl exec -it vault-0 -n vault -- vault operator init

Dit geeft je twee dingen: 5 unseal keys, en een initiële root token. De keys zijn Shamir-shares, dus standaard heb je er 3 van de 5 nodig om te unsealen.

Bewaar deze ergens veilig en offline. Verlies je de unseal keys, dan verlies je toegang tot elke secret die Vault bewaart. Geen recovery, geen supportticket, geen reset-link. Ik houd de mijne verdeeld over gescheiden offline locaties, want dat is juist het punt: geen enkele compromittering mag iemand de sleutels tot alles geven.

Unseal elke pod door er 3 van de 5 keys één voor één in te voeren:

kubectl exec -it vault-0 -n vault -- vault operator unseal <key1>
kubectl exec -it vault-0 -n vault -- vault operator unseal <key2>
kubectl exec -it vault-0 -n vault -- vault operator unseal <key3>

Herhaal voor vault-1 en vault-2. Ja, met de hand, drie keer.

Laag 1: auto-unseal

Het handmatige gedoe is prima voor een demo. In productie is het een valkuil. Elke pod-restart, elke node-reboot, elk chaos-event laat Vault sealed achter en je apps kunnen geen secrets lezen tot er een mens met de keys opdaagt. Voor een homelab die mij moet overleven terwijl ik slaap is dat geen optie.

Auto-unseal lost dit op door het unseal-werk over te laten aan een aparte sleutel, ofwel de transit-engine van een andere Vault, ofwel een cloud KMS:

# Andere Vault gebruiken voor transit auto-unseal
server:
  ha:
    enabled: true
  config: |
    seal "transit" {
      address = "https://vault-primary.example.com:8200"
      disable_renewal = "false"
      key_name = "autounseal"
      mount_path = "transit/"
      tls_skip_verify = "false"
    }

Of met een cloud KMS. Cloud gebruiken is hier een bewuste, nauwe uitzondering: één klein sleuteltje waarvan de enige taak is om Vault’s masterkey te wrappen, niet je echte secrets. De blast radius is klein en de resilience-winst groot, dus die ruil maak ik graag:

server:
  config: |
    seal "awskms" {
      region     = "eu-west-1"
      kms_key_id = "alias/vault-unseal"
    }

De KV secrets engine aanzetten

Met Vault unsealed kun je eindelijk iets opslaan. De kv engine is de simpelste en een goede plek om te beginnen. Log in, zet hem aan, schrijf een secret:

# Login met root token
kubectl exec -it vault-0 -n vault -- vault login

# Enable KV v2 secrets engine
kubectl exec -it vault-0 -n vault -- vault secrets enable -path=secret kv-v2

# Maak een secret
kubectl exec -it vault-0 -n vault -- vault kv put secret/myapp/config \
  database_url="postgres://user:pass@db:5432/myapp" \
  api_key="super-secret-key"

Kubernetes-authenticatie

Een secret in Vault is nutteloos als je pods er niet bij kunnen. De nette manier om toegang te geven is de Kubernetes auth method, waarmee een pod zijn identiteit bewijst met zijn eigen service account token. Geen langlevende statische credential in een image gebakken, geen gedeeld wachtwoord. Vault verifieert de token bij de API server en beslist of het in ruil een kortlevende Vault token uitgeeft.

Zet het aan en configureer het:

# Enable Kubernetes auth
kubectl exec -it vault-0 -n vault -- vault auth enable kubernetes

# Configureer het
kubectl exec -it vault-0 -n vault -- vault write auth/kubernetes/config \
  kubernetes_host="https://$KUBERNETES_SERVICE_HOST:$KUBERNETES_SERVICE_PORT"

Bind daarna een role aan een specifiek service account in een specifieke namespace. Hier begint least privilege: alleen het myapp service account in default krijgt deze role, en telkens maar voor een uur.

kubectl exec -it vault-0 -n vault -- vault write auth/kubernetes/role/myapp \
  bound_service_account_names=myapp \
  bound_service_account_namespaces=default \
  policies=myapp-policy \
  ttl=1h

Policies maken

De role hierboven verwees naar myapp-policy, die nog niet bestaat. In policies leg je precies vast wat een client mag lezen of listen, pad voor pad. Default-deny zit ingebakken: een pad dat niet is toegekend is verboden. Deze laat de app alleen zijn eigen config lezen en niets anders.

# myapp-policy.hcl
path "secret/data/myapp/*" {
  capabilities = ["read"]
}

path "secret/metadata/myapp/*" {
  capabilities = ["list"]
}

Pas het toe:

kubectl exec -it vault-0 -n vault -- vault policy write myapp-policy - <<EOF
path "secret/data/myapp/*" {
  capabilities = ["read"]
}

path "secret/metadata/myapp/*" {
  capabilities = ["list"]
}
EOF

Laag 2: secrets injecteren in pods

Tot nu toe draaide je vault-commando’s met de hand. Echte workloads hebben secrets nodig die automatisch worden geleverd. De Vault Agent Injector doet dit met niets meer dan pod-annotations: hij let erop, voegt een init container toe die authenticeert, en zet de gerenderde secret op een bestand dat je app leest. Geen SDK, geen Vault-bewuste code in je applicatie.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: myapp
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "myapp"
        vault.hashicorp.com/agent-inject-secret-config: "secret/data/myapp/config"
        vault.hashicorp.com/agent-inject-template-config: |
          {{- with secret "secret/data/myapp/config" -}}
          export DATABASE_URL="{{ .Data.data.database_url }}"
          export API_KEY="{{ .Data.data.api_key }}"
          {{- end }}
    spec:
      serviceAccountName: myapp
      containers:
        - name: app
          image: myapp:v1.0.0
          command: ["/bin/sh", "-c"]
          args:
            - source /vault/secrets/config && ./myapp

De injector:

  1. Voegt een init container toe die authenticeert met Vault
  2. Schrijft secrets naar /vault/secrets/config
  3. Je app leest ze bij startup

External Secrets Operator als alternatief

De injector is geweldig, maar koppelt je pods aan Vault’s annotation-dans en mount secrets als bestanden, wat niet elke app wil. External Secrets Operator kiest een andere route. Het synct Vault secrets naar native Kubernetes Secrets, zodat je workloads ze op de saaie, standaard manier consumeren via envFrom of valueFrom. Ik pak dit erbij als een app gewone environment variables verwacht en ik hem niets over Vault wil leren.

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: vault-backend
spec:
  provider:
    vault:
      server: "http://vault.vault:8200"
      path: "secret"
      version: "v2"
      auth:
        kubernetes:
          mountPath: "kubernetes"
          role: "myapp"
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: myapp-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: vault-backend
    kind: SecretStore
  target:
    name: myapp-secrets
    creationPolicy: Owner
  data:
    - secretKey: DATABASE_URL
      remoteRef:
        key: myapp/config
        property: database_url
    - secretKey: API_KEY
      remoteRef:
        key: myapp/config
        property: api_key

Dit maakt een normale Kubernetes Secret die je pods normaal kunnen gebruiken:

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          envFrom:
            - secretRef:
                name: myapp-secrets

Dynamische database credentials

Hier houdt Vault op een chique wachtwoordkluis te zijn en wordt het iets wat je niet zomaar met een cloud KMS namaakt. Statische secrets blijven liggen tot iemand ze roteert, oftewel: tot iemand het vergeet. Dynamische secrets draaien het model om: Vault maakt op aanvraag een gloednieuwe database-user, geeft die aan één pod, en verwijdert hem als de lease verloopt. Niets wordt gedeeld, niets is permanent.

Zet de database-engine aan, wijs hem naar Postgres, en definieer een role die users kan aanmaken:

# Enable database secrets engine
kubectl exec -it vault-0 -n vault -- vault secrets enable database

# Configureer PostgreSQL connectie
kubectl exec -it vault-0 -n vault -- vault write database/config/mydb \
  plugin_name=postgresql-database-plugin \
  connection_url="postgresql://{{username}}:{{password}}@postgres.default:5432/myapp" \
  allowed_roles="myapp-db" \
  username="vault_admin" \
  password="vault_admin_password"

# Maak role die credentials genereert
kubectl exec -it vault-0 -n vault -- vault write database/roles/myapp-db \
  db_name=mydb \
  creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
  default_ttl="1h" \
  max_ttl="24h"

Nu vraagt een pod zijn eigen credentials op via dezelfde injector-annotations als eerder:

annotations:
  vault.hashicorp.com/agent-inject-secret-db: "database/creds/myapp-db"
  vault.hashicorp.com/agent-inject-template-db: |
    {{- with secret "database/creds/myapp-db" -}}
    export PGUSER="{{ .Data.username }}"
    export PGPASSWORD="{{ .Data.password }}"
    {{- end }}

Elke pod krijgt unieke credentials die:

  • Auto-roteren
  • Individueel kunnen worden ingetrokken
  • Een audit trail creëren per pod

PKI: certificate authority

Hetzelfde dynamische-secret-idee werkt voor certificaten. Vault kan als je interne CA draaien en kortlevende certs op aanvraag uitgeven, wat fijner is dan het alternatief van langlevende certificaten die je vergeet tot ze op een zondag verlopen. Zet een root CA op en een role die afbakent wat hij mag signen:

# Enable PKI
kubectl exec -it vault-0 -n vault -- vault secrets enable pki

# Configureer max TTL
kubectl exec -it vault-0 -n vault -- vault secrets tune -max-lease-ttl=87600h pki

# Genereer root CA
kubectl exec -it vault-0 -n vault -- vault write -field=certificate pki/root/generate/internal \
  common_name="My Org Root CA" \
  ttl=87600h > CA_cert.crt

# Maak role voor uitgifte certs
kubectl exec -it vault-0 -n vault -- vault write pki/roles/internal-certs \
  allowed_domains="internal,svc.cluster.local" \
  allow_subdomains=true \
  max_ttl=72h

Knoop dit aan cert-manager en certificaten roteren zichzelf door het cluster zonder dat je er iets aan hoeft te doen.

High availability

Een secrets store die omvalt sleurt elke afhankelijke app mee, dus Vault is de laatste plek waar je een single point of failure wilt. Draai het in HA mode met de ingebouwde Raft storage, die de data binnen Vault zelf houdt in plaats van te leunen op een externe database die je óók resilient zou moeten maken.

server:
  ha:
    enabled: true
    replicas: 3
    raft:
      enabled: true
      setNodeId: true
      config: |
        cluster_name = "vault-cluster"
        storage "raft" {
          path = "/vault/data"
          retry_join {
            leader_api_addr = "http://vault-0.vault-internal:8200"
          }
          retry_join {
            leader_api_addr = "http://vault-1.vault-internal:8200"
          }
          retry_join {
            leader_api_addr = "http://vault-2.vault-internal:8200"
          }
        }

Dit creëert een 3-node Raft cluster:

  • Eén leader, twee followers
  • Automatische leader election bij failure
  • Data gerepliceerd over alle nodes

Backupstrategie

HA beschermt je tegen een stervende node. Het doet niets tegen een vingervlugge vault policy delete of een corrupte Raft-log. Daarvoor heb je snapshots nodig, en ze moeten geautomatiseerd zijn, want een backup die je zelf moet onthouden te draaien is een backup die er niet is op het moment dat je hem nodig hebt. Maak een handmatige snapshot om te zien hoe het werkt:

# Maak snapshot
kubectl exec -it vault-0 -n vault -- vault operator raft snapshot save /tmp/vault-snapshot.snap

# Kopieer naar lokaal
kubectl cp vault/vault-0:/tmp/vault-snapshot.snap ./vault-snapshot.snap

Maak het daarna saai en automatisch met een CronJob, zodat snapshots gebeuren of ik nu oplet of niet:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: vault-backup
spec:
  schedule: "0 */6 * * *"  # Elke 6 uur
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: vault-backup
          containers:
            - name: backup
              image: hashicorp/vault:1.15
              command:
                - /bin/sh
                - -c
                - |
                  vault operator raft snapshot save /backup/vault-$(date +%Y%m%d-%H%M%S).snap
              volumeMounts:
                - name: backup
                  mountPath: /backup
          volumes:
            - name: backup
              persistentVolumeClaim:
                claimName: vault-backup
          restartPolicy: OnFailure

Audit logging

Als je niet kunt zeggen wie welke secret las en wanneer, dan controleer je je secrets niet echt, je bewaart ze alleen. Audit logging dicht dat gat door elke request vast te leggen. Zet het aan:

kubectl exec -it vault-0 -n vault -- vault audit enable file file_path=/vault/audit/audit.log

Elke toegang belandt in de log als gestructureerde JSON, klaar om naar Loki te sturen of waar je ook mee query’t:

{
  "time": "2025-07-02T10:00:00Z",
  "type": "response",
  "auth": {
    "token_type": "service",
    "policies": ["myapp-policy"]
  },
  "request": {
    "path": "secret/data/myapp/config",
    "operation": "read"
  }
}

Het volledige plaatje: mijn echte setup

Weet je nog, die helm install van drie regels aan het begin? Hier landen alle lagen in de praktijk. Dit is de echte config die in mijn homelab draait: HA Raft, audit storage, verstandige resource limits, en de UI voor het zeldzame moment dat ik ergens op moet klikken:

server:
  ha:
    enabled: true
    replicas: 3
    raft:
      enabled: true

  dataStorage:
    size: 10Gi
    storageClass: longhorn

  auditStorage:
    enabled: true
    size: 10Gi
    storageClass: longhorn

  resources:
    requests:
      memory: 256Mi
      cpu: 250m
    limits:
      memory: 512Mi
      cpu: 500m

  extraEnvironmentVars:
    VAULT_CACERT: /vault/userconfig/vault-tls/ca.crt

injector:
  enabled: true
  resources:
    requests:
      memory: 64Mi
      cpu: 50m
    limits:
      memory: 128Mi
      cpu: 100m

ui:
  enabled: true

Een paar keuzes die het benoemen waard zijn:

  • HA met Raft houdt storage binnen Vault, dus er is geen externe database die je ook in leven moet houden.
  • Audit storage verdient zijn plek de eerste keer dat je moet beantwoorden “wie raakte deze secret aan?”.
  • Resource limits blijven klein omdat Vault echt lightweight is. Het is nauwelijks te merken op het cluster.
  • UI enabled voor de incidentele handmatige por, ook al loopt vrijwel alles via GitOps.

Migratie van cloud KMS

Al op AWS Secrets Manager of vergelijkbaar? Je hoeft geen knop om te zetten en te bidden. Migreer per app. Installeer Vault naast je bestaande KMS, sync de secrets die je wilt verplaatsen, wijs apps één voor één opnieuw aan, en decommission de cloud KMS zodra hij leeg is. Niemand krijgt een big-bang weekend.

External Secrets Operator maakt dit pijnloos omdat het tijdens de transitie van beide backends tegelijk kan lezen:

apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: aws-secrets-manager
spec:
  provider:
    aws:
      service: SecretsManager
      region: eu-west-1
---
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
  name: vault
spec:
  provider:
    vault:
      server: "http://vault.vault:8200"

Waarom dit ertoe doet

We begonnen met een base64-string in etcd en eindigden met dynamische database credentials, een eigen CA, een HA cluster, geautomatiseerde snapshots, en een audit log van elke toegang. Je kunt bij elke laag stoppen en nog steeds beter af zijn dan waar de defaults je achterlieten.

Wat je krijgt van het zelf draaien: inzicht in hoe secrets werkelijk worden opgeslagen en benaderd, dezelfde workflow of je nu op een cloud, on-prem of een Raspberry Pi in island mode zit, geen verrassende pricing, en de mogelijkheid om dingen te fixen als ze stuk gaan, omdat je de hele stack begrijpt.

Ik doe niet alsof de operationele kosten nul zijn. Je draait nu zelf unsealen, backups en upgrades. Maar secrets zijn het ene ding dat ik weiger in een black box te laten, want het zijn de sleutels tot al het andere dat ik draai. Die complexiteit zelf dragen is de prijs van niet afhankelijk zijn van een systeem dat ik niet kan inspecteren, en voor dit ene ding betaal ik die elke keer weer.