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.
| Layer | Where | What it controls | Loaded by |
|---|---|---|---|
| Project config | warlock.config.ts | The server, the build, the dev server, the CLI, top-level migrations | Once at boot |
| Subsystem config | src/config/*.ts | One 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():
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 yoursrc/tree (e.g.authMigrationsfrom@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.
src/config/*.ts — subsystem config
Section titled “src/config/*.ts — subsystem config”Every file in src/config/ exports a default config object for one subsystem. The file’s name becomes the config namespace:
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 rulesThe 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 configconst httpConfig = config.get("http"); // → entire HttpConfigurations object
// config.key(path) — one value by dot-notationconst port = config.key("http.port"); // → 3000const fromAddress = config.key("mail.from");const dbDriver = config.key("database.driver"); // → "mongo" | "postgres"const corsOrigin = config.key("http.cors.origin"); // → deep paths work tooconfig.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 unsetconfig.key("http.missingThing"); // → null when the path resolves to nothing and no default is givenA 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.
Validate a config file at boot
Section titled “Validate a config file at boot”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:
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.
.env — environment values
Section titled “.env — environment values”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 defaultconst debug = env("DEBUG", false); // boolean with defaultconst 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.
How the layers compose
Section titled “How the layers compose”.env ← values ↑ env(...)src/config/*.ts ← subsystem-shaped: typed by the contract types from @warlock.js/core ↑ default exportconfig.get(name) / ← what your app code reads at runtimeconfig.key(path)
warlock.config.ts ← project-shaped: read by the framework itself (server, build, CLI) ↑ defineConfig(...)framework internals ← not for app codeThe 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.
TypeScript 7 compiler
Section titled “TypeScript 7 compiler”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.
Co-located configuration tests
Section titled “Co-located configuration tests”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.