Vault
Secrets, API keys, and config — declared once as typed contracts, resolved from a real vault in production, never leaked into logs.
Vault is where your app's protected knowledge lives: API keys, database URLs, tokens — plus non-secret config like a public app URL. This page shows how to declare a secret, give it a value, and read it in a Flow, then how OKE keeps it safe in production.
The one rule
You declare contracts, never values, in code. Values come from the environment at boot — a secret that has no value anywhere fails the boot loudly, with every gap listed at once.
Quick start
Declare the contract
In src/vault.ts, declare what your app needs. This says "a secret named STRIPE_KEY exists" — it does not contain the key itself:
import { vault } from "okengine";
import { z } from "zod";
export const stripeKey = vault.secret("STRIPE_KEY", {
description: "Payments gateway key",
rotate: "90d",
schema: z.string().startsWith("sk_"),
dev: "sk_test_local", // used only by `oke dev` (local) when nothing else provides a value
});Give it a value
For local development, put the real value in .env.local (gitignored):
STRIPE_KEY=sk_test_your_key_hereOr set it from the CLI — it writes to the right place for the active mode:
oke vault set STRIPE_KEY sk_test_your_key_hereRead it in a Flow
Flows read secrets through fx.vault — never through process.env:
export const charge = on(
http.post("/charge"),
flow({
in: ChargeInput,
out: z.object({ id: z.string() }),
do: async (input, fx) => {
const key = fx.vault(stripeKey); // cleartext, inside this flow only
const intent = await stripe(key).create(input);
return { id: intent.id };
},
}),
);That's the whole loop: declare → set → read. Everything below is what OKE guarantees on top of it.
Two kinds of contracts
| Declaration | Sensitive? | Console shows | Use for |
|---|---|---|---|
vault.secret("STRIPE_KEY", …) | yes | fingerprint only | API keys, tokens, passwords |
vault.config("PUBLIC_APP_URL", …) | no | cleartext value | Public URLs, feature flags |
vault(name, …) is shorthand for vault.secret(name, …).
Contract options
| Option | Type | Meaning |
|---|---|---|
description | string | Shown in boot-gap listings and the Console |
rotate | string | Rotation hint ("90d") — drives the rotation-due signal in the Console |
schema | zod / Standard Schema | Validated at boot; a bad value fails boot like a missing one |
dev | string | Local-only fallback when no source provides a value (never used in prod) |
sensitive | boolean | Override the default (true for secrets, false for config) |
Zero-setup local fallback
dev: vault.fromDocker("store.sql") resolves the connection URL that oke dev --docker generated for that role — no manual copying of Postgres URLs:
export const dbUrl = vault.secret("DATABASE_URL", {
dev: vault.fromDocker("store.sql"),
});Where values come from
At boot, each contract is resolved through this chain — first hit wins:
| # | Source | Typical content |
|---|---|---|
| 1 | process.env | Real environment (CI, hosting platform) |
| 2 | .env.local | Your machine's local overrides (gitignored) |
| 3 | docker/.env.docker | Generated compose stack credentials |
| 4 | vault driver | OpenBao in docker/prod mode |
| 5 | dev fallback | The dev: option on the contract (never in prod) |
If every layer misses a contract, boot fails before any request is served:
VaultBootError: 1 secret(s) missing:
STRIPE_KEY — Payments gateway keyGood to know
This is the fail-loud contract: an app with a missing secret never boots halfway and fails later at request time. All gaps are listed in one error, not one at a time.
Setting and rotating values
| Command | What it does |
|---|---|
oke vault set NAME value | Write a value (OpenBao API in docker mode, .env.local locally) |
oke vault list | List names present in the active store |
oke vault import <file> | Bulk-import names from a dotenv file |
In docker mode, oke vault set writes straight to the running OpenBao — no git commit, no rebuild, no restart of the vault.
The Console (:6533) can also set and rotate values, but it is write-only: it shows a salted fingerprint (sha256:…) per secret, never the cleartext. When you rotate a key there, the panel shows the blast radius — which in-flight durable runs will wake up with the new value.
Secrets never reach logs
Every loaded secret is registered with the redactor at boot. Even if you accidentally pass one to
fx.log, the log line shows *** — the value, never. Traces and the Console get fingerprints.
Per-environment drivers
Which backend holds your values depends on the mode — configured once in oke.config.ts:
vault: {
local: "dotenv", // .env.local — zero ceremony
docker: "openbao", // real vault in the compose stack
test: "memory", // seeded map — deterministic tests
prod: "openbao", // real vault in production
},| Driver | Backend | Best for |
|---|---|---|
dotenv | .env.local | Local loop — no infra at all |
openbao | OpenBao (KV v2) | Docker + prod — durable, access-controlled |
memory | in-process map | Tests |
Other drivers (infisical, managed) implement the same VaultDriver interface — adding one is a driver exercise, not an architecture change.
OpenBao in docker and prod
When you run oke dev --docker (or bring up a --prod stack), OKE runs a real OpenBao container with durable Raft storage — not an in-memory dev server.
First boot is automatic
OKE initializes OpenBao (Shamir 1-of-1), unseals it, writes a least-privilege policy that covers only your declared secrets, and mints an app token bound to that policy. You never run raw bao commands.
Keys stay on the host
The unseal key and root token are written to .oke/openbao/ (mode 0600, gitignored) — never into docker/.env.docker, never into YAML. Only the least-privilege app token lands in .env.docker as OKE_VAULT_TOKEN.
Restarts just work
On every later start OKE detects the sealed vault and unseals it with the host key. Secrets set before a container restart are still there after it — proven by an integration test, not assumed.
| File | Holds | Used by |
|---|---|---|
.oke/openbao/unseal.key | The single unseal share | oke (every start) |
.oke/openbao/root.token | Root token | oke (policy sync / token mint) |
.oke/openbao/app.token | Least-privilege app token | the app + Console |
Single point of failure — read this once
Back up .oke/openbao/unseal.key to a separate safe location. Losing it means losing every secret
permanently, with no recovery.
This is by design: self-hosted single-node, Shamir 1-of-1, no cloud KMS. If OpenBao is initialized but the key is gone, boot fails with an explicit permanent-loss error — it never silently starts empty.
Troubleshooting
A contract has no value in any layer of the chain. Fix by providing it in exactly one place:
- locally: add
NAME=valueto.env.local(or runoke vault set NAME value) - docker mode:
oke vault set NAME valuewrites to OpenBao directly - CI / hosting: set a real environment variable
Re-run oke dev --docker — the bootstrap unseals with the host key on every start. If it reports the vault is initialized but .oke/openbao/unseal.key is missing, restore the key from your backup; the error is explicit because starting empty would look like "no secrets" instead of data loss.
.oke/openbao/root.token, mode 0600, gitignored. It exists only for oke to sync policies and mint app tokens — the running app never sees it.
Set the new value (oke vault set or the Console). The Console's rotation view shows the blast radius — in-flight durable runs that will resume with the new value — before you commit. The rotate: "90d" hint on the contract drives the rotation-due signal.
Learn more
- Console · Vault — fingerprints, resolution chain, rotation blast radius
- Flow — how
fx.vaultreads secrets insidedo - CLI Reference —
oke vault set·list·import