# Sync to Fly.io

Fly injects an app's secrets into a Machine when it boots, so there is no
earlier point at which seekrit could supply them. A binding owns one Fly app's
secret set — the same slot `fly secrets set` writes.

| At a glance | |
| --- | --- |
| **What seekrit writes** | An app's secrets, delivered to Machines as environment variables |
| **Addressed by** | The Fly app name — one secret set per app, shared by every Machine in every region |
| **Connection carries** | The token alone. No account, team, region, or token kind |
| **Token permission** | A deploy token scoped to one app (`fly tokens create deploy`), or an org token |
| **Takes effect** | Next Machine boot. Existing Machines keep their values until `fly secrets deploy` |
| **Value visibility** | Write-only at Fly — `fly secrets list` shows names and digests, not values |

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 a token

```bash
fly tokens create deploy -a storefront-production
```

That mints a token scoped to a single app, which is enough to write its secrets
and the least-privilege choice when a connection serves one destination.
`fly tokens org` covers every app in an organization — reach for it only when one
connection genuinely serves many apps.

Paste it whole, `FlyV1` prefix included. Fly's two token shapes travel under
different auth schemes, and seekrit picks the right one by reading the token — so
there is nothing else to configure here. No account, team, or region, and no
token kind to state the way Railway needs one:

```bash
printf '%s' "$FLY_API_TOKEN" | seekrit sync connect \
  --name acme-fly --provider fly
```

## 2. Bind an environment

A Fly app has **one** secret set, shared by every Machine in every region, so the
app name is the whole destination — there are no targets to pick. Fly'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-fly --provider fly --fly-app storefront-production

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

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

Verify fetches the app (`GET /v1/apps/{app}`) rather than reading its secrets —
the same call `flyctl` makes on every deploy, so every token flavour that can
push is known to be allowed it. A verify stricter than the push would reject
setups that work. A token with read but not write access surfaces on the first
push, in the run ledger, per name.

> **Note:** **Pushed secrets are staged until Machines restart.** Fly injects secrets when a Machine boots, so a push updates the app's secret set while already running Machines keep the values they started with. Machines created after the push have them; existing ones pick them up on `fly secrets deploy -a storefront-production`, or on any deploy. seekrit will not roll your Machines for you. Rolling a Machine set is a *deployment* — a lease per Machine, each one's config resubmitted, health checks between batches — and a secrets push is the wrong place to be doing it. The failure mode is an app taken down to deliver an environment variable. A bare Machine restart is not a substitute either: it carries no secrets version, which is why `flyctl` itself does not use one here.

## How a push behaves

- **One request for the whole environment.** `POST /v1/apps/{app}/secrets` takes
  a single `{ values: { NAME: value } }` map of up to 100 names, applied as a
  **merge**: a name with a value is set, a name with `null` is unset, and a name
  that is absent is left alone.
- **Merge semantics are what make this safe** to point at an app that already
  has secrets. Fly's own `FLY_*` secrets, another binding's names, and anything
  you set by hand survive a push untouched, and a removal unsets just that name.
- **Sets and removals travel separately**, so a rejected removal cannot report
  landed secrets as failures.
- **A run is capped at 100 requests**, small on purpose: Fly's Machines API
  allows roughly **one request per second per action, bursting to three**. Since
  a normal environment is one or two calls, that ceiling only bounds something
  pathological.
- **Fly never sees a value it can echo back.** Error text is built from Fly's
  own message fields, never from a request body.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| Rate-limited on **Test connection** | Fly allows about one request a second | Wait a moment and try again. seekrit tells this apart from a refusal, because the advice is the opposite |
| `401` on every name | The token was revoked, or it is an app token for a different app | Mint a new token with `fly tokens create deploy -a <app>` |
| `404` on the app | The app name is wrong, or the app was deleted | App names are lowercase letters, numbers, and dashes. `fly apps list` prints the exact one |
| Verify passes, pushes fail | A read-scoped token — verify uses the read call `flyctl` makes on deploy | Use a deploy or org token |
| Secrets pushed, app still on the old values | Fly injects at Machine boot | `fly secrets deploy -a <app>`, or deploy. seekrit deliberately rolls nothing |
| Run reported partial, `429` in the error | Fly rate-limited the token | Nothing to do — seekrit honors `Retry-After` and retries the rest |

## See also

- [Third-party sync](/docs/guides/third-party-sync) — the shared model, naming and filtering, deletions
- [seekrit-run launcher](/docs/guides/run) — if you control the container's entrypoint, prefer this: no plaintext leaves your side
- [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag
