Deployment Environments
The platform runs in three environments: local (Docker Compose on a developer's machine), development (a real Railway deployment, used for staging/testing against production-shaped infra), and production (Railway, live traffic). This page is the map of how those three relate — which NODE_ENV/domain each one uses, what's shared vs. separate, and how each client (backend, both Next.js frontends, docs, iOS) is pointed at the right one. For the exhaustive per-variable reference (what each .env key does, where to obtain it), see Environment Setup.
The three environments
| Local | Development | Production | |
|---|---|---|---|
| Where it runs | Docker Compose, your machine | Railway | Railway |
NODE_ENV | dev | development | production |
| Backend | http://localhost:8000 | dev.api.closegram.com | api.closegram.com |
| Docs | http://localhost:3005 (or similar) | dev.docs.closegram.com | docs.closegram.com |
| frontend-nextjs | http://localhost:3000 | dev.closegram.com | closegram.com |
| frontend-admin | http://localhost:3001 | dev.admin.closegram.com | admin.closegram.com |
| Postgres | Local Docker container (unencrypted) | Railway-managed, DB_NAME=closegram_dev | Railway-managed, DB_NAME=closegram |
| Analytics DB | Local Docker container | ANALYTICS_DB_NAME=closegram_analytics_dev | ANALYTICS_DB_NAME=closegram_analytics |
| Third-party credentials (Stripe, Firebase, AWS, Twilio, etc.) | Dev/test-mode placeholders | Same as production — only the two DB names above differ | Live credentials |
Development intentionally reuses production's third-party keys — it's meant to validate against production-shaped infrastructure, not to be a fully sandboxed copy. The only two Railway variables that should actually differ between the "development" and "production" Railway environments are DB_NAME and ANALYTICS_DB_NAME; everything else (API keys, secrets, WEBAUTHN_RP_ID, etc.) is the same value in both, with only the *_URL variables and NODE_ENV changing per the table below.
NODE_ENV matters for two independent things
- Which
.env.<NODE_ENV>file the backend loads (dev→.env.dev,development/production→ real env vars injected by Railway, no local.envfile involved). - Which Sequelize config key
sequelize-clireads fromdatabase/config/config.js—dev,development, andproductionare three separate keys there.developmentwas added specifically because Railway's "development" environment setsNODE_ENV=development, and without a matching key,sequelize-cli db:migrate(run as the RailwaypreDeployCommand, seepredeploy.sh) throwsconfig.development is undefinedand aborts the deploy. It mirrorsproduction's shape (SSL required — a real managed Postgres instance) rather thandev's (no SSL — local Docker only), since Railway's development database is a real remote instance too, just a separate one.
Seeders are tracked, not just migrations
Every environment key in database/config/config.js sets seederStorage: 'sequelize' with seederStorageTableName: 'SequelizeSeedMeta'. Without this, sequelize-cli defaults to a local JSON file to track which seeders ran — meaningless against a real Postgres instance, since it means "already seeded" state lives on whichever machine last ran npm run seed rather than in the database itself. With it, npm run seed (db:seed:all) records every seeder file it successfully runs into SequelizeSeedMeta (auto-created on first run, the same way SequelizeMeta is for migrations) and skips anything already recorded on the next run — so re-running npm run seed against an already-seeded database (Railway's development environment, most importantly, since its 37 seeder files include several bulk-* ones that insert a lot of demo data) is a no-op instead of re-inserting duplicates or failing on unique-constraint violations.
db:reset (full local reset without tearing down the Docker container) runs seed:undo:all before migrate:undo:all — in that order, since the seeders' own down() functions need the app tables to still exist, and undoing them also clears SequelizeSeedMeta so the following npm run seed re-seeds everything instead of seeing stale "already done" entries and seeding nothing into the freshly-recreated empty tables. db:fresh doesn't need this, since docker:reset recreates the container (and therefore SequelizeSeedMeta) from scratch.
Seeders are for local and development only — never production
Seeders insert throwaway demo/dummy data (fake users, bulk posts/follows/comments, an admin account, etc.). database/config/config.js refuses to run any db:seed* command (db:seed, db:seed:all, db:seed:undo, db:seed:undo:all) whenever NODE_ENV resolves to production, regardless of whether it's invoked via npm run seed or sequelize-cli directly — migrations and normal app boot are unaffected, since the check only fires for seed commands.
Optionally running seeders as part of a deploy
predeploy.sh's step 3 runs sequelize-cli db:seed:all when the Railway variable RUN_SEEDS_ON_DEPLOY is set to true on that service — unset (or anything else) skips it, which is the default for every service today. This is opt-in rather than unconditional on purpose: predeploy.sh is the same script for every Railway environment, so an unconditional seed step would also fire on production. Two independent things keep that safe:
- Opt-in: only set
RUN_SEEDS_ON_DEPLOY=trueon thedevelopmentservice. Never set it onproduction. - Backstop: even if it were set on
productionby mistake,database/config/config.js's production guard (above) would refuse to run the seed command — and a seed failure is deliberately non-fatal inpredeploy.sh(same pattern as the analytics-migration step), so it would never abort the deploy, only skip seeding and log why.
Because seeding is tracked via SequelizeSeedMeta (see below), leaving RUN_SEEDS_ON_DEPLOY=true on indefinitely is safe — every deploy after the first is a no-op for seeders that already ran.
Backend: Railway dashboard checklist
Per Railway environment (development and production are configured separately in the Railway dashboard — these are not read from any committed file):
| Variable | Development value | Production value |
|---|---|---|
NODE_ENV | development | production |
DB_NAME | closegram_dev | closegram |
ANALYTICS_DB_NAME | closegram_analytics_dev | closegram_analytics |
FRONTEND_URL | https://dev.closegram.com | https://closegram.com |
ADMIN_FRONTEND_URL | https://dev.admin.closegram.com | https://admin.closegram.com |
WEBAUTHN_RP_ID | closegram.com | closegram.com |
WEBAUTHN_ORIGIN | https://dev.closegram.com | https://closegram.com |
RUN_SEEDS_ON_DEPLOY (optional — see below) | true, if you want every deploy to (re-)seed demo data | Unset — never set this on production |
Everything else (DB_HOST/DB_USER/DB_PASSWORD, AWS, Stripe, PayPal, Firebase, Apple, APNs, Twilio, BigQuery, etc.) | Same value as Production | Same value as Development |
A single WEBAUTHN_RP_ID of closegram.com is valid across closegram.com, dev.closegram.com, admin.closegram.com, and dev.admin.closegram.com — WebAuthn relying-party IDs match the registrable domain and its subdomains, so passkeys don't need a per-subdomain RP ID. WEBAUTHN_ORIGIN, unlike the RP ID, does need to match the exact origin the browser is on, so it differs between dev and prod.
The live status of every one of these (whether it's set, without ever exposing its value) is visible at /system/environment in the admin panel — see Environment Status. Check that page after configuring a Railway environment rather than re-deriving the list by hand.
frontend-nextjs / frontend-admin: Railway dashboard checklist
Both are Next.js apps deployed via Docker on Railway. NEXT_PUBLIC_* variables are inlined into the JS bundle at build time, so they must be declared as Docker ARGs (already done in both Dockerfiles) and set as Railway build-time variables, not just runtime ones — a value only set at runtime never reaches the bundle.
| Variable | Development value | Production value |
|---|---|---|
NEXT_PUBLIC_GRAPHQL_URL | https://dev.api.closegram.com/web/graphql | https://api.closegram.com/web/graphql |
NEXT_PUBLIC_SITE_URL (frontend-nextjs only) | https://dev.closegram.com | https://closegram.com |
Every other NEXT_PUBLIC_* (Firebase, Stripe, PayPal, Google Maps, Giphy, AdSense, GAM, GA, Apple Sign-In) | Same value as Production | Same value as Development |
Backend CORS (ALLOWED_ORIGINS in apps/backend/api/server.js) is built from FRONTEND_URL/ADMIN_FRONTEND_URL, so once those are set correctly per-environment on the backend service (see above), both frontends can reach it with no separate CORS configuration.
docs: Railway dashboard checklist
apps/docs is a static Docusaurus build with no environment-specific variables — docs.closegram.com and dev.docs.closegram.com serve the same build output. No backend/frontend-style checklist applies here; the only per-environment concern is which domain Railway routes to which deployment.
iOS
Config/APIConfig.swift resolves one of three environments at launch, in this order:
DEV_ENVIRONMENTSwift compilation flag — if set, always resolves todevelopmentregardless of build configuration. This is the only mechanism that works for an archived/distributed build (TestFlight, ad hoc), since there's no launching Xcode process or shell environment to read at that point. Set viaOTHER_SWIFT_FLAGS="-DDEV_ENVIRONMENT".API_ENVIRONMENTprocess environment variable, read only inDEBUGbuilds (i.e. when launched from Xcode) —"development"or"production"; anything else (including unset) falls through to local. Set it in the Xcode scheme's Run action ("Arguments" tab → Environment Variables), the same placeAPI_HOSTis already set for pointing the simulator at a machine's LAN IP.- Fallback:
DEBUGbuilds with neither of the above set default to local;Releasebuilds default to production.
Each environment resolves its own GraphQL HTTP/WS URL and LiveKit WS URL:
| Local | Development | Production | |
|---|---|---|---|
| GraphQL | http://<API_HOST>:8000/web/graphql | https://dev.api.closegram.com/web/graphql | https://api.closegram.com/web/graphql |
| GraphQL WS | ws://<API_HOST>:8000/web/graphql | wss://dev.api.closegram.com/web/graphql | wss://api.closegram.com/web/graphql |
| LiveKit | ws://<API_HOST>:7880 | wss://livekit.closegram.com | wss://livekit.closegram.com |
<API_HOST> is the API_HOST environment variable (falls back to 127.0.0.1) — set it to your machine's LAN IP (ipconfig getifaddr en0 on macOS) so a physical device on the same network can reach your local backend; the simulator can use 127.0.0.1 directly.
Development and production currently point at the same LiveKit deployment (livekit.closegram.com) — there's no separate dev LiveKit instance today. If that changes, add a development case to APIConfig.liveKitWSUrl.
Fastlane
build_dev, build_release, beta, and beta_nogit all accept an env: lane option (falling back to the API_ENVIRONMENT shell variable if omitted):
# Points the built app at dev.api.closegram.com
bundle exec fastlane build_dev env:development
# Points a TestFlight build at dev.api.closegram.com instead of production
bundle exec fastlane beta env:development
When env resolves to "development", the lane passes xcargs: 'OTHER_SWIFT_FLAGS="-DDEV_ENVIRONMENT"' to build_app, which is exactly the compilation flag APIConfig.swift checks first (see above) — so this is the only reliable way to point an archived build at development, since archived builds have no runtime environment variables to read.
release (the App Store submission lane) deliberately has no env override — an App Store build always targets production, by design; there is no scenario where shipping a development-pointed build to the App Store is correct.
Alternatively, set API_ENVIRONMENT=development directly in fastlane/.env (copied from .env.default, which documents this variable) instead of passing env: on every invocation.