Skip to content

Configuration

Warlock splits configuration into two layers. They have different shapes and different jobs, and once you’ve seen both you’ll never confuse them again.

LayerWhereWhat it controlsLoaded by
Project configwarlock.config.tsThe server, the build, the dev server, the CLI, top-level migrationsOnce at boot
Subsystem configsrc/config/*.tsOne file per subsystem: http, mail, storage, cache, auth, …Once at boot, merged

Both pull values from .env via the env() helper. Both are typed. Both are read-only once boot finishes.

warlock.config.ts — project-level config

Section titled “warlock.config.ts — project-level config”

Lives at the project root next to package.json. Returns the project-wide configuration via defineConfig():

warlock.config.ts
import {
authMigrations,
registerAuthCleanupCommand,
registerJWTSecretGeneratorCommand,
} from "@warlock.js/auth";
import { defineConfig } from "@warlock.js/core";
export default defineConfig({
cli: {
commands: [registerJWTSecretGeneratorCommand(), registerAuthCleanupCommand()],
},
database: {
migrations: authMigrations,
},
});

What you can put in here:

  • server — port, host, retry-other-port-on-conflict.
  • build — output directory, minify, sourcemaps for the production build.
  • cli — custom CLI commands your app registers on top of the built-ins.
  • devServer — file-watch globs, type generation, health checkers, transpile-cache debug.
  • database — migrations from packages outside your src/ tree (e.g. authMigrations from @warlock.js/auth).

defineConfig() is just (config: WarlockConfig) => config — its job is to give you full type inference on the object literal so IDE completions and TypeScript errors guide you.

Every file in src/config/ exports a default config object for one subsystem. The file’s name becomes the config namespace:

src/config/http.ts
import type { HttpConfigurations } from "@warlock.js/core";
import { Application, env } from "@warlock.js/core";
const httpConfigurations: HttpConfigurations = {
port: env("HTTP_PORT", 3000),
host: env("HTTP_HOST", "localhost"),
log: true,
fileUploadLimit: 12 * 1024 * 1024,
rateLimit: {
max: 260,
duration: 60 * 1000,
},
cors: {
origin: "*",
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
},
cookies: {
secret: env("COOKIE_SECRET", "super-secret-key-change-me"),
options: {
httpOnly: true,
secure: Application.isProduction,
path: "/",
},
},
};
export default httpConfigurations;

A scaffolded project has one file per subsystem in src/config/:

src/config/
app.ts app name, timezone, base URL, public URL, locale
auth.ts JWT secret, token TTL, password policy
cache.ts driver, prefix, TTLs
database.ts driver (mongo|postgres), connection string, pool
http.ts port, CORS, cookies, rate-limit, uploads
log.ts channels, levels, redaction
mail.ts SMTP host/port/auth, default from, react templates
repository.ts repository defaults (cache strategy, pagination)
storage.ts disks (local, s3, …), default disk
tests.ts test runner settings, fixtures path
validation.ts schema defaults, custom rules

The default export’s keys become reachable from anywhere via the config object — two methods:

import { config } from "@warlock.js/core";
// config.get(namespace) — the whole subsystem config
const httpConfig = config.get("http"); // → entire HttpConfigurations object
// config.key(path) — one value by dot-notation
const port = config.key("http.port"); // → 3000
const fromAddress = config.key("mail.from");
const dbDriver = config.key("database.driver"); // → "mongo" | "postgres"
const corsOrigin = config.key("http.cors.origin"); // → deep paths work too

config.get(name) autocompletes the subsystem name from the auto-generated ConfigRegistry. config.key(path) is the general-purpose dotted accessor. Both take an optional default as the second argument:

config.key("http.port", 3000); // → 3000 if http.port is unset
config.key("http.missingThing"); // → null when the path resolves to nothing and no default is given

A key that resolves to nothing returns null (not undefined) when you don’t pass a default — handy to know when you branch on the result. See Configuration (deep) for the full surface.

A config file can export a configSchema next to its default export. The value is validated before it is registered — when warlock dev starts, on every hot reload of that file, and when the production build starts — and the app refuses to start with one error that lists every problem in every config file:

src/config/payments.ts
import { defineConfigSchema, env, type ConfigOf } from "@warlock.js/core";
import { v } from "@warlock.js/seal";
export const configSchema = defineConfigSchema(
v.object({
apiKey: v.string(),
webhookSecret: v.string(),
currency: v.string().default("usd"),
}),
{ env: { apiKey: "PAYMENTS_API_KEY", webhookSecret: "PAYMENTS_WEBHOOK_SECRET" } },
);
const paymentsConfig: ConfigOf<typeof configSchema> = {
apiKey: env("PAYMENTS_API_KEY"),
webhookSecret: env("PAYMENTS_WEBHOOK_SECRET"),
};
export default paymentsConfig;
InvalidConfigError: Invalid configuration (2 problems); the app cannot start until it is fixed:
- payments.apiKey: The apiKey is required (set PAYMENTS_API_KEY in your environment or .env)
- payments.webhookSecret: The webhookSecret is required (set PAYMENTS_WEBHOOK_SECRET in your environment or .env)

The env map names the environment variable behind each key path (nested keys use dots: "smtp.password"), so the error says what to set. Values are never printed. An invalid config is not handed to its subsystem (no connection is attempted with it); on a hot reload the previous valid value stays in place. Files without a configSchema load exactly as before. ConfigOf<typeof configSchema> types the default export from the schema.

The bottom layer. Loaded by @mongez/dotenv at the very start of bootstrap. Both warlock.config.ts and src/config/*.ts read from it via env():

import { env } from "@warlock.js/core";
const port = env("HTTP_PORT", 3000); // number with default
const debug = env("DEBUG", false); // boolean with default
const baseUrl = env("BASE_URL", "http://localhost:3000");

The second argument is the default — env() coerces to the default’s type. env("FLAG", false) always returns a boolean, even if FLAG=1 in the .env.

.env ← values
↑ env(...)
src/config/*.ts ← subsystem-shaped: typed by the contract types from @warlock.js/core
↑ default export
config.get(name) / ← what your app code reads at runtime
config.key(path)
warlock.config.ts ← project-shaped: read by the framework itself (server, build, CLI)
↑ defineConfig(...)
framework internals ← not for app code

The split keeps app concerns (port, mail-from, DB connection) separate from framework concerns (CLI extensions, build output, dev-server flags). When you change HTTP_PORT=4000 in .env, you change app behaviour — warlock.config.ts doesn’t move. When you register a new CLI command, you touch warlock.config.ts — your app code stays put.

Continue to First route — the page where Warlock actually does something.

Warlock 5.32 pins the native typescript@7.0.2 compiler. Follow the TypeScript 7 guide to remove legacy tsconfig options, rebase aliases, and declare Node types explicitly.

src/config/mail.spec.ts and src/config/mail.test.tsx are never imported as configuration. Discovery and reload also exclude test, tests, __tests__, __mocks__, __fixtures__, and fixtures directories at any depth. A real src/config/tests.ts remains ordinary configuration.