Where They Live
The three homes for a config secret — a local .env.local, the per-environment secrets YAML, and the Railway sync script that pushes them.
There are exactly three homes for a config secret, one per environment, and they're deliberately different mechanisms. This page walks each one and the script that ties the deployed two together.
- Local dev → a
.env.localfile in the repo root, which you create yourself and which git ignores. - Staging →
infra/staging/secrets.staging.yaml, synced into Railway. - Production →
infra/prod/secrets.prod.yaml, synced into Railway.
.env.local
When the app boots, NestJS's ConfigModule is told to load from .env.local.
ConfigModule.forRoot({
isGlobal: true,
load: [config],
envFilePath: '.env.local',
}),That populates process.env before the Zod schema validates it. (Sentry is wired even earlier — apps/server/src/instrument.ts calls dotenv.config({ path: '.env.local' }) at the very top of main.ts, before any module loads, so error reporting is live from the first line.) You build .env.local by hand, filling in every required key from the schema with values a teammate hands you out-of-band — never from a committed file, because there isn't one.
The missing .env.example is a security choice, not an oversight
Most projects ship a .env.example listing every key with dummy values. This one deliberately does not. An example file is a map of exactly which secrets exist and what they're called; keeping it out of the repo removes that reconnaissance aid, and removes the temptation to "just fill in the real values next to the placeholders" and accidentally commit them. The schema in env.config.ts is your checklist of required keys — read it, and ask a teammate for the values.
The secrets YAML
For deployed environments, every variable lives in a per-environment YAML file under infra/. Each file is a flat KEY: 'value' map, grouped by comment headers that mirror the inventory table.
# infra/prod/secrets.prod.yaml
# Railway environment variables for the Production environment.
# DO NOT commit to a public repo — this file contains real secrets.
# Database
DATABASE_URL: '<your-database-url>'
# Auth / JWT
JWT_SECRET_KEY: '<your-jwt-secret>'
# Microsoft
MICROSOFT_CLIENT_SECRET: '<your-microsoft-client-secret>'
MICROSOFT_WEBHOOK_SECRET: '<your-microsoft-webhook-secret>'
# API docs (HTTP Basic Auth protecting /docs in non-dev envs)
DOCS_USERNAME: '<your-docs-username>'
DOCS_PASSWORD: '<your-docs-password>'One value is special: REDIS_URL is set to '${{ Redis.REDIS_URL }}' — a Railway reference variable, not a literal. Railway resolves it at deploy time to the private internal URL of the linked Redis service, so the real connection string is never written into the file at all. That's the ideal: a secret the platform injects so we never have to hold it.
The Railway sync
These YAML files are pushed to Railway by scripts/sync-railway-env.sh, which reads the env folder, finds the target services from infra/<env>/railway.yaml, and runs railway variable set for every key/value pair on every listed service.
# Collect KEY=VALUE pairs from secrets.<env>.yaml
while IFS= read -r line; do pairs+=("$line"); done \
< <(yq 'to_entries | .[] | .key + "=" + (.value | tostring)' "$secrets_file")
# Push them to every service listed in railway.yaml, one deploy each
for service_id in $service_ids; do
railway variable set "${pairs[@]}" \
--service "$service_id" \
--environment "$environment_id"
doneThe railway.yaml next to each secrets file is the routing table — it names the project, the environment, and the service IDs (stable even if someone renames a service in the dashboard) that should receive the variables.
projectId: <project-id>
environmentId: <environment-id>
services:
- id: <api-service-id>
name: Naalya-API
- id: <worker-service-id>
name: Naalya-WorkerSo the full picture for a deployed env: edit the secrets.*.yaml, run scripts/sync-railway-env.sh infra/prod (with RAILWAY_TOKEN exported), and the script commits one deploy per service with the new values live. There is no manual variable-by-variable clicking in the Railway UI.
Railway stages changes — so don't half-apply
The sync script passes every pair in a single railway variable set call per service on purpose. Railway stages variable changes and only applies them on a commit/deploy; setting them one at a time (or with --skip-deploys) leaves changes staged-but-unapplied — values that look set in the dashboard but aren't actually live. The single batched call commits and deploys each service exactly once. Trust the script; don't hand-edit individual vars and assume they took.
Where to go next
Adding & Rotating
Run the sync script as part of adding a new key or rotating an old one.
Secrets at Rest
The product secrets that live in the database, not these env files.
Config & Environment
How the shaped config is consumed through ConfigService.
Secrets & Credentials
Back to the overview and the key-name inventory.
Secrets & Credentials
How the API handles secrets — the key names that matter, the Zod fail-fast boundary, and the one rule that keeps real values out of git.
Secrets at Rest
The product side — API keys hashed with SHA-256 and shown once, the Microsoft clientState secret, and the /docs Basic Auth gate.