Naalya Handbook
Secrets & Credentials

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.local file in the repo root, which you create yourself and which git ignores.
  • Staginginfra/staging/secrets.staging.yaml, synced into Railway.
  • Productioninfra/prod/secrets.prod.yaml, synced into Railway.

.env.local

When the app boots, NestJS's ConfigModule is told to load from .env.local.

apps/server/src/app/app.module.ts
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 (shape — values redacted)
# 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.

scripts/sync-railway-env.sh (core loop, trimmed)
# 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"
done

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

infra/prod/railway.yaml
projectId: <project-id>
environmentId: <environment-id>
services:
  - id: <api-service-id>
    name: Naalya-API
  - id: <worker-service-id>
    name: Naalya-Worker

So 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

On this page