lok8s manages secrets through a small, deterministic cache plus a kustomize generator — with optional SOPS/age encryption so secrets can be committed to git safely. No external secret store is required to get started.
- The secrets kustomize plugin (
secrets.lok8s.dev/v1/Secret) turns a declarative YAML spec into a KubernetesSecretat build time. See the plugin reference for every generator type (passwd,key,template,bash,env,file,secretRef,cert, …).template:composes multi-part secrets from typed sub-sections + a pattern;key:mints RSA/Ed25519 private keys as PKCS#8 PEM (noopenssl, no approval gate). - Generated values are cached one file per key —
Secret.<name>.<namespace>.<key>— in a per-domain store,clusters/<domain>/secrets/. The store is the source of truth — first build generates, later builds reuse, so output is stable. Each domain has its own store, so environments never share a secret (see Per-environment isolation). $PATH_SECRETSis the active domain's store.lo buildandlo deployexportPATH_SECRETS=clusters/<domain>/secretsfor the selected domain (set withlo use, recorded inclusters/.active) — so every generator, and everysecretRef:that reads another secret, resolves within that cluster. Only a project with no domain context falls back to one flat store ($PATH_SECRETS, default.secrets/).- Plaintext cache files are gitignored. To share them, commit them encrypted with SOPS/age (below).
clusters/app.example.com/secrets/Secret.myapp.default.PASSWORD ← plaintext (gitignored)
clusters/app.example.com/secrets/Secret.myapp.default.PASSWORD.enc ← SOPS-encrypted (committed)
Declare a generator and reference it from your kustomization:
# secret.yaml
apiVersion: secrets.lok8s.dev/v1
kind: Secret
metadata: { name: myapp, namespace: default }
passwd:
SESSION_KEY: { length: 64, chars: hex } # 256-bit key as hex# kustomization.yaml
generators:
- secret.yamllo build <domain> generates SESSION_KEY into that domain's store
(clusters/<domain>/secrets/), caches it, and emits the Secret. To rotate a
value, delete its cache file and rebuild. (Charset options and how to generate
proper cryptographic keys are covered in the
reference.)
For secrets that come from outside (an API token, a vendor key), write the value
straight into the cache instead of generating it. Pass --domain so it lands in
that domain's store (the flag also creates the store on first use):
lo secrets set --domain app.example.com --name myapp --namespace default API_TOKEN <value>
# omit the value to read it from a prompt / piped stdin (keeps it out of shell history):
printf %s "$TOKEN" | lo secrets set --domain app.example.com --name myapp --namespace default API_TOKEN
# or pass `-` explicitly (POSIX stdin placeholder), e.g. from a file
# (needs an argsh build including arg-sh/argsh#176 — older parsers
# reject a bare `-`):
lo secrets set --domain app.example.com --name myapp --namespace default API_TOKEN - < token.txtTo store the literal string - as a value, use the pipe form:
printf %s - | lo secrets set … API_TOKEN.
It's then cached like any generated value and can be encrypted + committed.
Pass --encrypt (short -e; the alias --enc also works) to SOPS-encrypt the
value in the same step — it writes the plaintext cache and its .enc in one go,
so you never leave a fresh value lying unencrypted:
printf %s "$TOKEN" | lo secrets set --domain app.example.com --name myapp API_TOKEN --encrypt--encrypt encrypts only the file it just wrote (not a whole-store sweep — see the
staging note under Committing secrets) and needs
encryption already set up (.sops.yaml present, via lo secrets init); without it the
set fails. When encryption is configured but you write plaintext-only (no
--encrypt), set warns that this value has no matching .enc yet — whether that's a
first write (none exists) or an edit (the committed one is now stale) — so re-run with
--encrypt or lo secrets encrypt before committing.
Some secrets are consumed by the CLI / provisioner as shell environment
variables — before a cluster (or any Kubernetes Secret) exists: a cloud API
token, bare-metal credentials. Keep those in the managed store too — named after
the env vars you need — and load them with lo secrets env instead of a loose
.env file:
lo secrets --domain prod set HCLOUD_TOKEN <token> --name hetzner --namespace provisioning
# …then, in your provisioning script:
eval "$(lo secrets --domain prod env --name hetzner --namespace provisioning)" # exports HCLOUD_TOKENenv emits an export <key>=<value> line per key of the named secret, with the
value shell-quoted (%q) so the eval is injection-safe. The creds get SOPS
encryption + per-domain isolation like everything else — no plaintext .env
sitting outside the model.
The Secret spec has no literal/static value field, by design: a plaintext
value must never be baked into a committed Secret.*.yaml. The rule:
- Default — generate it. Every key comes from a generator (
passwd,bash, …) and is cached. This holds even for values you might be tempted to fix by hand (a password, an HMAC key): let it be random. - Need a specific value? (a chosen password, or one value two components
must share.) Still declare the generator — so the key always exists and a build
never fails — then pin the exact value once with
lo secrets set(above). The cache is the source of truth, so the set value sticks; the operator does it a single time per environment, and it encrypts + commits like any other key. - Not actually a secret? An identifier — an OIDC
client_id, a username, a hostname, a public URL — does not belong in aSecretat all. Put it in plain config (Helm values / aConfigMap), where a literal is fine and reviewable in the diff. (Don't smuggle an identifier through a Secret just to colocate it.)
bash: generators run shell commands at build time, so they're gated: after
cloning, or whenever a bash: command changes, approve the current set once:
lo secrets allowUntil then the build refuses to execute them.
The cache is gitignored by default. To share secrets across machines or
teammates, commit them encrypted — no separate key ceremony, your SSH key
is your encryption identity (via ssh-to-age; ed25519 only).
# one-time: derive an age key from ~/.ssh/id_ed25519 and write .sops.yaml
lo secrets init # needs `sops` + `ssh-to-age` (b install)
lo secrets --domain app.example.com encrypt # write Secret.*.enc for committing
git add clusters/app.example.com/secrets/*.enc # each store's .gitignore commits only *.enc
# on another machine / for a teammate whose age key is in .sops.yaml:
lo secrets --domain app.example.com decrypt # restore plaintext from the .enc filesAdd teammates by putting their age public keys (derived from their SSH keys)
into .sops.yaml's creation_rules, then re-encrypt. Scope those rules by
path to give each environment different recipients — see below.
The per-domain store isn't just organisation — it's the security boundary. A repo driving more than one instance (dev and prod, or several tenants) must never let them share a store:
- No silent sharing. Cache keys are
Secret.<name>.<ns>.<key>with no environment in them, so a single flat store hands dev and prod the same value for a colliding (name, namespace) — including the same generated password or master key, which nobody chose. Per-domain stores make that impossible. - No shared tier, on purpose. There is no fallback from a domain's store to a shared one. A value genuinely needed in two instances is a deliberate manual copy — the operator consenting to that exposure — never something a default did quietly. Prefer issuing a separate credential per instance: most providers can (a per-instance registry robot account, a project-scoped API token, …).
- The real boundary is the decrypt key, not the folder. Folders alone are
cosmetic if one age key decrypts everything. Scope SOPS
creation_rulesby path so each environment encrypts to its own recipients — then a dev/CI key cannot decrypt prod:
# .sops.yaml — first match wins, so specific rules go BEFORE any catch-all
creation_rules:
- path_regex: clusters/kubehz\.cloud/secrets/Secret\..*
age: 'age1prod…' # prod-key holders only
- path_regex: clusters/.*/secrets/Secret\..*
age: 'age1dev…,age1prod…' # dev/CI (+ prod, who may read everything)Per-domain is opt-in, so existing flat stores keep working until you split them:
mkdir -p clusters/<domain>/secrets
git mv .secrets/Secret.<…>* clusters/<domain>/secrets/ # the keys that domain owns
lo secrets --domain <domain> encrypt # re-encrypt in placeAnything two instances were sharing must be re-issued per instance (or, if
truly unavoidable, copied deliberately). The cleanest reset is to regenerate at
go-live: create the per-domain stores, drop the old flat cache, scope the
.sops.yaml rules to per-environment keys, and let the next build mint fresh,
isolated values.
lo lint helps enforce the split: it warns when a Secret.* cache entry exists
in both a domain's store and the flat .secrets/ store — a deprecated
shadow. Identical copies are a stale duplicate to delete; differing copies
are active drift — different tools then read different stores, which can re-key a
live cluster from the wrong one. Keep domain secrets in the per-domain store
only; leave in flat .secrets/ just the global, non-domain material (a shared
registry/expose TLS cert).
Mount the Secret as a file rather than injecting it via an env var — a
mounted file isn't exposed through /proc, crash dumps, or child processes, and
rotates without a pod restart. The mounted file contains exactly the generated
bytes.
For secrets whose silent rotation would orphan stateful data — an encryption
masterkey, a database password, a signing key — add immutable: true to the
spec. It passes through to the k8s Secret's native immutable field (stable
since 1.21), so any apply that would change the live value is rejected by the
apiserver instead of quietly re-keying a running system. Rotation then requires
an explicit delete + re-apply. See
Sealing a secret for
the full semantics and trade-offs.
The cert: generator
signs development leaf certs (the app wildcard, registry TLS) with a local CA at
$CAROOT (default ~/.local/share/mkcert), created on first use. Nothing trusts
that CA until you install it into your system + browser trust stores — once per
machine:
lo trust # wraps `mkcert -install` (needs mkcert: b install mkcert)lo trust is the only step that needs the mkcert binary — minting never
does. After it, browsers accept https://*.<domain> and the host Docker daemon
accepts pushes to TLS registries. lo doctor reports whether the CA is trusted.
lo secrets --domain <domain> list # what's in that domain's store
lo secrets --domain <domain> print [pattern] # show value(s)
lo secrets --domain <domain> path # the resolved store path for the context(Omit --domain to act on the flat $PATH_SECRETS store — single-instance
projects, or anything not scoped to a domain.)
- Kustomize Plugins → Secrets Generator — every generator type, charsets, cryptographic-key guidance.