Skip to main content

Configuration

This is the most involved step — but only because it’s the most important one. Get this right and everything else just works. Refract splits configuration into two layers: a .env.development file at the repo root (injected by Docker Compose at the container boundary), and typed TypeScript config modules under apps/backend/src/configuration/ that resolve everything into a clean ConfigType object. The rest of the app never touches process.env directly — it receives a fully resolved tools object. Clean, predictable, testable.

How the pattern works

One TypeScript file per environment lives under apps/backend/src/configuration/:
apps/backend/src/configuration/
├── development.ts    ← local Docker dev
├── staging.ts        ← Railway staging
├── production.ts     ← Railway production
└── test.ts           ← automated test runs only
Each file exports a ConfigType object. Values come from process.env only inside that file — nowhere else in the codebase. Docker Compose injects variables from .env.development at the container boundary. The result: the rest of your application receives a fully resolved, typed tools object. No scattered process.env calls, no runtime surprises, no “works on my machine” moments.

What to set for local dev

SectionLocal dev valueNotes
frontend.urlhttp://localhost:4000Set FRONTEND_URL — Vite listens on 4000
backend.urlhttp://localhost:3000Set BACKEND_URL — Express serves API and GraphQL on 3000
backend.port3000Parsed from BACKEND_URL in development.ts
loggerPino, pretty: trueAlready checked in — no changes needed. → Pino
mailerclient: 'local'Logs rendered emails to console, no credentials needed. → Local mailer
queueclient: 'bullmq' + connection.url from REDIS_URLDefault stack: BullMQ on the same Redis as cache (redis://memory:6379 in Compose). Optional swap to SQS: SQS.
analyticsclient: 'local'Already checked in — logs events to console. → Local analytics
metricsStatsD, mock: trueNo StatsD server needed locally — mock silently no-ops all calls. → StatsD
paymentProcessorclient: 'stripe'Mandatory — always Stripe in local dev. Test keys go in .env.development. → Stripe
cacheRedisCompose sets REDIS_URL=redis://memory:6379 — shared with BullMQ. → Redis
rdsPGHOST=rdsUse the Docker service name rds, not localhost. → Sequelize
paymentProcessor is always Stripe in local dev — not stripeMock. The stripeMock client is only used in automated test runs via test.ts. If you’re running the stack locally, you need real Stripe test keys.

Your .env.development file

Open the .env.development file you created in Step 2 and fill in the following values. Everything with a ... needs a real value from you:
# URLs
FRONTEND_URL=http://localhost:4000
BACKEND_URL=http://localhost:3000

# Postgres — use the Docker service name, not localhost
PGHOST=rds
PGPORT=5432
PGUSER=postgres
PGPASSWORD=choose_a_local_password
PGDATABASE=refract_dev

# Redis — cache + BullMQ queue (required)
REDIS_URL=redis://memory:6379

# Optional AWS-style vars (S3, SES, etc.). Not used for the default BullMQ queue.
# To adopt SQS for queues, see ../tooling/queue/SQS.md
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test
AWS_EMAIL_ADDRESS=dev@example.com

# Stripe test keys — get these from dashboard.stripe.com → Developers → API keys
STRIPE_SECRET_KEY=sk_test_...
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_API_VERSION=2026-01-28.clover

# Railway (optional — only needed for deployment)
# RAILWAY_TOKEN=
# RAILWAY_PROJECT_ID=
Where to get your Stripe webhook secret locally: after running make stripe-login in Step 4, the containerized Stripe CLI will print a whsec_... value in the logs. Copy that into STRIPE_WEBHOOK_SECRET and restart the stack.
Never commit .env.development. It’s gitignored but worth saying again — this file contains real credentials. Rotate anything that was ever committed by mistake.

A note on PGHOST=rds

When your backend container connects to Postgres, it communicates over the Docker Compose network rather than directly from your computer. That’s why the Postgres service is referred to as rds in your compose.yml file—use rds as the hostname here. If you accidentally use localhost, the backend container won’t be able to locate Postgres, which will result in a connection error.
PGHOST=rds inside Docker, PGHOST=localhost if you ever connect with a local Postgres client like TablePlus or psql from your machine.

Swapping pluggable tool implementations

Every tool in the table above is pluggable — you swap providers by changing the client value in the relevant config file and updating dependencies. No loader code changes are needed.
ToolDefaultAlternativesFull guide
loggerPinoConsolePino · Console
mailerLocalTwilio SendGridSendGrid · Local
queueBullMQSQSBullMQ · SQS
analyticsLocalAmplitudeAmplitude · Local
metricsStatsD (mock)StatsD (real)StatsD
paymentProcessorStripeStripe
cacheRedisRedis
rdsSequelizeSequelize
The swap pattern is the same for every tool:
  1. Update the client value in development.ts, production.ts, and test.ts.
  2. In apps/backend/package.json, add the new tooling-* workspace package and remove the old one.
  3. Run make pnpm-lockfile-update then make pnpm-install.
  4. Follow the setup steps in the relevant tooling doc (environment variables, Compose services, etc.).
For the queue swap specifically (BullMQ ↔ SQS), see the full guide in SQS or BullMQ. For architecture detail, see Tooling system.
You’ve got the hardest part behind you. Everything from here is a command away. Next: Step 4 — First Run