# Third-party sync

Most ways of getting secrets into a running app keep decryption on your side:
[`seekrit run`](/docs/guides/run) decrypts in your process, the
[egress proxy](/docs/guides/agent-proxy) decrypts in your proxy, the
[SDKs](/docs/guides/sdks) decrypt in your code, and
[ESO](/docs/guides/kubernetes) decrypts in your cluster.

Some platforms don't let you run anything before your app starts. A Vercel build
reads environment variables that Vercel already holds; a Cloudflare Worker reads
bindings the platform injected before your code ran. There is no earlier point to
inject them. **Third-party sync** is for exactly that case: seekrit stays the
source of truth, and pushes the environment out whenever it changes.

## Choose the right tool first

| If you… | Use | Who decrypts |
| --- | --- | --- |
| control the process (CI job, container, server) | [`seekrit run`](/docs/guides/run) | your machine |
| have untrusted or agent workloads | [egress proxy](/docs/guides/agent-proxy) | your proxy |
| run Kubernetes | [ESO chart](/docs/guides/kubernetes) | your cluster |
| are writing the app | [a language SDK](/docs/guides/sdks) | your process |
| **don't control the runtime** (Vercel build env, Cloudflare bindings, a managed platform's env vars) | **sync** | **seekrit's sync engine** |

Sync is the last row, and it is the only feature in seekrit where our servers
decrypt anything. Reach for it when the rows above don't apply.

> **Warning:** **Enabling sync lets seekrit decrypt that environment.** To push a value to any of the platforms below, something has to hold it in the clear, and sync runs when nobody is logged in — so the sync engine must be able to decrypt on its own. This applies **only** to environments you explicitly enable it for, and only to the destination you named. Everything else stays zero-knowledge: seekrit cannot decrypt an environment that has no sync grant, and enabling one requires a key holder, so it can't be switched on server-side.

## How it works

Enabling sync creates two things:

1. A **connection** — the destination account: an API credential for Vercel,
   Cloudflare, AWS, or any other platform below. It is encrypted in your browser
   to a public key held by the sync engine, so the control plane stores
   ciphertext it cannot open.
2. A **binding** — one environment → one destination. Creating it also creates
   a **key grant**: the environment's data key, wrapped in your browser to that
   same public key. This grant is what authorizes decryption, and it is
   [an ordinary key grant](/docs/concepts/access-control) — visible alongside
   your users and service tokens, and revocable the same way.

```
secret write ─▶ seekrit API ─▶ sync engine ──▶ destination
                               (decrypts here, only with a grant)
```

The private half of that keypair lives inside the sync engine and is never
written to the database. Deleting the connection destroys it, which turns every
grant made to it into ciphertext nobody can open — including us.

Every destination follows the same three steps, and each page below walks them
for its own platform:

1. **Create a credential** at the destination, scoped as narrowly as that
   platform allows.
2. **Add the connection** with `seekrit sync connect` (or **Sync → Add
   connection** in the dashboard). The credential is read from stdin — never a
   flag, so it can't land in your shell history — and wrapped in your browser
   or terminal before it is sent.
3. **Bind an environment** with `seekrit sync enable`. This is the step that
   shows the decryption disclosure and creates the key grant, so it has to be
   run by someone who can already read the environment.

`--acknowledge-decryption` is the terminal's version of the dashboard's
disclosure: at a terminal you can leave it off and answer the prompt, but a
non-interactive run must pass it, and either way the acknowledgment lands in the
audit row. seekrit then pushes once immediately, and again on every change.

## Destinations

