Skip to content

Environment profiles

The backend runs under one of three profiles, chosen by the APP_ENV variable: dev, test, or prod. A separate flag, TESTING, marks an automated test run and works independently of the profile. This page is the reference for both.

Contents

How selection works

Settings read APP_ENV from the process environment (shell, Docker, Railway, CI), not from a dotenv file, because that value decides which file to load. The loader then reads .env first, then .env.<APP_ENV> on top, so a profile value overrides the shared base. When APP_ENV is unset, the dev profile applies.

Profile APP_ENV Where it runs Database Debug Rate limiting
dev unset or dev Local machine and the Railway dev service Set by DATABASE_URL: PostgreSQL in Docker locally (SQLite if unset), PostgreSQL on Railway off (the env/.env.dev.example template turns it on) on
test test Staging deploy and the test suite PostgreSQL (staging), in-memory SQLite or PostgreSQL (tests) off on when deployed, off under pytest
prod prod Railway production PostgreSQL off on

The two axes

APP_ENV and TESTING answer different questions and never substitute for each other.

  • APP_ENV (dev / test / prod) is the deployment profile. It drives debug output, CORS origins, the database it expects, and the deploy startup guard.
  • TESTING (1/true) marks an automated test run. Only automated test runs set it: pytest, the CI jobs, and scripts/ci/e2e-local.sh. It disables rate limiting and the deploy startup checks, and is never present on a deployed service.

This separation is what lets staging be realistic. A staging deploy sets APP_ENV=test with no TESTING, so its rate limits match production. The pytest suite sets APP_ENV=test together with TESTING=1, which keeps tests fast. The unit and integration suites mostly build their own in-memory SQLite engines, and the end-to-end runs use PostgreSQL.

Profiles in detail

dev

The default. Debug off, localhost CORS, and no startup guard on a laptop. DATABASE_URL picks the database: the code defaults to SQLite, and the base .env copied from env/.env.example points it at the PostgreSQL in docker/docker-compose.yml. It needs no profile file of its own, but it does need the base .env: SECRET_KEY has no default and the application refuses to start without it. The env/.env.dev.example template turns debug on, and the same profile on Railway is a deploy, so the guard applies to it.

uv run uvicorn api.main:app --reload

test (staging and the test suite)

Two consumers share this profile. A deployed staging service uses PostgreSQL with debug off and rate limiting on, which mirrors production for manual QA. It meets the same startup invariants as production: a strong secret, PostgreSQL, Redis, a privileged MIGRATION_DATABASE_URL, the Cloudflare origin secret, non-localhost CORS_ORIGINS, a non-loopback FRONTEND_BASE_URL, a working email provider, and debug off. The automated suite runs the same profile but adds TESTING=1, so it turns rate limiting off and skips the startup guard. Its unit and integration tests mostly use in-memory SQLite, and its end-to-end runs use PostgreSQL. The test conftests set both variables before any api import.

prod

PostgreSQL, debug off. Every deployed service runs a startup guard (_validate_deploy_invariants in api/config.py), the Railway dev service included: the guard keys off the deploy, not the profile name. A weak or default SECRET_KEY, a sqlite DATABASE_URL, a missing REDIS_URL, or localhost-only CORS_ORIGINS fails startup immediately, rather than running with insecure defaults. So does a missing CLOUDFLARE_ORIGIN_SECRET, which proves requests came through the Cloudflare edge. Each deployed environment needs its own value, paired with a Transform Rule for that environment's API host. So does a Turnstile bot check switched on without a secret and a hostname list, since every request would then be refused for want of configuration. The check left switched off does not fail startup: it is the kill switch for a Cloudflare siteverify outage, and the check is fail-closed, so taking that switch away would leave a revert as the only way out. A deploy in that state records turnstile_disabled at ERROR on startup instead, which puts the four unguarded endpoints in Sentry and the log drain rather than nowhere.

Configuration files

File Role
api/config.py Defines the Environment enum, resolves APP_ENV, builds the dotenv list, and runs the deploy guard
.env Shared base values, loaded first (gitignored)
.env.<stage> Per-profile overrides, loaded second (gitignored)
env/.env.example, env/.env.dev.example, env/.env.test.example, env/.env.prod.example Tracked templates to copy from
frontend/.env.development, .env.production, .env.test Vite per-mode values, mainly VITE_API_URL

