Naalya Handbook

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.env against 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 from process.env directly.

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.

libs/shared/src/config/env.config.ts
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.

apps/server/src/app/app.module.ts
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 or undefined. 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 silent undefined.

The CORS setup in main.ts is a clean example: the allowed origins are mandatory, so it uses getOrThrow.

apps/server/src/main.ts
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.

SectionPowersA few keys
(root)the HTTP server itselfport, host, nodeEnv
frontendCORS + redirect originsurl, allowedOrigins
databasethe Postgres connectionurl
authJWTs, impersonation, admin allow-listsjwtSecret, jwtExpiresIn, superAdminEmails
otpone-time-password rulesexpiryMinutes, resendCooldownSeconds, maxAttempts
emailtransactional email via ResendresendApiKey, fromEmail, fromName
microsoftMicrosoft 365 OAuth + directory syncclientId, clientSecret, tenantId, webhookSecret
cloudflareR2 object storage + AI searchaccountId, r2AccessKeyId, baseUrl, aiSearchApiToken
openaiAI featuresapiKey, model
unipilewebsite-enquiry messagingapiKey, baseUrl, webhookSecret
rabbitmq / redismicroservice transport + BullMQ queuesurl
docsHTTP Basic Auth on /docs*username, password

A handful of patterns show up across these sections that are worth recognizing:

  • Comma-split lists. FRONTEND_URL and SUPERADMIN_EMAILS are single strings split on commas. frontend.allowedOrigins becomes the full array (and drives CORS); frontend.url is just the first entry.
  • Milliseconds as strings. Token lifetimes like jwtExpiresIn are 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.url defaults to redis://localhost:6379, openai.model to a current model, otp.expiryMinutes to 15.

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.

.env.local
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...
FileUsed for
.env.locallocal development — loaded by the apps and the migration CLI
.env.stagingthe staging deployment
.env.productionproduction

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

On this page