Configuration
Rudder configuration spans three layers. Each has a clear responsibility, and putting a value in the wrong layer is the most common source of confusion for developers new to the framework.
| Layer | File(s) | Purpose |
|---|---|---|
| Environment | .env |
Secrets and environment-specific values |
| Runtime config | config/*.ts |
Named, typed objects that read from .env |
| Framework wiring | bootstrap/app.ts |
Server adapter, providers, routing loaders |
There is no rudderjs.config.ts. bootstrap/app.ts is the framework wiring file — pure structural decisions, no business logic.
Environment variables
Secrets and environment-specific values live in .env:
APP_NAME=MyApp
APP_ENV=local
APP_DEBUG=true
PORT=3000
CORS_ORIGIN=http://localhost:3000
DATABASE_URL="file:./dev.db"
AUTH_SECRET=change-me-in-production
APP_URL=http://localhost:3000
QUEUE_DRIVER=sync
MAIL_DRIVER=logNever commit .env. Provide .env.example as a template for teammates — it's also the source of typed Env keys and what rudder env:sync diffs your .env against.
Runtime config
Each config/*.ts file exports a plain object that reads values from the environment using the Env helper:
// config/server.ts
import { Env } from '@rudderjs/support'
export default {
port: Env.getNumber('PORT', 3000),
trustProxy: Env.getBool('TRUST_PROXY', false),
cors: {
origin: Env.get('CORS_ORIGIN', '*'),
methods: 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
headers: 'Content-Type,Authorization',
},
}The Env helper
| Method | Description |
|---|---|
Env.get(key, fallback?) |
String value or fallback. Throws if the key is missing AND no fallback was supplied. |
Env.getNumber(key, fallback?) |
Parsed number (via Number(...), so floats are accepted); throws on NaN. |
Env.getBool(key, fallback?) |
Returns true for 'true' (case-insensitive) or '1'; any other value is false. |
Env.has(key) |
Existence check; doesn't read the value. |
For critical values, validate at startup with defineEnv:
import { defineEnv } from '@rudderjs/support'
import { z } from 'zod'
export const env = defineEnv(
z.object({
DATABASE_URL: z.string().min(1),
AUTH_SECRET: z.string().min(32),
APP_ENV: z.enum(['local', 'production', 'test']),
})
)This throws at boot if any required variable is missing or malformed — before your app starts serving traffic.
Typed Env
Env.get('…') autocompletes the keys your app declares — and a declared key's read is checked at the call site:
Env.get('DATABASE_URL') // autocompleted from .env.example
Env.get('DATABASE_URI') // typo — not rejected (loose overload), but throws
// "Missing environment variable" at first readThe keys come from .env.example — the committed contract of what your app expects — never from .env itself (secret, per-machine, absent in CI). @rudderjs/vite's env scanner parses it on every dev/build and emits .rudder/types/env.d.ts:
// .rudder/types/env.d.ts — AUTO-GENERATED; commit it
declare module '@rudderjs/support' {
interface EnvRegistry {
'APP_NAME': string
'DATABASE_URL': string
'AUTH_SECRET': string
}
}Three rules worth knowing:
- Commented-out keys are not declared.
# OPENAI_API_KEY=in the example is an optional suggestion, not contract — uncomment it to declare it. - Unknown keys don't error.
Env.get()keeps its loosestringoverload because framework packages read keys your app doesn't declare (Env.get('REDIS_URL')). Same softness asroute()names and typedconfig(). For a hard-rejecting wrapper:const envStrict = <K extends keyof EnvRegistry & string>(key: K) => Env.get(key). - All values are typed
string— that's the runtime truth ofprocess.env. Parse at the edge withEnv.getNumber/Env.getBool/defineEnv.
rudder env:sync
The same scan, on demand and with a second job — diffing your .env against the contract:
pnpm rudder env:sync # regenerate env.d.ts + report drift
pnpm rudder env:sync --fix # …and append missing keys to .env with their example valuesA teammate adds STRIPE_KEY to .env.example, you pull, your .env silently lacks it — env:sync flags exactly that. --fix appends the missing lines (or creates .env wholesale from the example when you don't have one yet). Keys that exist only in your .env are reported but never deleted. Skip-boot, like routes:sync — works before the first pnpm dev.
Barrel export
config/index.ts collects every config file into a single default export:
import app from './app.js'
import server from './server.js'
import database from './database.js'
import auth from './auth.js'
export default { app, server, database, auth }bootstrap/app.ts then imports it as one object:
import config from '../config/index.ts'
Application.configure({ config, providers })Passing config binds the config repository in the DI container, so any service can retrieve a config slice on demand:
import { config } from '@rudderjs/core'
const serverConfig = config('server')
// or, via the repository: app().make<ConfigRepository>('config').get('server')Typed config()
config() is fully typed against your own config/ directory — dot-path keys autocomplete, and the return type is the actual shape of the value at that path:
import { config } from '@rudderjs/core'
const name = config('app.name') // string — autocompleted, typed
const cors = config('server.cors') // { origin: string; methods: string; headers: string }
const port = config('server.port') // numberThere's nothing to hand-write. Sibling to the typed Env scanner, @rudderjs/vite's config scanner runs on every dev/build and emits .rudder/types/config.d.ts — an AppConfig augmentation pulled straight from your config/index.ts barrel:
// .rudder/types/config.d.ts — AUTO-GENERATED; commit it
type RudderAppConfig = (typeof import('../../config/index.js'))['default']
declare module '@rudderjs/core' {
interface AppConfig extends RudderAppConfig {}
}
export {}The emit references your barrel by import type, so it's the same single file no matter what's in your config — editing a config/*.ts file (or adding a whole new section to the barrel) updates the types immediately, with nothing to regenerate. The only two states are "barrel present → emit" and "barrel gone → remove the stale emit". The one requirement is the barrel itself: config/index.ts must export default the object of named sections (the scaffolder convention).
rudder config:sync
The same emit, on demand — skip-boot, like env:sync and routes:sync, so it works before the first pnpm dev:
pnpm rudder config:sync # regenerate .rudder/types/config.d.tsApps created before the scanner existed need no migration beyond running it once (and deleting any hand-written interface AppConfig extends … {} augmentation they used to keep at the project root — the scanner now owns it).
Two things to know about the edges:
Unknown keys don't error.
config()keeps a looseconfig<T>(key: string)overload, because framework packages read keys your app may not declare. A typo'd key falls through to it and returnsunknown— which any concrete use of the value will surface as a type error. Same softness asroute()name lookups. If you want hard rejection inside your own app code, wrap it:import { config, type ConfigKey, type ConfigValue } from '@rudderjs/core' export const configStrict = <K extends ConfigKey>(key: K): ConfigValue<K> => config(key) configStrict('app.name') // ✓ configStrict('app.typo') // tsc error — not a declared dot-pathMismatched fallbacks degrade, they don't error.
config('server.port', 3000)matches the typed overload and returnsnumber.config('server.port', 'oops')doesn't fail at the call site — it falls through to the loose overload and the return type follows the fallback, so the mismatch surfaces wherever the value is actually used as a number.
Typed config() joins typed views, typed routes, typed models, and typed Env — the same convention everywhere: declare the shape once where it lives, and every call site is checked.
Framework wiring
bootstrap/app.ts is where the framework comes together. It contains structural decisions only — registered providers, route loaders, middleware — never environment values or business logic.
import 'reflect-metadata'
import 'dotenv/config'
import { Application } from '@rudderjs/core'
import { RateLimit } from '@rudderjs/middleware'
import providers from './providers.ts'
import config from '../config/index.ts'
export default Application.configure({ config, providers })
.withRouting({
web: () => import('../routes/web.ts'),
api: () => import('../routes/api.ts'),
commands: () => import('../routes/console.ts'),
})
.withMiddleware((m) => {
m.use(RateLimit.perMinute(60))
})
.create()Application.configure()
| Option | Description |
|---|---|
server |
HTTP adapter — optional. Omitted, the framework auto-resolves @rudderjs/server-hono with config('server'). Pass hono(...) (or another adapter) explicitly to override — see Application |
config |
Config objects to bind in the container |
providers |
Service provider classes, in boot order |
.withRouting()
| Option | Description |
|---|---|
web |
Web routes — tagged 'web', get the web middleware group (session, auth) |
api |
API routes — tagged 'api', stateless by default |
commands |
Rudder commands — loaded only when running the CLI |
Routes loaded via web automatically receive session and auth middleware. Routes loaded via api are stateless — opt into auth per-route with RequireBearer() from @rudderjs/passport. See Middleware for the full middleware-group model.
.withMiddleware()
The middleware configurator registers global middleware and group-specific middleware. m.use(...) appends to every request; m.web(...) and m.api(...) append to the matching group's stack.
.withMiddleware((m) => {
m.use(RateLimit.perMinute(60)) // every request
m.web(SomeWebOnlyMiddleware().toHandler()) // web routes only
m.api(SomeApiOnlyMiddleware().toHandler()) // api routes only
})Provider boot order
Providers boot in the order you list them. Most providers don't access the ORM during boot() — they only configure their own adapters. The database provider just needs to come before any provider whose boot() uses ORM models:
export default [
...(await defaultProviders()), // includes orm, auth, cache, queue, mail, etc.
AppServiceProvider, // your code — runs last so everything is ready
]For the full mechanics — stages, dependencies, opt-out paths — see Service Providers.