Stage 7 · Master
Policy as Code & Security Guardrails
Secrets Management in the Platform
External Secrets Operator, Vault, SealedSecrets. Platform-managed secret rotation. Developer UX: no secrets in git, ever.
Secrets Management Principles
- Zero secrets in git: Not even encrypted. Git is not a secret store
- Platform owns secret lifecycle: creation, rotation, revocation, audit
- Developers consume, never manage:
platform secret getnotvault kv put - Least privilege: Each service gets only the secrets it needs
- Rotation is automatic: No manual rotation, ever. Platform handles it
- Audit everything: Who accessed what, when, from where
Architecture
External Secrets Operator
ESO syncs secrets from external stores (Vault, AWS, Azure, GCP, IBM, Oracle, Kubernetes) into Kubernetes Secrets. Declarative: define ExternalSecret resource, ESO creates/updates K8s Secret.
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-platform
spec:
provider:
vault:
server: "https://vault.platform.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "platform-eso"
# Namespace isolation: each team gets own path
# namespace: "{{ .metadata.namespace }}"
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: payment-api-secrets
namespace: payments-payment-api
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-platform
kind: ClusterSecretStore
target:
name: payment-api-secrets
creationPolicy: Owner
template:
engineVersion: v2
data:
# Transform secret keys to env var format
DATABASE_URL: "{{ .postgresql_url | toString }}"
REDIS_URL: "{{ .redis_url | toString }}"
KAFKA_BROKERS: "{{ .kafka_brokers | toString }}"
JWT_SECRET: "{{ .jwt_secret | toString }}"
data:
- secretKey: postgresql_url
remoteRef:
key: payments/payment-api
property: database_url
- secretKey: redis_url
remoteRef:
key: payments/payment-api
property: redis_url
- secretKey: kafka_brokers
remoteRef:
key: shared/kafka
property: brokers
- secretKey: jwt_secret
remoteRef:
key: platform/jwt
property: signing_key
ClusterSecretStore defines Vault connection. ExternalSecret pulls specific keys, transforms via template, creates K8s Secret.
Vault Integration
- Platform team manages Vault: unseal, HA, backups, upgrades, audit
- Kubernetes auth method: ESO authenticates via service account token
- Path structure:
secret/<team>/<service>/<key> - Dynamic secrets: Database credentials generated on-demand, TTL-based
- PKI: Vault issues TLS certs for mTLS, cert-manager integration
- Transit engine: Encryption-as-a-service for app-level encryption
# Platform team policy
path "secret/data/platform/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "secret/metadata/platform/*" {
capabilities = ["list"]
}
# Payments team policy
path "secret/data/payments/*" {
capabilities = ["create", "read", "update", "delete", "list"]
}
path "secret/metadata/payments/*" {
capabilities = ["list"]
}
# Database dynamic secrets
path "database/creds/payments-readonly" {
capabilities = ["read"]
}
path "database/creds/payments-readwrite" {
capabilities = ["read"]
}
# Transit encryption
path "transit/encrypt/payments-pii" {
capabilities = ["update"]
}
path "transit/decrypt/payments-pii" {
capabilities = ["update"]
}
Vault policies per team. Dynamic database credentials. Transit engine for app-level encryption.
SealedSecrets for GitOps
SealedSecrets encrypts Kubernetes Secrets so they can be safely stored in Git. Only the SealedSecrets controller (running in cluster) can decrypt. One-way encryption: developers encrypt locally, only cluster decrypts.
# Install kubeseal CLI
brew install kubeseal
# Fetch public key from cluster
kubeseal --fetch-cert --controller-name=sealed-secrets --controller-namespace=kube-system > pub-cert.pem
# Create secret locally (never commit raw secret!)
cat > secret.yaml <<EOF
apiVersion: v1
kind: Secret
metadata:
name: payment-api-secrets
namespace: payments-payment-api
type: Opaque
stringData:
DATABASE_URL: "postgresql://user:pass@host:5432/db"
JWT_SECRET: "super-secret-key"
EOF
# Seal it (encrypt with cluster public key)
kubeseal --cert pub-cert.pem < secret.yaml > sealed-secret.yaml
# sealed-secret.yaml is safe to commit to Git!
cat sealed-secret.yaml
# apiVersion: bitnami.com/v1alpha1
# kind: SealedSecret
# metadata:
# name: payment-api-secrets
# namespace: payments-payment-api
# spec:
# encryptedData:
# DATABASE_URL: AgBy3i... (encrypted)
# JWT_SECRET: AgBy3i... (encrypted)
# template:
# metadata:
# name: payment-api-secrets
# namespace: payments-payment-api
# Commit sealed-secret.yaml to Git
git add sealed-secret.yaml
git commit -m "Add sealed secrets for payment-api"
git push
# ArgoCD applies SealedSecret -> Controller decrypts -> Creates Secret
Developer encrypts secret with cluster public key. Only cluster controller can decrypt. Safe for Git.
Automated Rotation
- Vault dynamic secrets: TTL-based, auto-revoked. Apps reload on SIGHUP or watch file
- Static secrets: Platform rotates on schedule (90 days), updates Vault, ESO syncs to K8s
- App reload: Reloader (stakater/reloader) watches Secret/ConfigMap changes, rolls pods
- Rotation window: Configurable per secret type (DB: 30d, API keys: 90d, TLS: 60d)
- Zero-downtime: Rolling restart with maxSurge/maxUnavailable, readiness probes
- Audit trail: Vault audit logs + Kubernetes audit logs + platform API logs
apiVersion: apps/v1
kind: Deployment
metadata:
name: payment-api
annotations:
reloader.stakater.com/auto: "true"
# Or specific secrets
# secret.reloader.stakater.com/reload: "payment-api-secrets,jwt-secret"
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
spec:
containers:
- name: payment-api
envFrom:
- secretRef:
name: payment-api-secrets
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
Reloader watches Secret changes, triggers rolling restart. Zero-downtime with proper probes and strategy.
Developer UX
# List secrets for a service (no values shown)
$ platform secret list payment-api
NAME TYPE LAST_ROTATED TTL
database_url dynamic 2024-01-10 24h
redis_url static 2024-01-05 90d
kafka_brokers static 2024-01-01 90d
jwt_secret static 2024-01-15 90d
# Get secret value (requires permission, audited)
$ platform secret get payment-api database_url
postgresql://user:pass@host:5432/db
# Rotate a static secret immediately
$ platform secret rotate payment-api jwt_secret
Rotated jwt_secret for payment-api. New version deployed. Rollout in progress...
# Create a new secret (platform provisions in Vault + syncs to K8s)
$ platform secret create payment-api new_api_key --type=static --ttl=90d
Created new_api_key for payment-api. Available in 30 seconds.
# View rotation history
$ platform secret history payment-api jwt_secret
VERSION ROTATED_AT ROTATED_BY REASON
v3 2024-01-15 10:30 alice@company Scheduled rotation
v2 2023-10-15 10:30 bob@company Scheduled rotation
v1 2023-07-15 10:30 charlie@company Initial creation
CLI abstracts Vault/ESO. Developers list, get, rotate secrets without knowing Vault. All actions audited.
Even SealedSecrets should be used sparingly. Prefer External Secrets Operator for all secrets. SealedSecrets only for: initial bootstrap, secrets that can't be in Vault (e.g., initial root CA), air-gapped clusters.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.