Local use

Copy the template for the profile you want, then fill in the real values:

cp env/.env.example .env              # the shared base, always needed
cp env/.env.dev.example .env.dev      # local development
cp env/.env.test.example .env.test    # staging-like run
cp env/.env.prod.example .env.prod    # production-like run

Pick a profile by exporting APP_ENV:

APP_ENV=dev uv run uvicorn api.main:app --reload
APP_ENV=prod uv run uvicorn api.main:app

Docker

The api service in docker/docker-compose.yml reads APP_ENV with ${APP_ENV:-dev}, so local Compose runs as dev unless you override it. docker/Dockerfile.dev pins APP_ENV=dev. The production docker/Dockerfile does not hardcode it. The value comes from the platform.

CI

.github/workflows/ci.yml runs on every push and pull request to dev, test, and prod, so the deploy branches get the same full pipeline as prod. That pipeline covers lint, Python and frontend tests, backend and Playwright end-to-end tests, and SonarCloud (the last on pull requests and on pushes to prod). It sets APP_ENV=test on the python-tests, backend-e2e, and e2e jobs, including the steps that start a live server. scripts/ci/e2e-local.sh sets the same value for local end-to-end runs.

The last job, check (shown as Check), waits on every other job in the workflow and fails unless each one succeeded or was skipped, so a failed or cancelled run ends in one red result. It runs even when a job it waits on fails, which a plain needs would not. Branch protection on dev requires Check.

.github/workflows/merge-guard.yml runs on pull requests into dev, test, and prod, including the labeled and unlabeled events, and its job Merge guard fails while the pull request carries the do-not-merge label. It is a workflow of its own so that setting a label does not rerun the whole pipeline above. It checks nothing out and reads only the label list from the event. Branch protection on dev also requires Merge guard, so the label blocks the merge. The check reads the labels as they were when its event fired, so a re-run of an old run replays the old list.

Development workflow

Each environment tracks one git branch, and a push to that branch redeploys the environment's Railway services.

Branch Environment Profile Services
dev dev APP_ENV=dev dev-backend, dev-frontend, dev-db, dev-photos
test test APP_ENV=test test-backend, test-frontend, test-db, test-photos
prod prod APP_ENV=prod prod-backend, prod-frontend, prod-db, prod-photos

Work moves in one direction. Cut a feature branch from dev, open a pull request back into dev, and the merge auto-deploys to dev for a first live check. When a slice is ready for QA, promote dev to test. That deploy runs the test profile with production-like settings (rate limiting on, debug off), so manual testing is realistic. Promote test to prod to release, which deploys the prod profile and serves the public site. Hotfixes travel the same path instead of landing on prod directly.

Every pull request into dev gets one size label from .github/workflows/pr-size.yml, counted over the changed lines without lock files, notebooks and files deleted whole: size:S under 300, size:M from 300 to 600, size:L from 601 to 1000, size:XL above that. The target is size:M or smaller. The label informs and never blocks a merge. The workflow runs on pull_request_target, which reads it from dev, so a change to the workflow itself takes effect on a pull request at its next push or edit after the change merges.

Each environment has its own isolated Railway Postgres (*-db) and its own Railway Storage Bucket for profile photos (*-photos).

Railway deployment

The root railway.toml carries the API build and deploy settings: it points at docker/Dockerfile and the /api/v1/health check. Railway reads that file from the repository root for every service in the project, so the frontend cannot share it without trying to build the API image. The frontend service therefore has its own config file, frontend/railway.json, chosen per service through the Railway "Railway Config File" setting (the absolute path /frontend/railway.json). It pins frontend/Dockerfile and a / health check. Everything else that differs between environments lives in per-environment service variables.

