# Sync to Railway

Railway applies variables at deploy time and hands them to the process it
starts, so there is no earlier point at which seekrit could inject them. A
binding maps one seekrit environment onto one (project, environment, service)
address inside Railway.

| At a glance | |
| --- | --- |
| **What seekrit writes** | A service's variables, or an environment's shared variables |
| **Addressed by** | Railway project, environment, and service — all three UUIDs |
| **Connection carries** | The API token, and which *kind* of token it is |
| **Token permission** | An account/workspace token, or a project token scoped to one project |
| **Takes effect** | On redeploy, which seekrit triggers by default |
| **Value visibility** | Readable by anyone with access to the Railway project |

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, and know which kind it is

Railway has two kinds, and they travel in **different headers** — a project token
sent as an account one fails exactly like an invalid token would, so seekrit asks
you which you pasted rather than guessing:

- **account** — from **Account Settings → Tokens** (personal or workspace).
  Reaches every project you can see. Sent as `Authorization: Bearer`.
- **project** — from **Project → Settings → Tokens**. Railway scopes it to one
  project and environment, which makes it the least-privilege choice when a
  connection serves a single destination. Sent as `Project-Access-Token`.

```bash
printf '%s' "$RAILWAY_TOKEN" | seekrit sync connect \
  --name acme-railway --provider railway --token-kind project
```

`--token-kind` defaults to `account`. There is no workspace or team id to state:
Railway ids are globally unique and a binding names its project outright, so the
token plus the destination is the whole address.

## 2. Bind an environment

A Railway variable is addressed by project, environment, and service — all three
are UUIDs, so each has its own flag. The **environment** here is Railway's
(`production`, `pr-42`), not the seekrit environment the binding reads from; a
binding is the mapping between the two.

```bash
seekrit sync verify acme-railway --provider railway \
  --railway-project 1111…  --railway-environment 2222…  --service 3333…

seekrit sync enable --connection acme-railway --provider railway \
  --railway-project 1111…  --railway-environment 2222…  --service 3333… \
  --app storefront --env production --acknowledge-decryption
```

Find the ids in the resource's URL, or on the project's Settings page. seekrit
checks each is UUID-shaped before storing it, because the alternative is a bare
`Problem processing request` hours later inside a run nobody is watching.

Verify asks for the environment and the service in **one** GraphQL document
rather than two round trips — Railway's hourly quota is small enough that a spare
request is worth avoiding.

Omit `--service` to write the environment's **shared** variables instead — the
project-level set that services opt into with `${{shared.NAME}}`. That is a
genuinely different destination, not a wildcard: shared variables reach only the
services that reference them.

> **Note:** Railway applies variables at **deploy time**, so changing one triggers a redeploy of the service. That is what makes a synced value reach the running process, and it is the default. Pass `--skip-deploys` (or tick *Don't redeploy on change*) when deploys are gated behind a release process — the values then sit staged until your next one.

## How a push behaves

- **One request for the whole environment.** `variableCollectionUpsert` takes a
  map of up to 100 names, so a normal run costs a single call no matter how many
  secrets it carries.
- **`replace: false`, always.** Railway's upsert can replace a collection
  wholesale; seekrit never uses that. Railway's own `PORT`, reference variables,
  and anything another binding owns are left alone, and a removal deletes just
  that name.
- **Removals are one request each.** Railway has no bulk delete. A variable
  Railway has never heard of counts as deleted rather than as a failure that
  would retry forever.
- **seekrit never reads Railway's existing values.** Variables are addressed by
  name, not by an internal id, so unlike Vercel there is nothing to list first —
  which keeps unrelated third-party plaintext out of the sync engine entirely.
- **A run is capped at 200 requests**, far below the other connectors' budgets,
  because Railway's own rate limits bite first: as low as 100 requests per *hour*
  on the free plan, and 10 per second on Hobby. Since a normal push is one
  request, that ceiling only ever bounds runs with many deletions.

> **Warning:** **Railway answers a failed call with HTTP 200.** A bad token, a project the token cannot see, and a deleted service all come back as `200 {"errors":[{"message":"Not Authorized"}]}`. seekrit treats a populated `errors` array as a failure regardless of status — trusting the status alone would mark every secret as pushed on a run where nothing left the building. It also means the *reason* has to be read out of the message text. `Not Authorized` is the one Railway returns for all three causes, so the fix is usually a process of elimination: check the token kind first, then the ids.

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `Not Authorized` on everything, immediately after setup | The token kind is wrong — Railway sends the two in different headers | Re-create the connection with the right `--token-kind`. A project token presented as a bearer fails identically to an invalid one |
| `Not Authorized` on a connection that worked | The token was revoked, or its owner lost access to the project | Mint a new token and re-create the connection |
| `Not Authorized` naming one destination only | A project token pointed at a project or environment outside its scope | Use an account token, or a project token minted in the right project |
| A UUID is rejected when enabling | An id was pasted from the wrong place — a slug, or a name | All three ids are UUIDs. Take them from the resource's URL |
| Values pushed, process still on the old ones | `--skip-deploys` is on, or the deploy has not finished | Deploy the service; without the flag seekrit triggers one for you |
| Run reported partial, `429` in the error | Railway 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
- [CLI reference](/docs/reference/cli#third-party-sync) — every `seekrit sync` flag
