seekrit
Docs/Heroku

Sync to Heroku

Heroku hands an app's config vars to every dyno as environment variables when it starts them, so there is no earlier point at which seekrit could inject them. A binding owns one Heroku app's config vars — the same slot heroku config:set writes.

At a glance
What seekrit writesAn app's config vars, delivered to every dyno and process type
Addressed byThe Heroku app name, or its UUID
Connection carriesThe token alone — Heroku app names are globally unique
Token permissionA token whose user has the operate or deploy role on the app
Takes effectImmediately — a new release, and the dynos restart
Value visibilityReadable by anyone with access to the app's config

New to sync? Read Third-party sync first — the decryption grant, name mapping, deletions, and failure handling are the same on every destination.

1. Create an API token

heroku authorizations:create --short

Reach for that rather than heroku auth:token: the CLI's own token expires a year after you log in — or eight hours if your account uses SSO — and a sync connection built on it stops working overnight, long after you have forgotten where it came from. authorizations:create mints a token that does not expire.

The token carries its user's access to every app and team they can reach, and Heroku app names are globally unique, so a Heroku connection has nothing else to configure:

printf '%s' "$(heroku authorizations:create --short)" \
  | seekrit sync connect --name acme-heroku --provider heroku
note

A Heroku token is only as narrow as the user who made it. There is no per-app API token the way Fly has one, so the least-privilege setup is a machine user added to just the apps it syncs, with the deploy or operate role.

2. Bind an environment

A Heroku app has one set of config vars, shared by every dyno and every process type, so the app is the whole destination — there are no targets to pick. Heroku's own convention is that staging and production are separate apps, and that is the split a binding maps onto:

seekrit sync verify acme-heroku --provider heroku --heroku-app storefront-production

seekrit sync enable --connection acme-heroku --provider heroku \
  --heroku-app storefront-production \
  --app storefront --env production --acknowledge-decryption

--heroku-app is Heroku's app; --app is the seekrit application the environment belongs to. They are different things, which is why the flag is not just --app.

You can pass the app's UUID instead of its name, and that is the sturdier choice — renaming an app in the Heroku dashboard breaks a binding that holds its name. seekrit checks a name against Heroku's own pattern before storing it, so the habitual slips (pasting example.herokuapp.com, or a name with capitals) fail at the form rather than as a 404 inside a run nobody is watching.

Verify fetches the app rather than reading its config vars: that proves the token and that the app exists — every failure you can cause from the connection dialog — without pulling a single value into seekrit.

caution

Every push restarts the app. Heroku applies config vars by cutting a new release and restarting the dynos — the same thing heroku config:set does. A sync run that writes anything will do that to storefront-production.

seekrit keeps it to one release per run: a run's sets and removals travel in a single request, and a run with nothing to write sends no request at all. But if brief restarts are costly for this app, bind it in manual mode (--mode manual) and push with seekrit sync run when you choose, rather than on every write.

How a push behaves

  • Exactly one request per run. PATCH /apps/{app}/config-vars carries the sets and the removals together in one flat map. No other connector does that — here a request is the expensive unit, because each one bounces the dynos.
  • Merge semantics. A name with a value is set, a name with null is removed, and a name that is absent is left alone.
  • Add-on credentials survive. That matters more here than anywhere else, because Heroku apps are full of config vars that are not yours: add-ons write DATABASE_URL, REDIS_URL, and their kin, and rotate those values on their own schedule. seekrit never sends a name it was not given, so add-on credentials, HEROKU_*, and anything you set by hand survive a push untouched.
  • A rejected request is blamed on every name in it, which is honest here: Heroku validates the whole map before applying it, so a rejected PATCH leaves the app exactly as it was and none of those names landed.
  • Invalid names are failed without being sent. Because the whole run is one request, a single unacceptable name would otherwise take the entire environment down with it. seekrit checks names against Heroku's rules first and records the offenders as per-name failures, leaving the rest to push normally.
  • A value Heroku echoes back in an error is scrubbed before the reason is stored on the run row.

Name and value rules

note

Heroku will not accept every name seekrit will. Config var names take only letters, numbers, and underscores, cannot begin with a digit or a double underscore, and cannot begin with HEROKU_, which Heroku reserves for itself. A name that breaks one of those rules is reported as a failure against that name alone — the rest of the environment still pushes — so watch for it if you use a --prefix.

Heroku also caps an app's config vars at 64KB across every key and value. seekrit checks its own payload against that before sending, but the app may already hold config vars seekrit does not manage, so passing that check is not a promise Heroku will accept it — only failing it is a certainty that Heroku would not.

Troubleshooting

SymptomCauseFix
401 on every nameThe token expired or was revoked — likely heroku auth:token, which expires (8 hours under SSO)Re-create the connection with heroku authorizations:create --short
403The token's user lacks the deploy or operate role on this appAdd the machine user to the app with the right role
404 on the appWrong name, a .herokuapp.com URL, or the app was renamedBind by UUID instead — a rename then can't break it
One name failed, rest landedThe mapped name breaks Heroku's rules — a digit or HEROKU_ at the front, a stray characterFix the prefix or rename; seekrit refuses to send it rather than fail the run
422 about the app totalThe app's config vars exceed 64KB across all keys and valuesTrim, or move bulk config out of env vars
Every name reported failed at onceThe single PATCH was rejectedNothing landed — Heroku validates before applying. Fix the cause and re-run
Dynos restarting more than you'd likeEvery write cuts a releaseBind with --mode manual and push with seekrit sync run

See also