Adding & Rotating
The step-by-step for declaring a new config secret, syncing it to Railway, rotating an old one, the never-commit rules, and the gotchas.
This page is the how-to: add a new config secret, push it live, rotate one safely, and the rules and gotchas that keep real values out of git. Never reproduce a real secret value here or anywhere — placeholders like <your-secret> only.
Step 1: Add a new secret
Say a new integration needs an ACME_API_KEY. There are three coordinated edits, in this order.
First, declare it in the Zod schema so the app knows it exists and validates it. Make it required (z.string()) unless there's a real fallback.
const envSchema = z.object({
// ...existing keys
ACME_API_KEY: z.string(),
});Then expose it through the shaped config so services read it via ConfigService instead of process.env directly — group it with related keys.
return {
// ...
acme: { apiKey: process.env.ACME_API_KEY },
};Then add it to every environment's secrets file — infra/staging/secrets.staging.yaml and infra/prod/secrets.prod.yaml — and to your own .env.local. Miss one environment and that environment won't boot, because the schema is required.
# Acme
ACME_API_KEY: '<your-acme-key>'Step 2: Sync it to Railway
With the YAML updated, push it to the deployed environment. Export a Railway project token and run the sync script against the env folder.
export RAILWAY_TOKEN='<your-railway-project-token>'
scripts/sync-railway-env.sh infra/staging
# verify staging boots, then:
scripts/sync-railway-env.sh infra/prodThe script batches all variables into one railway variable set per service and triggers exactly one deploy each, so the new key goes live applied — not staged. The mechanics of that script live in Where They Live.
Step 3: Rotate a secret
Rotation is the same machinery with old and new overlapping briefly. To rotate, say, RESEND_API_KEY:
- Generate a new value in the provider's dashboard (Resend, Cloudflare, Microsoft, etc.). Keep the old one valid for now.
- Replace the value in the relevant
secrets.*.yaml(and.env.localfor local). - Run
scripts/sync-railway-env.sh infra/<env>— this redeploys the service with the new value. - Confirm the feature works against the new credential, then revoke the old value at the provider.
For product secrets, rotation is different and simpler: an API key has a revokedAt column, so ApiKeyService.revoke(id) stamps it and verify immediately stops matching it — mint a fresh key, hand it over, revoke the old. No redeploy needed, because product secrets live in the database, not the environment. See Secrets at Rest for how those are stored.
Rotate, don't reuse — and never log a secret
When a value is compromised (or a laptop with .env.local walks off), rotate it; don't assume it's fine. And when you read a secret in code, route it through ConfigService and never console.log it, never put it in an error message, never stuff it into a Sentry breadcrumb. The api-key mapper omitting keyHash is the pattern: secrets should have no path to an output.
Never-commit rules
Two mechanisms keep config secrets out of git.
.gitignore blocks every .env variant. Local secret files can't be committed even by accident, because every dotenv filename is ignored.
# dotenv environment variable files
.env
.env.local
.env.development
.env.production
.env.staging
.env.prodThe secrets.*.yaml files carry a loud warning and a private-repo assumption. Each file's header says DO NOT commit to a public repo — this file contains real secrets. They exist in the repo precisely so the deploy mechanism (the sync script reading from disk) can find them — which only works because this repository is private. The header is a standing reminder of that assumption.
And there is no .env.example — the absence is the policy: nothing in the tree advertises the key names with fillable slots next to real-looking values.
Gotchas
A few traps that bite people working with secrets here.
Committing a real value because the file is 'already in the repo'
The secrets.*.yaml files being tracked does not mean it's fine to paste real values into other files — a test fixture, a code comment, a quick script, a doc like this one. The YAML files are the only place real values belong, and only because the repo is private. Putting a real key anywhere else (especially anything that could end up in a public mirror or a screenshot) is a leak. Use <your-secret> placeholders everywhere else.
Build-time vs runtime: secrets are a runtime concern
Config secrets are read from process.env when the app runs, not when it builds. The Docker image / compiled bundle should contain no secrets — they're injected by Railway at deploy time (or loaded from .env.local at startup locally). If you find yourself wanting a secret available during pnpm build, stop and reconsider: baking a secret into a build artifact means it ships inside the image, which is exactly what we're avoiding.
A secret read but never validated
It's easy to add process.env.NEW_THING straight into a service and skip the schema. Don't. A key that isn't in env.config.ts is never validated, so a typo or a missing value won't fail at boot — it'll surface as undefined deep in a request, often as a confusing downstream error from the third-party SDK. Every secret goes through the Zod schema first; that's what turns "mysterious 3am failure" into "the app refused to start and told you which key was missing."
Where to go next
Where They Live
The sync script and routing table this how-to leans on.
Secrets at Rest
How product secrets are stored and why their rotation is simpler.
Config & Environment
The full ConfigService surface and how the shaped config is consumed.
Secrets & Credentials
Back to the overview and the key-name inventory.