Config & Environment
How the Naalya API validates its environment at boot with Zod and reads config at runtime through ConfigService.
Every backend needs a pile of secrets and settings to run — a database URL, a JWT signing key, an email API key. The naive way is to scatter process.env.WHATEVER reads across the codebase and hope every value is set. The Naalya API does the opposite: it declares every variable it needs in one place, validates them all the instant the app starts, and refuses to boot if anything is missing or malformed.
That one place is libs/shared/src/config/env.config.ts. It is the single source of truth for configuration — and understanding it means you'll never again be surprised by a undefined is not a string error three layers deep in a service.
The file does two jobs, and it helps to keep them separate in your head:
- Validate — it parses
process.envagainst a Zod schema (envSchema). Zod is a TypeScript-first validation library; a schema is just a description of the shape your data should have. If a required variable is absent, the parse throws. - Map — it reshapes the flat list of env vars into a tidy, nested config object grouped by concern (
auth,database,email, …). The rest of the app reads from that object, never fromprocess.envdirectly.
The schema is the .env.example
You will not find a committed .env.example in this repo, and that is on purpose. envSchema already lists — by name, with .optional() marking the ones that have defaults — every variable the app requires. The schema is the source of truth, so a stale example file can never drift out of sync with reality. To build your .env.local, read the schema (and the setup notes in Getting Started).
Fail fast at boot
The most important property of this setup is that a misconfigured app dies immediately, with a clear message, instead of limping along and exploding at 3am when some code path finally touches the missing value.
Validation happens inside config() — the function each app loads at startup. It runs envSchema.parse(process.env) and, if Zod complains, wraps the error in a recognizable prefix.
const config = () => {
try {
envSchema.parse(process.env);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Config validation error: ${errorMessage}`);
}
return {
/* ...nested config object... */
};
};If you ever see Config validation error: … in your logs on startup, that is this line. The message lists exactly which variable failed and why, so the fix is almost always "add the missing key to your .env.local".
Boot errors are config errors first
When the app won't start at all, suspect your environment before your code. A fresh clone, a new machine, or a teammate adding a required variable will all surface here as a Config validation error. Check your .env.local against the schema before you go spelunking in the source.
Wiring it up
Validation only happens because every app loads config through Nest's ConfigModule. ConfigModule is the standard NestJS piece for reading configuration; forRoot registers it once for the whole application.
Here is how the main server app wires it up — and each of the other apps (worker, audit) does the same.
ConfigModule.forRoot({
isGlobal: true, // available everywhere, no re-importing
load: [config], // runs validation + builds the nested object
envFilePath: '.env.local',
}),Three things are happening. load: [config] is what triggers the Zod check and produces the structured object. isGlobal: true means any module can inject ConfigService without importing ConfigModule again. And envFilePath: '.env.local' tells Nest which dotenv file to read on your machine.
Reading config
Once the app is up, you never reach for process.env yourself. You inject ConfigService and read values with dot-notation keys that mirror the nested object — auth.jwtSecret, email.fromEmail, frontend.allowedOrigins.
Two methods cover almost every case:
get('section.key', { infer: true })returns the value orundefined. The{ infer: true }flag turns on TypeScript narrowing so the return type matches the real shape.getOrThrow('section.key')returns the value or throws if it is missing. Reach for this when the value is genuinely required at the call site — you want a loud failure, not a silentundefined.
The CORS setup in main.ts is a clean example: the allowed origins are mandatory, so it uses getOrThrow.
const configService = app.get(ConfigService);
app.enableCors({
origin: configService.getOrThrow<string[]>('frontend.allowedOrigins'),
credentials: true,
});get vs getOrThrow — pick on purpose
If a value has a sensible fallback or the feature is optional, use get and handle undefined. If the app cannot do its job without the value — a signing secret, a connection string — use getOrThrow so a misconfiguration surfaces immediately at the point that needs it, with a name attached.
For full type-safety, type the service with the config shape — ConfigService<EnvTypes> — so keys and return types are checked at compile time. EnvTypes is exported from env.config.ts as ReturnType<typeof config>: the type of the nested object. Its sibling SharedEnv (z.infer<typeof envSchema>) describes the raw, flat env shape — you'll rarely need it.
The config sections
The nested object groups related variables so a feature reads from one tidy namespace. You do not need to memorize the variables — open env.config.ts when you need the exact name. What's worth carrying in your head is the map of sections and what each one powers.
| Section | Powers | A few keys |
|---|---|---|
| (root) | the HTTP server itself | port, host, nodeEnv |
frontend | CORS + redirect origins | url, allowedOrigins |
database | the Postgres connection | url |
auth | JWTs, impersonation, admin allow-lists | jwtSecret, jwtExpiresIn, superAdminEmails |
otp | one-time-password rules | expiryMinutes, resendCooldownSeconds, maxAttempts |
email | transactional email via Resend | resendApiKey, fromEmail, fromName |
microsoft | Microsoft 365 OAuth + directory sync | clientId, clientSecret, tenantId, webhookSecret |
cloudflare | R2 object storage + AI search | accountId, r2AccessKeyId, baseUrl, aiSearchApiToken |
openai | AI features | apiKey, model |
unipile | website-enquiry messaging | apiKey, baseUrl, webhookSecret |
rabbitmq / redis | microservice transport + BullMQ queues | url |
docs | HTTP Basic Auth on /docs* | username, password |
A handful of patterns show up across these sections that are worth recognizing:
- Comma-split lists.
FRONTEND_URLandSUPERADMIN_EMAILSare single strings split on commas.frontend.allowedOriginsbecomes the full array (and drives CORS);frontend.urlis just the first entry. - Milliseconds as strings. Token lifetimes like
jwtExpiresInare stored as string millisecond values with defaults (e.g.'900000'— 15 minutes). Parse them where you use them. - Sensible defaults. Optional values fall back in code:
redis.urldefaults toredis://localhost:6379,openai.modelto a current model,otp.expiryMinutesto15.
Not every required var lives on the config object
A few variables are required by envSchema — so the app won't boot without them — yet are read straight from process.env where they're used rather than surfaced on the nested object. AI_GATEWAY_API_KEY, AI_GATEWAY_BASE_URL, and the three SENTRY_* variables work this way. The rule of thumb: envSchema is the authoritative list of what the app needs at boot; the returned object is the convenience layer for what's read through ConfigService.
Payment credentials are per-tenant, not env vars
Do not look for SchoolPay or other payment-gateway secrets here. The platform is multi-tenant, and each school configures its own provider — those credentials live per-school in the database (SchoolConfigEntity / the school_config table), not in global environment variables. Global config is for things shared by the whole deployment.
Environment files
Your local config is just key-value pairs in a dotenv file. The deploy environments use their own files, but the shape is identical — same variable names, different values.
PORT=3000
HOST=0.0.0.0
NODE_ENV=development
DATABASE_URL=postgres://user:pass@localhost:5432/naalya
JWT_SECRET_KEY=your-signing-secret
# ...every other required key from envSchema...| File | Used for |
|---|---|
.env.local | local development — loaded by the apps and the migration CLI |
.env.staging | the staging deployment |
.env.production | production |
Adding a value
When a feature needs a new setting, the change touches four places — schema, object, your dotenv, and the deploy environments — in that order.
Step 1: Declare it in the schema
Add the variable to envSchema in env.config.ts. Mark it .optional() if it will have a default; leave it required if the app truly cannot run without it (required means the app won't boot until it's set).
Step 2: Surface it on the config object
Add it under the right section in the object config() returns, providing a fallback if it's optional. This is what makes it readable by dot-notation key.
Step 3: Set it everywhere it runs
Add the value to your .env.local, then to .env.staging and .env.production. A required variable that's only in your local file will pass on your machine and fail the moment it deploys.
Step 4: Read it
Pull it in where you need it via configService.get('section.key', { infer: true }), or getOrThrow if it's required at that call site.
Where to go next
Getting Started
Stand up the project locally and build your first .env.local.
Architecture
How the server, worker, and audit apps fit together — and why each loads config.
Auth & Permissions
Where the auth and JWT/OTP config sections actually get used.
Queues & Messaging
How redis and rabbitmq config wire up BullMQ and the microservice transport.
Queues & Messaging
The two messaging systems behind the backend — fire-and-forget BullMQ jobs and RabbitMQ request/response — and how to enqueue a job.
Secrets & Credentials
How the API handles secrets — the key names that matter, the Zod fail-fast boundary, and the one rule that keeps real values out of git.