| Destination | What it writes | Takes effect |
| --- | --- | --- |
| [**Vercel**](/docs/guides/third-party-sync/vercel) | Project environment variables (`encrypted`), per deployment target | next build |
| [**Cloudflare Workers**](/docs/guides/third-party-sync/cloudflare) | A Worker's `secret_text` bindings — the slot `wrangler secret put` writes | immediately |
| [**Cloudflare Pages**](/docs/guides/third-party-sync/cloudflare) | A project's environment variables (`secret_text`), per deployment config | next deployment |
| [**Cloudflare Secrets Store**](/docs/guides/third-party-sync/cloudflare) | Account-level secrets that Workers bind by name | next Worker deploy |
| [**Railway**](/docs/guides/third-party-sync/railway) | A service's variables, or an environment's shared variables | redeploy (triggered by default) |
| [**AWS Secrets Manager**](/docs/guides/third-party-sync/aws) | One secret per name, or all of them as one JSON secret | next read by your app |
| [**AWS Parameter Store**](/docs/guides/third-party-sync/aws) | SSM parameters under one path, `SecureString` by default | next read by your app |
| [**Render**](/docs/guides/third-party-sync/render) | One service's variables, or a shared environment group's | next deploy |
| [**Fly.io**](/docs/guides/third-party-sync/fly) | An app's secrets, delivered to Machines as environment variables | next Machine boot (`fly secrets deploy`) |
| [**Northflank**](/docs/guides/third-party-sync/northflank) | A secret group's variables, inherited by the project's services and jobs | next deploy or restart |
| [**DigitalOcean**](/docs/guides/third-party-sync/digitalocean) | An App Platform app's or component's environment variables, encrypted | a new deployment, started by the push |
| [**Heroku**](/docs/guides/third-party-sync/heroku) | An app's config vars, delivered to every dyno as environment variables | immediately — a new release, and the dynos restart |
| [**Netlify**](/docs/guides/third-party-sync/netlify) | A site's environment variables, for the deploy contexts you name | next build and deploy |
| [**Bunnyshell**](/docs/guides/third-party-sync/bunnyshell) | An environment's variables, or a project's — inherited by environments made later | next deployment of the environment |
| [**GitHub Actions**](/docs/guides/third-party-sync/github-actions) | Repository, deployment environment, or organization Actions secrets | next workflow run |
| [**Google Secret Manager**](/docs/guides/third-party-sync/google-secret-manager) | One secret per name, or all of them as one JSON secret | next read by your app |
| [**LangGraph Platform**](/docs/guides/third-party-sync/langgraph-platform) | An Agent Server deployment's secrets, read as environment variables | a new revision, started by the push |

Each page covers the credential to create, the least-privilege scope for it, how
a binding addresses that platform, and what a push does and does not touch there.
Everything on *this* page — naming, filtering, references, deletions, failure
handling — works the same on every one of them.

> **Note:** **Syncing to GitHub Actions?** Read [that page's opening warning](/docs/guides/third-party-sync/github-actions) first. GitHub is the one destination here that seekrit *can* inject into at runtime, so the published [`seekritdev/github-action`](https://github.com/seekritdev/github-action) is the better answer for most workflows — sync only reaches the cases an Action cannot.

## Naming and filtering

By default, secret names are pushed verbatim. A binding can adjust that:

- **Prefix / suffix / case** — e.g. prefix `NEXT_PUBLIC_` or force upper case.
- **Rename** — map individual names exactly. An explicit rename is used
  verbatim; prefix and case are not applied on top of it.
- **Include / exclude** — glob allow/deny lists (`DB_*`, `*_PASSWORD`).
  Exclusion always wins over inclusion.

If two secrets would end up with the same destination name, the run fails and
tells you which two. seekrit will not silently let one value shadow another.

From the CLI, `sync enable` takes `--prefix`, `--include`, and `--exclude`
(comma-separated globs). Suffixes, case folding, and per-name renames are set in
the dashboard.

```bash
seekrit sync enable --connection acme-production \
  --app storefront --env production --project prj_abc \
  --prefix NEXT_PUBLIC_ --include 'API_*,FEATURE_*' --exclude '*_PASSWORD' \
  --acknowledge-decryption
```

> **Note:** **A name transform is the usual way to produce a name a destination rejects.** Every platform has its own rules — Heroku reserves `HEROKU_`, GitHub reserves `GITHUB_`, Bunnyshell wants at least three characters, Secret Manager takes no slashes. seekrit's own names are legal almost everywhere, so it is a `--prefix` or a case fold that usually pushes one over the line. Each destination page lists its rules, and a name that breaks one is reported as a failure against that name alone — the rest of the environment still pushes.

## Secret references

[`${OTHER_SECRET}` references](/docs/guides/references) are expanded before the
push, the same way `seekrit run` and the SDKs expand them.
The destination receives the final value, not the reference — pushing the raw
stored text would put a literal `${OTHER_SECRET}` into your Vercel project.

A reference to a name that doesn't exist is left as written (so CI templating
like `${GITHUB_SHA}` passes through untouched). A reference **cycle** fails the
run, because no value would be correct to push.

## Composed environments

Sync pushes the **effective** environment — what your app actually resolves,
including every [composed group](/docs/guides/environments). That means the
connection needs a grant on each composed group environment too, not only the
application environment. The dashboard walks you through granting all of them;
if one is missing, the run fails with `no key grant for group "…"` rather than
pushing a partial environment.

