# 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 writes** | An app's config vars, delivered to every dyno and process type |
| **Addressed by** | The Heroku app name, or its UUID |
| **Connection carries** | The token alone — Heroku app names are globally unique |
| **Token permission** | A token whose user has the **operate** or **deploy** role on the app |
| **Takes effect** | Immediately — a new release, and the dynos restart |
| **Value visibility** | Readable by anyone with access to the app's config |

New to sync? Read [Third-party sync](/docs/guides/third-party-sync) first — the
decryption grant, name mapping, deletions, and failure handling are the same on
every destination.

## 1. Create an API token

```bash
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:

```bash
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:

```bash
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.

> **Warning:** **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

| Symptom | Cause | Fix |
| --- | --- | --- |
| `401` on every name | The token expired or was revoked — likely `heroku auth:token`, which expires (8 hours under SSO) | Re-create the connection with `heroku authorizations:create --short` |
| `403` | The token's user lacks the deploy or operate role on this app | Add the machine user to the app with the right role |
| `404` on the app | Wrong name, a `.herokuapp.com` URL, or the app was renamed | Bind by UUID instead — a rename then can't break it |
| One name failed, rest landed | The mapped name breaks Heroku's rules — a digit or `HEROKU_` at the front, a stray character | Fix the prefix or rename; seekrit refuses to send it rather than fail the run |
| `422` about the app total | The app's config vars exceed 64KB across all keys and values | Trim, or move bulk config out of env vars |
| Every name reported failed at once | The single `PATCH` was rejected | Nothing landed — Heroku validates before applying. Fix the cause and re-run |
| Dynos restarting more than you'd like | Every write cuts a release | Bind with `--mode manual` and push with `seekrit sync run` |

## See also

- [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions
- [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag
