Skip to content

Environments & secrets ​

Secrets live in 1Password β€” the single source of truth for every environment. Nobody edits secrets by hand in the Railway dashboard, and no plaintext .env is mounted from the 1Password desktop app anymore.

Where a value lives: three buckets ​

Not everything is a 1Password secret. A variable falls into exactly one bucket:

BucketExamplesLives inSynced by CI?
1. Railway-provisioned infraREDIS_URL, MONGODB_URI (runtime), RAILWAY_PUBLIC_DOMAINRailway reference variables❌ never
2. External third-party secretsOpenAI, Vapi, Twilio, AWS, Firebase, Slack, Notion, Reducto, FieldX, Grafana, Mongo Atlas (MONGODB_ASSETS_URI)1Password itemβœ… yes
3. Non-secret per-env configNODE_ENV, URLs, feature flagsRailway (or committed defaults)❌

Only bucket 2 lives in 1Password and is what CI syncs. Redis/Mongo-runtime come from Railway itself β€” never mirror them into 1Password.

1Password structure ​

One item per service Γ— environment, named <service>-<tier>, with identical field labels across tiers (field label = the env var name) β€” one mental model, an env-agnostic sync, and the same item serves both the laptop and the matching Railway service:

Servicedevstagingproduction
agri-backendbackend-devbackend-stagingbackend-production
agri-frontendfrontend-devfrontend-stagingfrontend-production
agri-adminadmin-devadmin-stagingadmin-production
agri-mcpβ€”mcp-stagingmcp-production

All items live in vault railway-env (dev, staging and production).

The CI service account (railway-secrets-sync) has read-only access to the railway-env vault. Human accounts read it too.

SA-vault gotcha: a 1Password Service Account only sees the vaults it was granted. The Terraform SA sees only terraform; the sync SA sees only railway-env. If you run a script with OP_SERVICE_ACCOUNT_TOKEN set, op authenticates as that SA β€” env -u OP_SERVICE_ACCOUNT_TOKEN op … drops it so op authenticates as your user instead.

Local dev ​

Secrets sync from 1Password into a local gitignored .env on demand (pnpm --filter agri-backend env:sync), not on every server start β€” so the server never prompts 1Password on restart:

  • apps/agri-backend/.env.tpl (committed, no secrets) β€” every secret is an op:// reference (op://railway-env/backend-dev/OPENAI_API_KEY); non-secrets are literal.
  • apps/agri-backend/.env.local (gitignored, optional) β€” your machine-specific overrides, e.g. your frpc public URL for Vapi callbacks (CALL_WEBHOOK_URL=https://<you>-api.dev.review-time.info).

env:sync runs op inject on .env.tpl and appends .env.local (which wins), writing .env. pnpm dev / VS Code Backend: debug read that .env (auto-syncing once if it's missing). Re-run env:sync when the backend-dev item changes.

The web apps follow the same pattern with their own items and templates: agri-frontend (op://railway-env/frontend-dev/*, apps/agri-frontend/.env.tpl) and agri-admin (op://railway-env/admin-dev/*, apps/agri-admin/.env.tpl). pnpm --filter <app> env:sync and pnpm --filter <app> dev (auto-sync when .env is missing) work identically.

Add a new secret ​

  1. Add the field to the backend-dev item in 1Password (once).
  2. Add the op:// line to .env.tpl (reviewable in the PR).
  3. Add it to the matching Zod config factory (src/*.config.ts) β€” it throws at boot if missing.
  4. git pull β†’ everyone gets it at their next env:sync. A non-secret goes straight into .env.tpl as a literal; a machine-specific value goes into your .env.local.

Staging & production (Railway) ​

Railway serves the variables it stores; it does not read 1Password at runtime. 1Password stays the source of truth via a push sync β€” the sync-secrets-railway workflow runs a matrix over the Railway services (backend, frontend, admin, mcp), each reading its own <service>-<tier> item and pushing bucket-2 secrets into Railway with the Railway CLI. Reference variables (REDIS_URL, …) are never touched.

  • Matrix service β†’ Railway service <tier>:<service> (e.g. dev:backend, staging:admin, production:mcp). Services are per-environment β€” each tier has its own set, so a rename in one env never touches another.
  • mcp is staging/prod only β€” not deployed in dev, so the dev tier skips it.
  • Branch β†’ GitHub Environment β†’ Railway env β†’ item is resolved in one place (the workflow).
  • Change a secret in the 1Password item, run the workflow β†’ Railway is updated. Never edit the Railway dashboard by hand β€” the next sync would overwrite it anyway.
  • Frontend/admin are Vite β€” VITE_* are baked at build time, so a secret-only change takes effect on the workflow's redeploy (manual dispatch rebuilds), not just a var set.