Skip to content

Secrets Management

Two mechanisms, split by who owns the value.

MechanismOwnsCommitted?
Terraform-generateddatabase-credentials, spaces-credentialsNo — created by terraform apply
Sealed SecretsEverything elseYes — encrypted, safe in git

Why Sealed Secrets

A plain Kubernetes Secret is base64, not encryption. Committing one publishes it.

Bitnami Sealed Secrets fixes this with asymmetric encryption: you encrypt with a public certificate, and only the controller running in the cluster holds the private key. The resulting SealedSecret is safe to commit, review, and diff — and the controller decrypts it into a real Secret in-cluster.

Rendering diagram…

Encryption is scoped to a namespace and secret name, so a sealed secret cannot be moved to another namespace or renamed — which is why staging needs its own re-sealed copies.

Inventory

SecretKeysConsumed byManaged by
database-credentialsDATABASE_URL, DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAMEapi, anansi, ogun, igdb-heartbeatTerraform
spaces-credentialsAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, S3_ENDPOINT, S3_URL, S3_BUCKET, CDN_ENDPOINTapi, ogun, igdb-heartbeatTerraform
redis-secretsREDIS_HOST, REDIS_PORT, REDIS_PASSWORD, REDIS_URIapi, esu, anansi, ogunSealed
api-secretsJWT_SECRET, RESET_SECRET, REVENUECAT_WEBHOOK_SECRETapiSealed
esu-secretsAPI_SERVICE_TOKEN, REALTIME_ADMIN_TOKENapi, esuSealed
stripe-secretsSTRIPE_PUBLISHABLE_KEY, STRIPE_SECRET_KEYapi, anansiSealed
mux-secretsMUX_ACCESS_TOKEN, MUX_SECRET_KEY, MUX_WEBHOOK_SECRETapi, ogunSealed
livekit-secretsLIVEKIT_HOST, LIVEKIT_WS_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRETapiSealed
igdb-secretsIGDB_CLIENT_ID, IGDB_CLIENT_SECRETigdb-heartbeatSealed
grafana-secretsGrafana admin credentialsGrafanaSealed

Terraform keeps the first two because it creates the underlying resources and already knows the values — sealing them would mean copying secrets by hand for no benefit.

Setting up the controller

Once per cluster:

bash
helm repo add sealed-secrets https://bitnami-labs.github.io/sealed-secrets
helm repo update

helm install sealed-secrets sealed-secrets/sealed-secrets \
  --namespace sealed-secrets --create-namespace

kubectl get pods -n sealed-secrets

Then fetch the public certificate, which is committed so anyone can seal a secret without cluster access:

bash
kubeseal --fetch-cert \
  --controller-name=sealed-secrets \
  --controller-namespace=sealed-secrets \
  > infra/k8s/sealed-secrets/public-cert.pem

Creating a sealed secret

infra/k8s/sealed-secrets/seal.sh wraps kubeseal with the right certificate and namespace:

bash
kubectl create secret generic livekit-secrets \
  --namespace default \
  --from-literal=LIVEKIT_HOST='ws.playpalz.gg' \
  --from-literal=LIVEKIT_WS_URL='wss://ws.playpalz.gg' \
  --from-literal=LIVEKIT_API_KEY='…' \
  --from-literal=LIVEKIT_API_SECRET='…' \
  --dry-run=client -o yaml \
| ./infra/k8s/sealed-secrets/seal.sh livekit-secrets

Or from an env file:

bash
kubectl create secret generic stripe-secrets \
  --namespace default \
  --from-env-file=./apps/api/.env.stripe \
  --dry-run=client -o yaml \
| ./infra/k8s/sealed-secrets/seal.sh stripe-secrets

--dry-run=client is what keeps the plaintext secret out of the cluster and off disk — it is generated, piped, and discarded.

Then apply and commit:

bash
kubectl apply -f infra/k8s/sealed-secrets/livekit-secrets.yaml
git add infra/k8s/sealed-secrets/livekit-secrets.yaml

Consuming a secret

yaml
env:
  - name: JWT_SECRET
    valueFrom:
      secretKeyRef:
        name: api-secrets
        key: JWT_SECRET

Pods do not pick up a changed secret automatically:

bash
kubectl rollout restart deployment/playpalz-api

Rotating

  1. Create and seal a new version of the secret.
  2. kubectl apply the sealed secret.
  3. kubectl rollout restart every deployment that consumes it.
  4. Commit the updated file.

Rotating JWT_SECRET logs every user out — every issued token becomes unverifiable. Rotating REALTIME_ADMIN_TOKEN requires restarting api, esu, and ogun together, or realtime fan-out breaks in the gap.

Local secrets

Local development uses .env files, one per service, each with a committed .env.example. Never commit a real .env — the root .gitignore covers them.

Housekeeping

Plaintext credentials on disk in dev-notes/

dev-notes/secrets-commands.md contains live production credentials in plaintext — the Spaces access key and secret, the production PostgreSQL connection string and password, and the LiveKit API secret. dev-notes/info.md contains an account password.

dev-notes/ is listed in .gitignore, so none of this is in git history — the exposure is limited to developer machines and anything that syncs them (cloud drives, backups, AI tooling with filesystem access).

Treat those files as a credential leak waiting to happen: move the values to a password manager, replace them in the file with placeholders, and rotate anything that has been shared or synced.

dev-notes/ is not in the repository

Because the whole directory is git-ignored, a fresh clone does not include it. That takes the design system specification (dev-notes/playpalz-design-system.html), the sealed-secrets guide, and every implementation plan with it.

The credential files should stay out of git. The design system and the operational guides should not — consider moving the non-sensitive ones into apps/docs/ so they reach the next developer.

Disaster recovery

The sealed-secrets controller's private key is the only thing that can decrypt the committed sealed secrets. If you lose the cluster, you lose the ability to decrypt them.

Back up the key:

bash
kubectl get secret -n sealed-secrets \
  -l sealedsecrets.bitnami.com/sealed-secrets-key \
  -o yaml > sealed-secrets-key-backup.yaml

Store that file somewhere genuinely secure — it is equivalent to every secret it protects. Restore by applying it to a new cluster before the controller generates a fresh key.

Without the backup, recovery means re-creating and re-sealing every secret from their original sources.

Internal documentation — PlayPalz platform