Tamer secrets vault — operator guide
How to manage Worker secrets with tamer secrets — encrypted vault on Cloudflare D1, classic Worker secrets at deploy time.
Audience: engineers operating stacks that declare secrets in tamer/project.config.ts (workers.<key>.secrets.required and/or wfp.namespaces.<ns>.workers.<tmpl>.secrets).
North star
| Principle | Meaning |
|---|---|
.dev.vars = local only | Plain .dev.vars is for wrangler dev; vault seeding uses {ownerDir}/.dev.vars.{env} |
| Vault is source of record | Readable encrypted store (tamer-secrets); Worker secrets are write-only sinks |
| Names in config, values in vault | Declarations list names only — never put secret material in config or state |
| Master key stays off Cloudflare | TAMER_SECRETS_KEY_{env} lives in CI + password manager only; Tamer never writes it to D1 or state |
| Single front door | Use tamer secrets / tamer deploy — not wrangler secret put or the dashboard for managed secrets |
How Tamer addresses a secret (owner scoping)
A secret lives in the vault at a key built only from things you type in defineConfig plus the runtime --env flag:
{env}:{kind}:{stack}-{ownerKey}:{secretName}| Segment | Where it comes from | Example |
|---|---|---|
{env} | --env dev (CLI flag) | dev |
{kind} | which block you declared it in | worker (under workers) or wfp (under wfp.namespaces) |
{stack} | config.stack | aat |
{ownerKey} | the worker key, or {namespace}-{template} for WFP | api / platform-api |
{secretName} | the entry in secrets.required[] / secrets[] | ACCESS_JWT_SECRET |
dev:worker:aat-api:ACCESS_JWT_SECRET
dev:wfp:aat-platform-api:ACCESS_JWT_SECRETTwo important properties fall out of this:
- The scope is the deployable-script unit. An account worker (
workers.api) and a WFP service template (wfp.namespaces.<ns>.workers.<tmpl>) are each "the thing that becomes a script," so each owns and scopes its secrets. Two owners that happen to share a name get independent values — no silent sharing, ever. - The key never touches the naming engine. A custom
naming.workerNameconvention or ascriptNameoverride does not reshape the vault. Every character traces to a config value or the--envflag, so it is documentable and survives naming changes.
env is the one segment not in the config body. That is deliberate: a single config covers dev/staging/prod/feature-X uniformly, which is what makes ephemeral environments a one-flag deploy.
Selecting an owner: --target
Every secrets subcommand that addresses one secret takes --target <kind>:<owner>:
| You declared the secret on… | --target value |
|---|---|
workers.api | --target worker:api |
wfp.namespaces.platform.workers.svc | --target wfp:platform/svc |
--worker <key> is kept as shorthand for --target worker:<key> (account workers are the common case). Bulk commands (load/verify/push) with no --target cover every declared owner — account workers and WFP templates.
One-time setup per env
1. Provision the vault
bootstrap creates tamer-secrets alongside tamer-state and tamer-artifacts:
tamer bootstrapOr provision only the vault + generate a master key:
tamer secrets init --env devinit prints the master key once. Store it immediately — Tamer never persists or re-displays it.
2. Generate and store the master key
One 256-bit key per env:
tamer secrets init --env dev # or: openssl rand -base64 32TAMER_SECRETS_KEY_dev=<base64-key> # dev / staging / pr previews
TAMER_SECRETS_KEY_prod=<different-key> # prod — different key, different machinesStore in two durable places: GitHub Actions (repo/environment secret TAMER_SECRETS_KEY_dev, TAMER_SECRETS_KEY_prod, …) and a password manager for break-glass recovery. Never put the master key on Cloudflare.
3. Declare required secrets in config
Account worker:
workers: {
api: {
main: "workers/api/src/index.ts",
secrets: { required: ["STRIPE_KEY", "JWT_PRIVATE_KEY"] },
},
},WFP service template:
wfp: {
namespaces: {
platform: {
workers: {
api: {
main: "apps/tenant-api/src/index.ts",
secrets: ["STRIPE_KEY"], // pushed to every tenant dispatch script of this template
},
},
},
},
},Secret files
| File | Use |
|---|---|
.dev.vars | Local only — wrangler dev; never tamer secrets load |
{ownerDir}/.dev.vars.{env} | Vault seed — tamer secrets load --env dev --target worker:api |
Commit a .dev_vars_example or .dev.vars.dev.example; keep real files gitignored.
Day-to-day flow
Export the master key for the target env, then:
export TAMER_SECRETS_KEY_dev=...
# Store values (stdin only — avoids shell history). --target is required.
echo -n "sk_live_..." | tamer secrets set STRIPE_KEY --target worker:api --env dev
echo -n "..." | tamer secrets set STRIPE_KEY --target wfp:platform/api --env dev
# Bulk import — ALL owners (account workers + WFP templates), each from its own .dev.vars.{env}
tamer secrets load --env dev
# Or one owner
tamer secrets load --env dev --target worker:api
# Inspect without values (owner column shown when no --target)
tamer secrets list --env dev
tamer secrets list --env dev --target wfp:platform/api
# Reconcile declared vs vault vs last-pushed vs script
tamer secrets verify --env dev
# Push stale secrets via CF API (account workers → worker script; WFP → every tenant script)
tamer secrets push --env dev
# Deploy auto-pushes stale/undeployed account-worker secrets before wrangler deploy
tamer deploy --env devRead-back (get) requires confirmation and writes an audit row:
tamer secrets get STRIPE_KEY --target worker:api --env dev
tamer secrets get STRIPE_KEY --target worker:api --env dev --yes # non-interactiveRemove: tamer secrets rm STRIPE_KEY --target worker:api --env dev
Copy between envs — duplicate the entire vault contents from one env to another without decrypting (ciphertext rows copied verbatim, scope preserved). No master key required, only CLOUDFLARE_* credentials:
tamer secrets copy --from dev --to pr-42Copied rows stay encrypted under the source env's master key, so the target env must use the source env's key: set TAMER_SECRETS_KEY_{target} to the same value as TAMER_SECRETS_KEY_{source}.
Key rotation
Envs seeded via copy stay bound to the source env's master key. If you rotate the source env's key, every env copied from it can no longer decrypt — re-copy (or re-set) after rotating.
WFP secrets
Secrets declared on a WFP template (wfp.namespaces.<ns>.workers.<tmpl>.secrets) are scoped to that template. At tamer wfp tenant provision, each declared secret is read from the vault and pushed to every provisioned tenant dispatch script for that template (all tenants run your code and share the same platform credentials). verify and drift reconcile WFP templates alongside account workers; secrets push re-pushes a template's stale secrets across all its tenants.
WFP "on script" presence is checked optimistically (declared names are treated as present). Rotation detection (vault hash ≠ last-pushed hash) still works via state.
Verify and plan/drift
tamer secrets verify prints one of:
| Status | Meaning |
|---|---|
in sync | Vault hash matches last push; script has the secret |
declared, no vault value | In config but not in vault — set or load |
never deployed | In vault, never pushed — run push or deploy |
rotated, not deployed | Vault changed since last push — run push or deploy |
removed from vault | State remembers a push but vault row gone |
not in secrets.required | On script but not declared — informational only |
Exit code: verify fails (exit 1) only when a declared secret is missing from the vault (declared, no vault value) or removed after a recorded push (removed from vault). Pre-deploy states are reported but do not fail.
plan and drift surface declared-secret reconciliation per owner (e.g. ~ secret wfp:platform/api:STRIPE_KEY rotated in vault, not deployed).
CI pattern
env:
TAMER_SECRETS_KEY_dev: ${{ secrets.TAMER_SECRETS_KEY_DEV }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
steps:
- run: tamer secrets verify --env dev
- run: tamer deploy --env dev # pushes stale account-worker secrets, then deploys codeDeploy fails if a declared secret is missing from the vault — fix with set/load before retrying.
Upgrading to 0.54 (owner-scoped vault)
Before 0.54 the vault keyed secrets by {env}:{secretName} only — so two workers, two WFP templates, or two stacks sharing a name silently shared one value. 0.54 adds the owner dimension. State migrates automatically (schema v8 → v9, deterministic). The vault is best-effort because old rows carry no owner information:
# Once per env, after upgrading to 0.54:
tamer secrets migrate --env devmigrate re-keys each legacy row to its owner-scoped key when exactly one declared owner in this stack's config claims the name. It leaves and lists:
- Unclaimed — not declared in this stack (belongs to another stack, or stale). Declare it in config and re-run, or
tamer secrets rmif stale. - Ambiguous — was shared across multiple owners pre-0.54. Resolve by
tamer secrets set ... --target <owner>for each owner.
The command is idempotent (owner-scoped rows are skipped on re-run). Run it once per stack config in multi-stack accounts — a row unclaimed by one stack may belong to another.
Per-env master keys
| Env | Master key var | Custody |
|---|---|---|
dev | TAMER_SECRETS_KEY_dev | Dev laptops + dev CI secret |
staging | TAMER_SECRETS_KEY_staging | Staging CI only |
prod | TAMER_SECRETS_KEY_prod | Prod CI + password manager — not on dev machines |
Prod ciphertext is useless without the prod master key even if someone dumps D1.
Related docs
- brownfield-adoption.md — bootstrap → sync → deploy for existing stacks
- quickstart.md — install and first deploy