Naalya Handbook
Secrets & Credentials

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.

libs/shared/src/config/env.config.ts
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.

libs/shared/src/config/env.config.ts
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.

infra/prod/secrets.prod.yaml
# 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.

terminal
export RAILWAY_TOKEN='<your-railway-project-token>'
scripts/sync-railway-env.sh infra/staging
# verify staging boots, then:
scripts/sync-railway-env.sh infra/prod

The 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:

  1. Generate a new value in the provider's dashboard (Resend, Cloudflare, Microsoft, etc.). Keep the old one valid for now.
  2. Replace the value in the relevant secrets.*.yaml (and .env.local for local).
  3. Run scripts/sync-railway-env.sh infra/<env> — this redeploys the service with the new value.
  4. 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.

.gitignore
# dotenv environment variable files
.env
.env.local
.env.development
.env.production
.env.staging
.env.prod

The 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

On this page