Sync targets application environments. To sync a shared group, bind the
application environments that compose it.

## Branch environments

A [branch environment](/docs/guides/branches) can be synced like any other, and
the pairing is a natural one: point a branch at a Vercel `preview` target with
its git branch set, and each PR gets its own overlay downstream.

Two things follow from how branches inherit:

- A write to the **parent** re-syncs its branches too, since a branch supplies
  only the values that differ.
- When a branch **expires**, its binding goes with it and seekrit stops pushing.
  Values already at the destination are left alone — expiry removes the branch,
  not the copy the destination is holding. Delete those there if you need them
  gone.

## Deletions

A binding's `onDelete` policy decides what happens when a secret disappears from
seekrit:

- **`delete`** (default) — remove it at the destination too, so the destination
  is a true mirror.
- **`retain`** — leave it. Use this when something else also writes to that
  project and seekrit is not the only source.

A removal only ever touches what the binding itself owns: on Vercel, variables
whose targets overlap the binding's; on Pages, the deployment configs it writes;
on Render, the one service or group the binding names; on AWS and Google Secret
Manager, the names under the binding's prefix or path. Two bindings can point at
one project with different targets without stepping on each other. A name that is
already gone at the destination counts as deleted rather than failing forever.

> **Note:** On **Secrets Manager**, deleting is AWS's *scheduled* deletion with its 30-day recovery window — a mistaken removal is recoverable in the console. The consequence is that the name stays reserved for those 30 days: if the secret comes back before the window closes, seekrit restores it rather than failing to recreate it. **Google Secret Manager has no such window.** A removal deletes the secret and every version of it immediately and permanently, so use `retain` there if you would rather clean up by hand.

## When a sync fails

Runs are recorded with per-name outcomes. A partial run is normal — no
destination API is transactional, so some names can land while others fail — and
only the failures are retried. Where a destination writes in batches (the
Cloudflare Workers and Secrets Store bulk endpoints, Heroku's single `PATCH`) a
rejected batch is reported against every name in it, because that is all the API
tells us; re-pushing a value that did in fact land is harmless.

Retries back off exponentially. After five consecutive failures the binding
stops and org admins get an email, because the destination is now serving stale
values and nothing downstream will say so on its own. Fix the cause, then
re-enable the binding or use **Sync now**.

From the CLI:

```bash
seekrit sync bindings                # mode, last run, last error
seekrit sync runs --binding syb_abc  # per-run history
seekrit sync run syb_abc             # push now; exits non-zero unless fully successful
```

`sync run` treats a `partial` result as a failure for exit-code purposes, so it
can gate a deploy step.

> **Note:** **A very large environment can take more than one run.** Every connector works to a per-run request budget, sized against whichever ceiling binds first on that platform — the Worker subrequest cap, or the destination's own rate limit (Railway's is as low as 100 requests an hour on the free plan). A run that hits its budget reports that it has more to do and the engine re-runs immediately, so a big first sync is slower rather than silently partial.

A few failures are the same everywhere. The rest are per-destination, and each
page has a troubleshooting table for its own:

- `no key grant for …` — the grant was revoked, or the environment now composes
  a group the connection can't read. Re-enable sync for that environment.
- `the sync key grant may be stale` — the environment was re-keyed without
  re-granting, so the connection holds a wrap around the wrong data key. Re-enable
  sync for that environment.
- `name collision: "…" and "…" both map to "…"` — a name transform collapsed two
  names into one. Fix the rename, the prefix, or the filters; seekrit will not
  pick a winner.

> **Note:** **A failure reason never carries a secret value.** Error text is built from the destination's own message fields, never from a request body — and where a platform is known to echo a submitted value back in an error (Heroku, Netlify, Bunnyshell), the connector scrubs any value it just sent out of the message before it is stored on the run row.

## Turning it off

Deleting a binding revokes that environment's grant to the connection (unless
another binding still needs it). Deleting the connection revokes all of its
grants and destroys its keypair.

Either way the values already at the destination stay there — seekrit stops
updating them, it does not reach in and clean up. Remove them at the destination
if you want them gone.

```bash
seekrit sync pause syb_abc               # stop pushing, keep the binding and its grant
seekrit sync disable syb_abc             # delete the binding and revoke its grant
seekrit sync disconnect acme-production  # delete the account, its bindings, and its keypair
```
