AGE secrets in GitHub Actions
Generate a CI-only AGE identity, paste it into a GitHub secret, and deploy from Actions with a single environment variable.
How to use age-sealed secrets from a CI workflow. The whole integration is one env var.
One-paragraph refresher. age uses an asymmetric keypair. The recipient (
age1…, public) goes inyoink.yamland seals new values. The identity (AGE-SECRET-KEY-1…, private) goes in your secret manager and unseals at deploy time. CI gets its own pair: a fresh identity it owns and never shares, plus the matching recipient added alongside the laptop recipient(s) inyoink.yamlso either can decrypt. See the secrets guide for the full mental model.
Prerequisites
You've already set up sealed secrets locally; see Sealed secrets (age) if not. Specifically:
secrets.ageis committed to the repo (encrypted)yoink.yamlhas asecrets.recipients:list with at least your laptop's recipient- Your laptop's identity is saved somewhere readable to you (default:
~/.config/yoink/keys/<recipient>.key, written byyoink secrets key generate)
Setup
Generate a CI-only identity and ship it to GitHub
Each principal that needs to decrypt (your laptop, CI, a teammate's laptop) gets its own age keypair. Don't reuse the laptop pair for CI; if a CI runner is ever compromised you want to revoke its identity without disturbing day-to-day operator workflow.
One command does the generate + ship:
yoink secrets key generate --print | gh secret set YOINK_AGE_KEY --repo you/your-repo--print sends the secret to stdout; the header, recipient, and follow-up notes go to stderr. The pipe captures only the AGE-SECRET-KEY-1… line, so gh secret set gets a clean key, and you still see the recipient (age1…) on your terminal so you can copy it into yoink.yaml in the next step.
If you'd rather paste manually: run without the pipe, copy the secret out, then Repo Settings → Secrets and variables → Actions → New repository secret named YOINK_AGE_KEY. Clear your scrollback (reset) when done; bare key generate --print leaves the identity in your terminal.
Add the CI recipient to yoink.yaml
recipients: is a list. Add the new CI recipient alongside the existing laptop recipient; don't replace it, or you'll lock yourself out of the file you just sealed. Either identity decrypts the same secrets.age; that's the whole reason recipients is a list.
secrets:
provider: age
recipients:
- age1...your-laptop-recipient # already there
- age1w8jcq22re378p38nxrudmjqdkyh42cyzsge7snwzqxlzyqt7fgkqmmvy45 # CIRe-seal so both identities can decrypt:
yoink secrets edit # save without changes — picks up the new recipient
git add yoink.yaml secrets.age
git commit -m "chore: add CI to age recipients"
git pushWire it into the workflow
# .github/workflows/deploy.yml [step]
name: Deploy
on:
push:
branches: [live]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install yoink
run: |
curl -LsSf https://github.com/oddur/yoink/releases/latest/download/yoink-installer.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Deploy
run: yoink up --tag api=${{ github.sha }} --tag web=${{ github.sha }}
env:
YOINK_AGE_KEY: ${{ secrets.YOINK_AGE_KEY }}Yoink finds YOINK_AGE_KEY in the env, decrypts secrets.age on the runner, and uses the values exactly like a laptop deploy would.
How yoink finds the key
Same code path locally and in CI; yoink tries each source in order, stops at the first match:
| Order | Source | When |
|---|---|---|
| 1 | YOINK_AGE_KEY env (raw key) | CI |
| 2 | YOINK_AGE_KEY_FILE env (path) | Explicit override |
| 3 | ~/.config/yoink/keys/*.key | Laptop default; picks the key whose public matches secrets.recipients: |
| 4 | ~/.config/yoink/age.key | Legacy fallback |
If none match, yoink errors with a pointer to yoink secrets key generate.
Rotating the CI key
When you want to roll the CI identity (left the company, suspected leak, periodic hygiene):
Run yoink secrets rotate
Generates a new identity, re-seals secrets.age against [old recipients + new recipient], and prints the new private + public key. After this step yoink.yaml and secrets.age are already updated locally; commit them.
yoink secrets rotateUpdate the YOINK_AGE_KEY GitHub secret
To the new private key the rotate command printed.
Verify CI decrypts with the new key
Push and watch a deploy run. The transitional state has both identities able to decrypt, so a stale runner doesn't break the deploy during the swap.
Drop the old recipient
Once CI is decrypting fine, remove the old recipient from yoink.yaml and run yoink secrets edit (save without changes) to re-seal against the new recipient only.
The transitional state where both keys can decrypt prevents a deploy outage during the swap.
Checking what's sealed (without revealing values)
In CI debug, useful to confirm yoink can read what you expect:
- name: List sealed keys
run: yoink secrets show # masked by default
env:
YOINK_AGE_KEY: ${{ secrets.YOINK_AGE_KEY }}show --reveal prints actual values. Don't use --reveal in CI: workflow logs are visible to anyone with read access to the repo. (Yoink also refuses to do this when $CI is set; override with YOINK_ALLOW_REVEAL_IN_CI=1 if you really mean it, e.g. when piping into a different secret store rather than into build logs.)
Two axes of rotation
Recipients (who can decrypt) and values (what's stored) are independent, an important distinction during incident response. Editing yoink.yaml recipients: rotates who can decrypt; editing secrets.age via yoink secrets edit rotates what's stored. A leaked CI identity needs both: revoke the recipient (so the leaked identity can't decrypt new edits) and rotate every value (because the attacker has a copy of the old secrets.age and the leaked identity already decrypts it).
Failure modes
age decryption failed: no matching key: CI's secret doesn't match any recipient insecrets.age. Either the GitHub secret wasn't updated after a rotation, or you forgot to re-seal after adding the new recipient. Runyoink secrets showlocally with the same key to confirm.no age identity found: the workflow forgot theenv: YOINK_AGE_KEY:block on the deploy step.workflow logs leak the values: never useyoink secrets show --revealin CI; neverecho $DATABASE_URLin a step. GitHub auto-masks values that match registered secrets, but yoink-decrypted plaintext isn't registered.
See also
- Secrets guide — full mental model for the keypair shape and the
provider: commandescape hatch when GitHub Secrets isn't where your secrets live. - Pre-merge dry-run on every PR — same
YOINK_AGE_KEYsetup, applied to PR-time validation.