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:
| Bucket | Examples | Lives in | Synced by CI? |
|---|---|---|---|
| 1. Railway-provisioned infra | REDIS_URL, MONGODB_URI (runtime), RAILWAY_PUBLIC_DOMAIN | Railway reference variables | β never |
| 2. External third-party secrets | OpenAI, Vapi, Twilio, AWS, Firebase, Slack, Notion, Reducto, FieldX, Grafana, Mongo Atlas (MONGODB_ASSETS_URI) | 1Password item | β yes |
| 3. Non-secret per-env config | NODE_ENV, URLs, feature flags | Railway (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:
| Service | dev | staging | production |
|---|---|---|---|
| agri-backend | backend-dev | backend-staging | backend-production |
| agri-frontend | frontend-dev | frontend-staging | frontend-production |
| agri-admin | admin-dev | admin-staging | admin-production |
| agri-mcp | β | mcp-staging | mcp-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 onlyrailway-env. If you run a script withOP_SERVICE_ACCOUNT_TOKENset,opauthenticates as that SA βenv -u OP_SERVICE_ACCOUNT_TOKEN op β¦drops it soopauthenticates 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 anop://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 β
- Add the field to the
backend-devitem in 1Password (once). - Add the
op://line to.env.tpl(reviewable in the PR). - Add it to the matching Zod config factory (
src/*.config.ts) β it throws at boot if missing. git pullβ everyone gets it at their nextenv:sync. A non-secret goes straight into.env.tplas 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.