Variable dev test prod
APP_ENV dev test prod
SECRET_KEY strong value (openssl rand -hex 32) strong value strong value
DATABASE_URL dev become_app role test become_app role prod become_app role
MIGRATION_DATABASE_URL privileged role, Alembic only privileged role, Alembic only privileged role, Alembic only
CORS_ORIGINS dev origins test origins prod origins
REDIS_URL dev Redis test Redis prod Redis
CLOUDFLARE_ORIGIN_SECRET own secret own secret own secret, each matching that environment's Cloudflare Transform Rule
TURNSTILE_ENABLED / TURNSTILE_SECRET_KEY / TURNSTILE_HOSTNAMES true, widget secret, dev frontend host true, widget secret, test frontend host true, widget secret, prod frontend hosts
DEBUG false false false
LOG_LEVEL DEBUG INFO INFO
API_PUBLIC_URL dev API URL test API URL prod API URL
VITE_API_URL / VITE_SENTRY_DSN / VITE_APP_ENV / VITE_TURNSTILE_SITE_KEY (frontend build args) dev values test values prod values
BUCKET_NAME / BUCKET_ENDPOINT / BUCKET_ACCESS_KEY_ID / BUCKET_SECRET_ACCESS_KEY injected from dev-photos injected from test-photos injected from prod-photos

A deployed service that leaves APP_ENV unset falls back to the dev profile. The startup guard still runs there, because it keys off RAILWAY_ENVIRONMENT_NAME rather than the profile name, so an unset variable cannot weaken a deploy. Set the profile explicitly anyway (dev, test, or prod), so that the log level, the log format, and the .env.<APP_ENV> overlay are the ones you meant.

LOG_LEVEL appears as a service variable even though api/config.py already defaults it per profile (DEBUG on dev, INFO on test and prod). The default is the safety net for a service whose variable was never set. The variable is what makes the level visible to whoever opens the service without reading the settings module. An explicit value always wins over the profile default.

Log format follows the deploy, not the profile. api/logging_config.py emits human-readable text only when the profile is dev and RAILWAY_ENVIRONMENT_NAME is absent, i.e. on a laptop. The Railway dev service is a deploy, so it emits JSON like staging and production and its extra fields stay indexable in the drain.

VITE_TURNSTILE_SITE_KEY is the sitekey paired with the API's TURNSTILE_SECRET_KEY on the same service. Provisioning an environment that sets the three TURNSTILE_* variables and omits this one switches the check on with no widget in the bundle: no token reaches the API, and every sign-in, sign-up, password reset and resend answers 403.

In frontend/Dockerfile, declare an ARG and an ENV for every VITE_* variable the SPA reads. Railway passes service variables to the build as build args, but Docker only exposes the ones the Dockerfile declares. Vite then inlines undefined for anything missing at build time, silently and with no build error. That gap left VITE_SENTRY_DSN set on all three frontend services while Sentry.init was tree-shaken out of every bundle.

Database schema and access

Alembic versions the schema. Migrations live in migrations/. migrations/env.py reads its target from ALEMBIC_DATABASE_URL, then MIGRATION_DATABASE_URL, then DATABASE_URL, and treats SQLModel.metadata as the source of truth. Every deploy runs alembic upgrade head once through the preDeployCommand in railway.toml, before the new version goes live, so a failed migration blocks the release instead of starting a broken one. create_db_and_tables() still builds the schema directly, but only for SQLite and TESTING=1 runs, including the end-to-end tests on PostgreSQL. On any other PostgreSQL database, local or deployed, it is a no-op and Alembic stays in charge.

The application connects through a least-privilege role, become_app. It reads and writes the application tables but cannot create, alter, or drop objects, is not a superuser, and cannot bypass row-level security. Each backend therefore carries two database URLs: DATABASE_URL points at become_app for the running app, while MIGRATION_DATABASE_URL points at the privileged role that Alembic uses for DDL. api/db/engine.py hardens the connection: it requires TLS on deployed databases (a laptop run on a loopback or socket database uses prefer), tags each connection with an application_name, and sets per-session statement and idle-in-transaction timeouts so one query cannot monopolize the database. The schema also carries domain CHECK constraints (fuzzy-number ordering, positive expert counts, scale bounds) so the database rejects invalid rows on its own.

