Skip to content

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

PrincipleMeaning
.dev.vars = local onlyPlain .dev.vars is for wrangler dev; vault seeding uses {ownerDir}/.dev.vars.{env}
Vault is source of recordReadable encrypted store (tamer-secrets); Worker secrets are write-only sinks
Names in config, values in vaultDeclarations list names only — never put secret material in config or state
Master key stays off CloudflareTAMER_SECRETS_KEY_{env} lives in CI + password manager only; Tamer never writes it to D1 or state
Single front doorUse 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}
SegmentWhere it comes fromExample
{env}--env dev (CLI flag)dev
{kind}which block you declared it inworker (under workers) or wfp (under wfp.namespaces)
{stack}config.stackaat
{ownerKey}the worker key, or {namespace}-{template} for WFPapi / 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_SECRET

Two 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.workerName convention or a scriptName override does not reshape the vault. Every character traces to a config value or the --env flag, 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:

bash
tamer bootstrap

Or provision only the vault + generate a master key:

bash
tamer secrets init --env dev

init 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:

bash
tamer secrets init --env dev   # or: openssl rand -base64 32
bash
TAMER_SECRETS_KEY_dev=<base64-key>    # dev / staging / pr previews
TAMER_SECRETS_KEY_prod=<different-key> # prod — different key, different machines

Store 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:

ts
workers: {
  api: {
    main: "workers/api/src/index.ts",
    secrets: { required: ["STRIPE_KEY", "JWT_PRIVATE_KEY"] },
  },
},

WFP service template:

ts
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

FileUse
.dev.varsLocal onlywrangler 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:

bash
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 dev

Read-back (get) requires confirmation and writes an audit row:

bash
tamer secrets get STRIPE_KEY --target worker:api --env dev
tamer secrets get STRIPE_KEY --target worker:api --env dev --yes   # non-interactive

Remove: 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:

bash
tamer secrets copy --from dev --to pr-42

Copied 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:

StatusMeaning
in syncVault hash matches last push; script has the secret
declared, no vault valueIn config but not in vault — set or load
never deployedIn vault, never pushed — run push or deploy
rotated, not deployedVault changed since last push — run push or deploy
removed from vaultState remembers a push but vault row gone
not in secrets.requiredOn 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

yaml
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 code

Deploy 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:

bash
# Once per env, after upgrading to 0.54:
tamer secrets migrate --env dev

migrate 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 rm if 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

EnvMaster key varCustody
devTAMER_SECRETS_KEY_devDev laptops + dev CI secret
stagingTAMER_SECRETS_KEY_stagingStaging CI only
prodTAMER_SECRETS_KEY_prodProd CI + password manager — not on dev machines

Prod ciphertext is useless without the prod master key even if someone dumps D1.

Released under the Tamer Evaluation License.