Skip to main content

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

LocalDevelopmentProduction
Where it runsDocker Compose, your machineRailwayRailway
NODE_ENVdevdevelopmentproduction
Backendhttp://localhost:8000dev.api.closegram.comapi.closegram.com
Docshttp://localhost:3005 (or similar)dev.docs.closegram.comdocs.closegram.com
frontend-nextjshttp://localhost:3000dev.closegram.comclosegram.com
frontend-adminhttp://localhost:3001dev.admin.closegram.comadmin.closegram.com
PostgresLocal Docker container (unencrypted)Railway-managed, DB_NAME=closegram_devRailway-managed, DB_NAME=closegram
Analytics DBLocal Docker containerANALYTICS_DB_NAME=closegram_analytics_devANALYTICS_DB_NAME=closegram_analytics
Third-party credentials (Stripe, Firebase, AWS, Twilio, etc.)Dev/test-mode placeholdersSame as production — only the two DB names above differLive 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

  1. Which .env.<NODE_ENV> file the backend loads (dev.env.dev, development/production → real env vars injected by Railway, no local .env file involved).
  2. Which Sequelize config key sequelize-cli reads from database/config/config.jsdev, development, and production are three separate keys there. development was added specifically because Railway's "development" environment sets NODE_ENV=development, and without a matching key, sequelize-cli db:migrate (run as the Railway preDeployCommand, see predeploy.sh) throws config.development is undefined and aborts the deploy. It mirrors production's shape (SSL required — a real managed Postgres instance) rather than dev'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:

  1. Opt-in: only set RUN_SEEDS_ON_DEPLOY=true on the development service. Never set it on production.
  2. Backstop: even if it were set on production by mistake, database/config/config.js's production guard (above) would refuse to run the seed command — and a seed failure is deliberately non-fatal in predeploy.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):

VariableDevelopment valueProduction value
NODE_ENVdevelopmentproduction
DB_NAMEclosegram_devclosegram
ANALYTICS_DB_NAMEclosegram_analytics_devclosegram_analytics
FRONTEND_URLhttps://dev.closegram.comhttps://closegram.com
ADMIN_FRONTEND_URLhttps://dev.admin.closegram.comhttps://admin.closegram.com
WEBAUTHN_RP_IDclosegram.comclosegram.com
WEBAUTHN_ORIGINhttps://dev.closegram.comhttps://closegram.com
RUN_SEEDS_ON_DEPLOY (optional — see below)true, if you want every deploy to (re-)seed demo dataUnset — 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 ProductionSame 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.

VariableDevelopment valueProduction value
NEXT_PUBLIC_GRAPHQL_URLhttps://dev.api.closegram.com/web/graphqlhttps://api.closegram.com/web/graphql
NEXT_PUBLIC_SITE_URL (frontend-nextjs only)https://dev.closegram.comhttps://closegram.com
Every other NEXT_PUBLIC_* (Firebase, Stripe, PayPal, Google Maps, Giphy, AdSense, GAM, GA, Apple Sign-In)Same value as ProductionSame 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:

  1. DEV_ENVIRONMENT Swift compilation flag — if set, always resolves to development regardless 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 via OTHER_SWIFT_FLAGS="-DDEV_ENVIRONMENT".
  2. API_ENVIRONMENT process environment variable, read only in DEBUG builds (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 place API_HOST is already set for pointing the simulator at a machine's LAN IP.
  3. Fallback: DEBUG builds with neither of the above set default to local; Release builds default to production.

Each environment resolves its own GraphQL HTTP/WS URL and LiveKit WS URL:

LocalDevelopmentProduction
GraphQLhttp://<API_HOST>:8000/web/graphqlhttps://dev.api.closegram.com/web/graphqlhttps://api.closegram.com/web/graphql
GraphQL WSws://<API_HOST>:8000/web/graphqlwss://dev.api.closegram.com/web/graphqlwss://api.closegram.com/web/graphql
LiveKitws://<API_HOST>:7880wss://livekit.closegram.comwss://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.