🪝 yoink

Sealed secrets workflow

Generate an AGE keypair, seal values into the repo, consume them at deploy time, and rotate or share across environments.

Operator tasks for provider: age: generating an identity, sealing values, consuming them, splitting staging/prod, backups, rotation. For the mental model (asymmetric keypair, threat model), see the Secrets guide.

One-time setup

If you ran yoink init, skip to Sealing valuesinit already generated an identity at ~/.config/yoink/keys/<recipient>.key and added the secrets: block to your yoink.yaml.

Manual flow:

Generate a keypair

yoink secrets key generate

Writes a fresh identity to ~/.config/yoink/keys/<recipient>.key (mode 0600) and prints the matching recipient (the age1… public half). Yoink discovers the key automatically on seal/unseal with no env var and no per-project gitignore. Multiple projects with different identities coexist in the dir; the filename is the recipient, so yoink picks the right one per yoink.yaml.

Back the key up. Lose it and every value sealed against it is unrecoverable. Pick at least one:

  • Password manager: paste from cat ~/.config/yoink/keys/<recipient>.key into 1Password / Bitwarden / Keychain.
  • Encrypted backup: cp ~/.config/yoink/keys/<recipient>.key ~/Backups/.
  • Teammate handoff: add their age1… recipient to yoink.yaml so a second identity can also unseal.

Add the recipient to yoink.yaml

secrets:
  provider: age
  recipients:
    - age1w8jcq22re378p38nxrudmjqdkyh42cyzsge7snwzqxlzyqt7fgkqmmvy45

The recipient is the public half, safe to commit. yoink secrets key public re-derives it from your current identity.

Sealing values below creates secrets.age; commit it.

Routing the identity to a managed store

To put the identity somewhere other than the laptop's keys dir (GitHub Actions secret, 1Password, AWS Secrets Manager, macOS Keychain), pass --print — secret goes to stdout, header/recipient/instructions go to stderr:

# GitHub Actions
yoink secrets key generate --print | gh secret set YOINK_AGE_KEY --repo you/your-repo

# 1Password
yoink secrets key generate --print | op item create --category=password \
  --title='yoink: your-repo' --vault=Engineering password=-

# AWS Secrets Manager
yoink secrets key generate --print | aws secretsmanager create-secret \
  --name yoink/your-repo --secret-string file:///dev/stdin

# macOS Keychain
yoink secrets key generate --print | security add-generic-password \
  -s yoink-your-repo -a $USER -w

The pipe captures only the AGE-SECRET-KEY-1… line. The recipient prints to stderr; copy that into yoink.yaml.

Don't run key generate --print bare and copy-paste. Shell scrollback, iTerm shared sessions, and tmux capture-pane history retain the identity. The default (no --print) writes to disk and never touches stdout. Use --print only when piping into a store.

--out PATH writes to a specific file (mode 0600), for a project-local keyfile alongside yoink.yaml.

For a complete CI walkthrough see AGE secrets in GitHub Actions.

Sealing values

yoink secrets edit

Decrypts the current secrets.age (or starts a fresh one if it doesn't exist), opens it in $EDITOR as a plain dotenv:

DATABASE_URL=postgres://user:[email protected]/app
JWT_SIGNING_KEY=...
GHCR_TOKEN=ghp_...

Save and quit. Yoink validates the dotenv, re-seals against the recipients, and writes the result to secrets.age atomically. Commit + push.

One-shot alternative (piping in from elsewhere):

echo "FOO=bar" | yoink secrets seal
yoink secrets seal --in plain.env
yoink secrets seal --as DEPLOY_KEY=@/tmp/deploy_key   # single key from a file
yoink secrets seal --as YOINK_DOCS_HOST=5.75.123.45 \
                   --as [email protected] \
                   --as [email protected]        # multiple in one shot

secrets seal merges into the existing bundle by default: it adds new keys, updates any that already exist, and leaves the rest alone. Output reports added / updated / preserved counts. To wholesale-rewrite the bundle (rare), pass --replace; if that would drop existing keys, you'll be prompted to confirm (skip with --yes).

For per-key tweaks during incident response, the TUI's secrets pane (e from any view) lets you view / add / edit / remove individual keys without leaving the dashboard. Bulk multi-line edits stay on yoink secrets edit; the TUI is per-key only.

Using the values

Reference each key by name from a service:

services:
  - name: api
    image: ghcr.io/you/api
    secrets: [DATABASE_URL, JWT_SIGNING_KEY]
    env_from_secrets:
      OTEL_EXPORTER_OTLP_HEADERS: GRAFANA_AUTH_HEADER

Yoink resolves the values from the sealed bundle and injects them as env vars. The values feed into spec_hash, so rotating a secret triggers a redeploy like a config change. Editing any key in secrets.age rerolls every service that references any secret; yoink up --dry-run shows the diff first.

Loading into a local dev shell

source <(yoink secrets env)                  # exports every key into the current shell
eval "$(yoink secrets env)"                  # equivalent for shells without process substitution
yoink secrets env --no-export > .env.local   # bare KEY='value' for `docker run --env-file` etc.

Values are POSIX single-quote-escaped so $, embedded quotes, and newlines round-trip literally — a JWT_SIGNING_KEY containing $ won't get re-expanded by the shell. Refuses to run when $CI / $GITHUB_ACTIONS / etc. are set unless YOINK_ALLOW_REVEAL_IN_CI=1; this is meant for laptops, not build logs.

Use the bundle from Terraform / CI

A profile in yoink.yaml captures the env shape one downstream tool wants. The same recipe drives both an operator shell and a yoink pre-deploy hook, so the bundle-key → env-var-name mapping lives next to the bundle that supplies the values instead of being copy-pasted into N Taskfiles + N CI workflows.

# deploy-prod/yoink.yaml
secrets:
  provider: age
  recipients: [age1…]
  profiles:
    terraform-cloudflare:
      include: [CLOUDFLARE_API_TOKEN,
                TFSTATE_B2_KEY_ID, TFSTATE_B2_APPLICATION_KEY]
      rename:
        TFSTATE_B2_KEY_ID:          AWS_ACCESS_KEY_ID
        TFSTATE_B2_APPLICATION_KEY: AWS_SECRET_ACCESS_KEY
      unset: [B2_ENDPOINT, B2_BUCKET_NAME]

include, rename, unset compose. Schema details: Secrets profiles.

From a Taskfile

env:
  YOINK_TF:
    sh: cd ../../deploy-prod && yoink secrets env --profile terraform-cloudflare

tasks:
  tf:plan:
    cmds:
      - eval "$YOINK_TF" && terraform plan

The single yoink secrets env --profile … invocation replaces every per-key sh: bash -c '… eval … printf "$X"' extractor block.

From a GitHub Actions workflow

- run: |
    eval "$(cd deploy-prod && ../yoink-bin/yoink secrets env --profile terraform-cloudflare)"
    {
      echo "CLOUDFLARE_API_TOKEN=$CLOUDFLARE_API_TOKEN"
      echo "AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID"
      echo "AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY"
    } >> "$GITHUB_ENV"

When $GITHUB_ENV is set in the runner, yoink auto-prepends echo '::add-mask::<value>' for every revealed value before the exports — no per-key copy-paste, no "I forgot to mask the new key" failure mode.

From a yoink pre-deploy hook

A pre-deploy hook with secrets_profile: <NAME> runs the same recipe as part of every yoink up. Subprocess hooks (no image:/tag:) invoke cmd[0] directly on the operator's machine and inherit the parent env, so the profile's unset: list applies just like in an operator shell. See Run Terraform from a hook for the worked example.

Multiple environments (staging / prod)

To keep staging and prod values separate so a leaked staging key can't unlock prod:

yoink.staging.yaml   secrets.staging.age   YOINK_AGE_KEY (in staging CI)
yoink.prod.yaml      secrets.prod.age      YOINK_AGE_KEY (in prod CI)

Each yaml carries its own secrets.recipients: and secrets.file:. The staging CI workflow only has the staging identity; prod runs with the prod identity. A compromised staging identity can't decrypt the prod file; they use different recipients.

If staging and prod share values (same image, different DATABASE_URL only), put both recipients on one sealed file: either identity decrypts, deploys carry env-specific overrides via --tag or per-host yaml. Trade-off: compromise of either identity reveals both environments.

Backup and recovery

The sealed file is in git. The identity is the irreplaceable part.

  • Two stores that can't fail together. Primary in your secret manager; recovery offline (sealed envelope, encrypted USB, second manager under a different account). Don't co-locate under one SSO.
  • Test recovery quarterly. Decrypt secrets.age on a clean machine using only the recovery key.
  • Rotation isn't a backup. It swaps the active identity; it doesn't help if the current one is already gone. If both copies are lost, re-seal from scratch from upstream sources (Stripe dashboard, AWS console, manager CLIs).

Planned key rotation

yoink secrets rotate

Generates a new identity, re-seals secrets.age against [existing recipients + new public key], and prints the new private + public for updating CI / your secret manager. Both keys decrypt during the transition, so there's no deploy outage.

When is it safe to drop the old recipient? After at least one successful yoink up has run with only the new private key in YOINK_AGE_KEY. Check the deploy log: a run that reads secrets.age and reconciles without "age decrypt failed" confirms every consumer is on the new key. A premature drop bricks any operator or CI runner still on the old key.

Then remove the old recipient from yoink.yaml, run yoink secrets edit (save without changes) to drop it from the sealed file, and only after that wipe the old YOINK_AGE_KEY from your manager.

Manual flow:

Generate a new identity

yoink secrets key generate

Writes to ~/.config/yoink/keys/<new-recipient>.key and prints the recipient.

Add the new recipient alongside the old one

secrets:
  recipients:
    - age1...old
    - age1...new

Re-seal against both

yoink secrets edit   # save without changes
git commit -am 'rotate age recipient'

Update CI to the new private key

gh secret set YOINK_AGE_KEY (or the equivalent for your store) with the new identity. Use --print for a clean pipe.

Run a deploy with only the new key

Confirm it succeeds end-to-end before dropping the old one.

Drop the old recipient

Remove from secrets.recipients:, re-seal one more time, commit, then delete the old key from your manager.

Compromised key: emergency rotation

If YOINK_AGE_KEY leaks (Slack paste, workflow log, stolen unlocked laptop, terminated employee's password manager), rotate today. Same flow as planned rotation, different urgency and cleanup.

The leaked key decrypts every value sealed against it for as long as the attacker has the repo:

Generate a new identity, re-seal, ship

Run yoink secrets rotate (or the manual flow above). From this point only the new identity decrypts new revisions.

Rotate every value in the bundle, not just the key

A leaked age key equals every secret it ever decrypted. The attacker has secrets.age and decrypts it freely. Roll the database password, regenerate the JWT signing key, rotate the GHCR token, re-seal the new values. Yoink's rotation swaps the wrapping key, not the contents; run your "secret X leaked" checklist separately.

Revoke cached copies of the old key

GitHub Actions secret: delete and replace. 1Password / vault entries: delete the old version (some managers retain edit history). Laptop key files: srm / shred.

Audit the deploy log

git log secrets.age shows file edits; gh run list --workflow=deploy.yml shows deploys with the key. Unknown deploys in the leak window mean the host is suspect.

See also

On this page