Rotate and sync secrets across every place they live — Vercel, Cloud Run, GCP Secret Manager, Koyeb, GitHub Actions, MongoDB Atlas user passwords, and local
.envfiles — from a single values-free JSON config per project.
Two motivations, both increasingly underserved by existing "secrets management" tools.
1. Secrets live in N places, not one. Most tools assume one central vault with runtime fetch. Real personal infrastructure rarely looks like that: a Mongo URI lives in four places (Vercel env, GCP Secret Manager, Cloud Run env, local .env), a Mailjet key in three (Vercel, GitHub Actions, local), an e2e test token sometimes needs the same value across eight repos' Actions secrets. Manual rotation across all of them is a bug factory — miss one and prod stops working.
2. AI coding agents read .env. Claude Code, Cursor, Copilot, Codex — they crawl your repo for context and don't reliably tell .env.example apart from .env. The moment a real value lands in an LLM transcript, treat it as leaked: it might be in a remote log, in a chat history that gets shared, in a model's retained conversation. "Tell the agent not to read .env" is a soft constraint that drifts the moment context gets long. We need a structured way for the agent to look at the secrets inventory without ever reading values.
keyrotate answers both:
- For (1): declare where each secret physically lives in JSON.
secret rotate <project> KEYproduces a new value and pushes it to every declared sink in one command (sequentially; see the "no rollback" note below). No daemon, no vault, no SaaS — just bash + a thin wrapper around provider APIs you already have credentials for. - For (2): configs are values-free by design (project IDs, cluster hosts, target lists — no passwords or tokens, ever). Agents can read configs freely, and should call the CLI rather than touch
.env:secret list / notes / lsanswer "what exists where, and how do I rotate it?" without ever exposing a value;secret pullpopulates a local.env— for keys that have a localEnv sink — without putting the value on stdout. There's a matching Claude Code skill template that teaches an agent this protocol, and triggers emergency rotation if a value does end up exposed.
One command, N places. Above: re-pushing
JWT_SECRETfrom the ownings0phi3config lands the value in Vercel (prod + preview, auto-redeploy queued), locallogin/.env, and then viacrossProjectPropagateintodreamAtelierandinfo_hub— each getting their own GCP Secret Manager version and Cloud Run new-revision deploy. Every downstream verifier picks up the new value atomically; nothing manual, no drift across N repos.
A "target" is somewhere a secret value physically needs to be. On secret rotate / secret set, every declared target for a secret is updated sequentially in array order (no pre-flight, no transaction, no rollback — see Failure modes below):
| Target | Provider it talks to | What it does |
|---|---|---|
vercel |
Vercel REST API | DELETE + POST env var (sensitive by default), then trigger a production redeploy so running functions pick up the new value (Vercel env updates do not auto-redeploy on their own — functions hold the env snapshot from when they were last deployed). Opt out by setting vercel.autoRedeploy: false. |
gcpSecretManager |
gcloud CLI |
Add a new version to the named secret |
cloudRun |
gcloud CLI |
gcloud run services update --update-secrets KEY=name:latest per configured service |
koyeb |
Koyeb REST API | Upsert account-level secret (manual redeploy still required for it to take effect) |
github |
gh CLI |
gh secret set KEY --repo … for each repo in .github.repo / .github.repos |
userPassword |
Mongo + GitHub | Composite for test-user rotation: bcrypt → users.{username}.password in Mongo + plaintext → GitHub Actions repos |
ssh |
any SSH-reachable host | ssh user@host + cat > /path/to/secret + chmod 600. For NAS / VPS / Raspberry Pi / anywhere without a management API. Per-secret .ssh.path is required. |
localEnv |
filesystem | Rewrite the single KEY=... line in each configured .env (other lines preserved) |
Want a new target? See Contributing — each is ~50 lines of bash + curl.
| Strategy | Source |
|---|---|
atlas-mongodb |
PATCHes the Atlas user's password via Admin API, assembles a mongodb+srv://… URI (with optional dbName embedded in the path) |
random |
openssl rand → 48-char base62 by default; length + encoding: hex overrides |
manual |
You provide it: KEY=value shorthand, --value <V>, or --from-stdin |
The tool reads provider credentials via macOS Keychain (security find-generic-password). On Linux / WSL it'll error out the moment you try to use the atlas-mongodb strategy. vercel and koyeb targets do honor env-var fallbacks ($VERCEL_TOKEN, $KOYEB_TOKEN); github and gcloud targets use their own cross-platform CLI auth and work anywhere.
Porting credential lookup to libsecret (secret-tool) for Linux is roughly a 30-line change in bin/secret — PRs welcome. If you're not on macOS and don't want to port, you can stop reading here.
| Required | Purpose |
|---|---|
bash, jq, curl, openssl |
the script itself |
macOS security (Keychain) |
reading Atlas API key (other targets have env-var fallbacks) |
| Optional (only if you use that target / strategy) | Purpose |
|---|---|
node + npm |
install bcrypt + mongodb npm deps for the userPassword target |
gh (logged in) |
github target (gh secret set) |
gcloud (logged in) |
gcpSecretManager and cloudRun targets |
install.sh checks all of these and skips optional steps cleanly when their tool is missing.
Homebrew (recommended):
brew install sophie4869/tap/keyrotatePulls jq as a dep and recommends gh (needed only for the github + userPassword targets — every other target works without it). Post-install hints point at ~/.config/keyrotate/ and the macOS Keychain services each provider expects.
From source (if you want the install.sh bootstrap that also seeds ~/.config/keyrotate/ with an example config, or you're on Linux and stubbornly want to try):
git clone https://github.com/sophie4869/keyrotate ~/keyrotate
cd ~/keyrotate && bash install.shThat symlinks ~/bin/secret into ~/bin/, runs npm install for the userPassword helper if node+npm are present (otherwise skipped with a note — you can still use every other target), and seeds ~/.config/keyrotate/ with the example config to copy from.
secret rotate / secret set walks each targets[] entry in order. There is no pre-flight, no transaction, and no rollback — if Vercel succeeds and a later step fails, you'll have a half-rotated state where Vercel has the new value and the failed sink still serves the old one.
One exception, added deliberately: a Cloud Run service that fails to deploy no longer aborts the whole run. A single un-bootable container (e.g. a service that's been broken for days on an unrelated bug) used to kill propagation under set -e — silently starving every later target and every downstream crossProjectPropagate project of the new value. Now it logs a ⚠️ and continues, so one broken service can't hold the rest of the fleet hostage.
For shared JWT signing secrets, add postRotateCheck.type="jwt-auth-status" to the secret config. After rotate / set finishes propagation and any queued Vercel redeploys, keyrotate signs a short-lived HS256 probe token with the new value and calls every configured verifier URL (typically /auth/status). Any verifier rejection, non-2xx response, or network timeout exits red/non-zero after retries. This does not make propagation transactional, but it turns "Vercel accepted the new key while a downstream verifier still rejects it" into a loud failed run instead of silent drift.
secret rotate is not idempotent: every invocation generates a fresh value (Atlas PATCH + new password for atlas-mongodb; openssl rand for random). Re-running it after a partial failure won't push the existing new value to the remaining sinks — it'll mint another new value and try again.
Recovery recipe:
- Read the just-written value from any sink that already succeeded —
localEnvis the easiest (grep '^KEY=' <projectRoot>/<env_file>), and is usually listed first intargets[]for exactly this reason. For Vercel, the/v1/.../env/{id}?decrypt=trueendpoint returns the plaintext forencrypted(notsensitive) types;secret add/secret removealready use this same fallback chain internally if you want to see it in action. - Push that value to the remaining sinks with
secret set <project> KEY='<value-from-sink>'(orKEY --value '<v>', orKEY --from-stdin). This is the idempotent escape hatch —secret setonly propagates, it doesn't mint. - Re-running
secret rotateis fine too if you'd rather just rotate again from scratch — it's safe, just wasteful (extra Atlas PATCH / extra random gen) and you lose the in-flight value.
Adding pre-flight checks + ordered propagation retries is on the backlog.
You only need creds for the targets you actually use. The rest of this section assumes macOS Keychain (per the heads-up above); env-var alternatives are noted per provider.
First-time setup (create a single org-level key, reused across all your Atlas projects):
- Atlas → top-left org dropdown → Access Manager → tab Applications → API Keys → Create.
- Description: anything (e.g.
keyrotate). Org permission:Read Onlyis enough (you don't need to manage the org itself; the per-project Project-Owner grant in the next step is what authorizes the password PATCH). - Copy public + private halves into Keychain:
security add-generic-password -U -s atlas-api -a public -w '<PUBLIC_KEY>' security add-generic-password -U -s atlas-api -a private -w '<PRIVATE_KEY>'
Per Atlas project — even with an org-level key, each project needs the key explicitly added before keyrotate can see/PATCH users in it:
- Atlas → top-left project switcher → pick the project that hosts the cluster you want to rotate → left nav Access Manager → tab Applications → API Keys → Invite to Project → search for the org key by description → grant Project Owner.
- Repeat step 4 once for every Atlas project keyrotate needs to manage.
To verify the key sees a project, run curl --user "$PUB:$PRIV" --digest -H 'Accept: application/vnd.atlas.2024-08-05+json' https://cloud.mongodb.com/api/atlas/v2/groups | jq -r '.results[].name' — the names you see are the ones keyrotate can rotate users for. If a project name is missing here, the atlas-mongodb strategy will return ❌ Atlas HTTP 401 for any secret pointing into it.
(If you create the API key inside a project instead of at the org level, it's automatically a Project-Owner for that one project but invisible to all other projects. The org-level + per-project-grant pattern above scales better when you accumulate projects.)
- https://vercel.com/account/settings/tokens → Create Token.
- Scope: team if you want one token across all your team projects, account if personal-only. Expiration 1 year is reasonable.
-
security add-generic-password -U -s vercel-api -a token -w '<VERCEL_TOKEN>'
- https://app.koyeb.com/user/settings/api → Create new token.
-
security add-generic-password -U -s koyeb-api -a token -w '<KOYEB_TOKEN>'
Uses gh CLI auth — no separate token. Just gh auth login once.
Uses gcloud auth. gcloud auth login and make sure your account has roles/secretmanager.admin on the relevant project (gcloud projects add-iam-policy-binding …) and roles/run.developer for Cloud Run revision updates.
Open ~/.config/keyrotate/example.json (seeded by install.sh) — a worked example covering every strategy and target with _note strings explaining the trickier secrets. Copy it to <your-project>.json and edit. No field is auto-discovered — keyrotate doesn't crawl your repo or call provider APIs to populate the config. You write the JSON once; the tool reads it forever after. (Field-by-field documentation lives in SCHEMA.md, since .json files can't hold comments.)
The fields fall into two buckets:
Hand-written from things you already know (one-time setup, no real research):
projectRoot— absolute path of your local checkoutlocalEnv[].file— relative path inside that checkoutvercel.envs— usually["production", "preview"]github.repo/.repos—owner/repostringsatlas.dbName— the Mongo database name your app callsclient.db('…')withsecrets.<KEY>.targets— which sinks above to push to
Hand-written from a quick dashboard lookup (5 min per project):
vercel.projectId+vercel.orgId— Vercel dashboard → Project Settings → General → Project ID; Team Settings → General → Team IDgcpProject+gcpAccount—gcloud projects listandgcloud auth listcloudRun.services+.region—gcloud run services list --region <r>koyeb.appName+.serviceName— Koyeb dashboard orkoyeb apps listatlas.projectId— Atlas project URL/v2/<projectId>/...; or the APIGET /api/atlas/v2/groupslists allatlas.clusterHost— Atlas → cluster → Connect → copy thecluster0.xxxxx.mongodb.netpart of the connection stringatlas.dbUser+.authDb— the specific Atlas user this entry rotates. Required, no default. The convention in our examples isdbUser: "client"+authDb: "admin", but it can be any user that already exists in your Atlas project (keyrotate never creates users — it only PATCHes the password). For a cluster with multiple Atlas users (e.g. aclientread-write user and areadonlyuser for analytics), declare one secret entry per user:secret rotate myapp MONGODB_URIrotates only theclientpassword;secret rotate myapp MONGODB_URI_READONLYrotates onlyreadonly. The two are independent — different env-var names, optionally differenttargets.
Per-secret choices:
strategy—atlas-mongodbfor the Mongo URI;randomfor things you self-generate (JWT secrets, session keys, internal API tokens);manualfor third-party API keys you got from a provider dashboard- For
random:length(default 48) andencoding(base62default, orhex) - For
atlas-mongodb: nestedatlasblock (see above) - For
sshtarget: per-secretssh.path(the absolute remote path to write to — each secret usually wants its own file) manualSteps(optional) — array of strings; surfaced bysecret notes <project> <KEY>as the rotation playbook (provider UI link, what scopes to grant, how to verify, etc.)postRotateCheck(optional) — for shared JWT signing secrets, configuretype: "jwt-auth-status"plusservices[].urlentries. Afterrotate/set, keyrotate signs a short-lived HS256 probe token with the new value and requires every verifier URL to return 2xx.
Inventory-only entries ("targets": []): for credentials that live somewhere keyrotate can't reach (vendor portal, internal one-off, anything API-less). secret list / notes surface the entry as documentation; secret rotate / set succeed with a no-op propagation. See PROVISIONING_DOC_URL in examples/example.json for the pattern.
Full schema in SCHEMA.md. A worked example covering every strategy + target in examples/example.json.
secret list <project> prints the target matrix for every tracked secret — this is the values-free inventory an agent can freely read:
Then the day-to-day CLI:
secret ls # all configured projects
secret list myapp # secrets in myapp (as above)
secret rotate myapp JWT_SECRET # generate random + propagate
secret set myapp OPENAI_API_KEY=sk-… # KEY=value shorthand
secret set myapp OPENAI_API_KEY --value sk-… # equivalent explicit form
secret add myapp ALLOWED_ORIGINS --value 'https://new.com' # append to a list value
secret notes myapp OPENAI_API_KEY # show the manual rotation playbookA single secret rotate walks all declared targets for a secret — Atlas password rotation, GCP SM new version, Cloud Run revision roll, Vercel env recreation, GitHub Actions secret push, and local .env overwrite happen sequentially in one command. On the happy path everything stays in sync; on a sink failure, see Failure modes.
Both rotate and set accept multiple keys in a single invocation. The redeploy that Vercel needs to actually pick up a new env value is deferred until the end of the run and deduplicated by Vercel projectId, so rotating N keys that all hit the same Vercel project = 1 redeploy, not N. Dedup also collapses redeploys queued via crossProjectPropagate hops.
# Rotate two API tokens in one shot — 1 Vercel redeploy at the end
secret rotate myapp API_TOKEN_1 API_TOKEN_2
# Set three at once with KEY=value shorthand
secret set myapp DB_HOST=db.prod.example DB_PORT=5432 DB_NAME=prod
# Mix shorthand with --value freely (split on FIRST '=' so values can contain '=')
secret set myapp \
TOKEN_A=eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0= \
TOKEN_B --value 'value with spaces' \
WEBHOOK=https://discord.com/api/webhooks/...--from-stdin and the interactive prompt are single-key only (stdin can't be split between keys; prompting N times is hostile UX).
secret ls list known projects
secret list <project> list secrets in a project
secret rotate <project> [--targets a,b] [--only-project P] <KEY> [KEY...]
rotate (atlas-mongodb / random); multi-key batches Vercel redeploy
secret set <project> [--targets a,b] [--only-project P] <KEY=V | KEY --value V | KEY --from-stdin> [more pairs...]
multi-pair batches Vercel redeploy; mix shorthand and --value freely
secret add <project> <KEY> --value V [--separator ,] append to delimited list (read-current+dedupe+push)
secret remove <project> <KEY> --value V [--separator ,] remove from delimited list
secret pull <project> [KEY] resync localEnv from GCP Secret Manager (keys with a localEnv sink)
secret get <project> <KEY> print current value to stdout (human-only — agents must not call without explicit user request; warns on non-TTY)
secret vercel-upgrade <project|--all> [--dry-run] [--encrypted|--sensitive]
upgrade Vercel env vars to sensitive (heuristic by default)
secret notes [project [KEY]] show rotation playbooks (manualSteps)
add / remove read the current value via fallback chain: local .env → GCP Secret Manager (if configured) → Vercel single-env endpoint with decrypt=true. They handle the decrypt=true quirk that only works on /v1/.../env/{id} (not on the list endpoint).
Both rotate and set accept two orthogonal, composable filters that narrow where a value lands:
--targets <list>— comma-separated sink types (koyeb,vercel,cloudRun,ssh,localEnv, …). Keeps only those sinks, applied across both the owning project's targets and everycrossProjectPropagateentry. So--targets koyebreaches a Koyeb sink wherever it lives — even a downstream-only one — and--targets sshretries just the ssh pushes after a partial failure.--only-project <name>— restrict to a single project (alias-resolved), whether that's the owning project or onecrossProjectPropagateentry.
Combine them to surgically fix one lagging downstream service without redeploying the fleet. For a JWT signing key owned by an auth service and verified by N others, if only one downstream is stale:
# push the CURRENT value to just that project's Koyeb sink, and nothing else
secret get s0 JWT_SECRET | (read -r V; secret set s0 JWT_SECRET --value "$V" --targets koyeb)--targets earlier skipped crossProjectPropagate wholesale; it now filters within it, so a sink-scoped push reaches downstream sinks of that type too.
crossProjectPropagate entries can also set "key" to write the shared value under a different env-var name in that downstream project. This is useful while standardizing a fleet: a shared _mailjet config can own MAILJET_API_SECRET, while an older project still receives the same value as MAILJET_SECRET_KEY until its code is renamed.
| Tool | Why this exists instead |
|---|---|
| HashiCorp Vault | Heavy; runtime fetch model assumes app re-reads vault on every restart. keyrotate is for the case where the secret value is already baked into N platforms' env stores and you need to replace it in each. |
sops / age |
Encrypts secrets at rest in your repo. Useful but orthogonal — once decrypted you still need to push to N platforms manually. keyrotate does that push. |
| Doppler / Infisical | Vendor-managed unified store. Great if you only have one platform; doesn't help when the same MONGODB_URI value has to physically exist on Vercel, Cloud Run, and a GitHub Actions secret. |
vercel env pull / gh secret set / gcloud secrets versions add |
These are the building blocks keyrotate orchestrates. |
| Just a bash one-liner | This used to be a 50-line bash script per project. After 13 projects, the per-target glue had grown to be the bulk of the code; this is what factoring it out looks like. |
This grew out of one user's home-lab dotfiles (~14 personal projects across Vercel, Cloud Run, Koyeb, NAS, and a GitHub App). The original setup-secret.sh was MongoDB-specific; this is the generalization. There's no SaaS, no telemetry, no daemon. It's bash, jq, curl, one node helper for bcrypt+mongo, and a JSON file per project.
The config files themselves are safe to commit — they hold structure only (Atlas project IDs, GCP project names, Vercel project IDs, GitHub repo names, list of target sinks). The actual values (passwords, API keys) live exclusively in the configured targets and never appear in a config file. The single short-lived exception is during a rotation, where the value transits through the script's memory between gen and propagate; it's not written to any temp file and set -euo pipefail ensures partial failures don't leak state.
Issues and PRs welcome. Particularly interested in:
- Additional target types (Fly.io was dropped; Render / Heroku / Railway / AWS SSM Parameter Store would be straightforward)
- Better Atlas API integration (currently only password rotation; could do user creation, scope management)
- Cron-rotation safety checks (refuse to rotate
*_ENCRYPTION_KEYetc. patterns)
MIT — see LICENSE.