On production and staging each Postgres instance is reachable only over Railway's internal network: the public TCP proxies are gone, so the internet cannot reach those databases. A proxy can be recreated briefly when a laptop needs direct access for a migration or a dump.

Photo storage

Profile photos live in a per-environment Railway Storage Bucket (dev-photos, test-photos, prod-photos), reached over the S3 API. Buckets are private, so the backend serves each image itself through the public proxy GET /api/v1/users/{id}/photo. The users.photo_url column stores the object key, and responses carry the proxy URL built from API_PUBLIC_URL. Attaching a bucket to a service auto-injects BUCKET_NAME, BUCKET_ENDPOINT, BUCKET_ACCESS_KEY_ID, and BUCKET_SECRET_ACCESS_KEY. When they are absent (plain local runs), photo upload switches off and the rest of the API keeps working.

Email delivery

Registration and password reset both send mail through EMAIL_PROVIDER, which is http (a Resend-style API, described in api/README.md) or console. The console sender sends nothing: it logs each link with any token masked and prints the full link to stdout. Registration now depends on delivery in a way password reset never did: the account is created unverified, and nobody can log in to it until someone opens its activation link. A deployment whose mail provider is broken or misconfigured therefore creates accounts nobody can activate. The deploy guard already required a working provider for password reset (_validate_deploy_invariants in api/config.py fails startup on a deployed service without one). That same check now also gates whether a fresh signup is usable.

Two settings gate the address checks in api/services/email_policy.py that run before a registration reaches the database, both on by default. DISPOSABLE_EMAIL_BLOCKING_ENABLED rejects a vendored list of known disposable-mail domains. MX_CHECK_ENABLED rejects a domain that does not exist, has no MX, A, or AAAA record, or publishes a null MX (RFC 7505), and a resolver timeout or other inconclusive result fails open rather than rejecting. Either check can be turned off with a Railway variable, with no code change, if it starts rejecting real signups. The value takes effect when the service restarts, which setting the variable triggers. EMAIL_VERIFICATION_TOKEN_TTL_HOURS (default 24) sets how long an activation link stays redeemable. That is longer than the one-hour password-reset window, because people routinely open an activation email the next morning.

Current status

All three environments run entirely on Railway, each with its own isolated Postgres and photo bucket. The database layer is hardened the same way across them: Alembic owns the schema, the app connects as the least-privilege become_app role, and the production and staging databases are internal-only.

  • prod is live: https://www.becomify.app (frontend) and https://api.becomify.app (API), APP_ENV=prod. Database is Railway Postgres (prod-db). Profile photos live in a Railway Storage Bucket (prod-photos) served through the API photo proxy.
  • test / staging is live from test: https://harbor.becomify.app (frontend) and https://api-harbor.becomify.app (API), on its own Railway Postgres (test-db) and bucket (test-photos), APP_ENV=test.
  • dev deploys from dev: https://atelier.becomify.app (frontend) and https://api-atelier.becomify.app (API), on its own Railway Postgres (dev-db) and bucket (dev-photos). It also runs locally without selecting a profile, since dev is the default, though the base .env with SECRET_KEY is still required.

Dev and staging moved off their generated *.up.railway.app hosts on 2026-07-31. They had to. up.railway.app is on the Public Suffix List, so a frontend and an API on two of those hosts count as different sites. The browser never sent the SameSite=Strict session cookies between them. Login answered 200 and the next request answered 401, which looked like a broken session rather than a domain-topology problem. Production was never affected, since www.becomify.app and api.becomify.app share one registrable domain. The names avoid dev and staging so guessing does not find the environments. Certificate Transparency logs publish the certificates anyway, so that is a speed bump rather than access control. Adding a domain, the _railway-verify TXT record a proxied CNAME needs, and the cutover order are covered by the cloudflare-operations skill.

Where the code lives

The selector and guard live in api/config.py: Environment (the enum), _resolve_environment() (reads APP_ENV), _env_files_for() (builds the .env plus .env.<stage> list), and the _validate_deploy_invariants model validator, which guards every deployed service: prod, a deployed test, and the Railway dev service. Rate limiting reads settings.testing in api/middleware/rate_limit.py.