Secrets
docs/secrets.md
Managing secrets in kilter projects
Short, practical guide for any kilter deploy project. The golden rule:
deploy-time configuration is a secret, wired per-environment — there is
no .env shortcut.
Why
At the current kilter CLI version, a plain env: block in kilter.yaml is
not shipped to deployed pods. The only deploy-time env mechanism is the
appSecrets → per-environment secrets: path. Two failure modes follow:
- Silent fallback — a var that isn't wired is just absent, so the app
uses its in-code default (e.g. an API client quietly falling back to
localhostand reporting the upstream as unreachable). - Hard 400 — if an environment's secret object doesn't exist in its
namespace,
kilter deployrefuses:missing secrets in <ns>: <name>.
Both mean the same thing: a secret wasn't wired for that environment.
How it fits together
kilter.yaml secrets/<env>.env in-cluster
───────────────────────── ───────────────── ──────────────
appSecrets: MY_API_URL=... → Secret object
- <app>-secrets (plaintext, gitignored) (SOPS-encrypted
environments: by kilter deploy,
prod: decrypted by Flux)
secrets: ┌───────────────────────┐
<app>-secrets: ──────────┤ points at the file │
secrets/prod.env └───────────────────────┘
staging:
secrets:
<app>-secrets: secrets/staging.env
appSecrets:declares the secret name(s) the app mounts. Mountedoptional: truein dev, so localkilter upworks without the files.- Each environment maps that name to a local plaintext
KEY=VALUEfile. kilter deploySOPS-encrypts the file to the cluster's age recipient (sopsRecipientinkilter.yaml); Flux decrypts it into a real k8s Secret in the environment's namespace.
Rules of thumb
- One secret file per environment.
secrets/prod.env,secrets/staging.env, … Each is independent; a value set in one is not inherited by another. secrets/is gitignored — never commit plaintext. Only the SOPS-encrypted form belongs anywhere shared, andkilter deployproduces that. Verify before every commit:git check-ignore secrets/<env>.env # must print the path git ls-files secrets/ # must be empty- Adding a new environment = add an
environments.<env>.secretsblock and createsecrets/<env>.env. Skipping either reproduces the 400. - Adding a new variable = add it to every environment's file, not just the one you're testing. A var present in one env file and missing from another is the silent-fallback bug waiting to happen.
- Back up the source files out-of-band. The plaintext lives only on your machine; the cluster keeps only the encrypted copy. A lost laptop = reconstruct-from-memory. Use a password manager / shared vault for the canonical values.
- Treat every value as sensitive. Even "harmless" ones like service URLs leak topology; rotate any real credential the moment a plaintext file lands somewhere it shouldn't.
Recipes
Add a variable to an existing environment
echo 'NEW_VAR=value' >> secrets/prod.env # repeat for each env file
kilter deploy --env prod # re-ship the secret
Add a new environment (e.g. staging)
# kilter.yaml
environments:
staging:
secrets:
<app>-secrets: secrets/staging.env
cp secrets/prod.env secrets/staging.env # then edit values for staging
kilter deploy --env staging
Emergency: create the secret directly in-cluster (bypasses SOPS/Flux —
last resort; the next kilter deploy overwrites it from the file)
kubectl create secret generic <app>-secrets \
--from-env-file=secrets/staging.env \
-n <app>-staging --context <cluster-context>
Mini-cluster gotchas
- Cross-namespace egress is firewalled. Pointing one app's
*_API_URLat another namespace's service requires the target namespace labeledkilter.dev/role=infra; the kilter-app egress NetworkPolicy otherwise allows only same-namespace + port 443. The external hostname is no workaround — the gateway 403s hairpinned in-cluster traffic. - kubectl needs an explicit context.
~/.kube/configdefaults to netshire prod. Use--context kind-kilter-<project>for a project's local KIND cluster or--context admin@mini-clusterfor netshire — never rely on the default.