Deployment & production
Shipping a Warlock app is two commands: warlock build to produce a bundle, warlock start to run it. Dev runs through a custom ESM loader optimized for fast reloads; production runs an esbuild bundle on a plain Node process — no tsx, no loader hook, no file watcher. This page covers what build emits, how start launches it, how the environment is selected, why the runtime flips into “production” mode, and what happens to your connectors when the process gets a kill signal.
It stays grounded in the actual build/start commands and the production builder. Where to host the resulting process — a VM, a container, a PaaS — is up to you; Warlock just needs a Node runtime and the right env vars. There’s a short pointer at the end, but no Docker or PM2 recipe here, because the framework doesn’t ship one.
The 30-second look
Section titled “The 30-second look”flowchart TD
build["warlock build<br/><i>esbuild bundle → dist/app.js</i>"]
env["NODE_ENV + .env files<br/><i>loadEnv picks .env.<NODE_ENV> or .env</i>"]
start["warlock start<br/><i>node --enable-source-maps dist/app.js</i>"]
boot["Generated bootstrap.ts<br/><i>runtimeStrategy = production</i>"]
early["Early connectors<br/><i>logger · db · cache · …</i>"]
app["App code<br/><i>routes · main · events</i>"]
late["Late connectors<br/><i>http · socket</i>"]
serving["Serving<br/><i>awaits requests</i>"]
signal["SIGINT / SIGTERM"]
shutdown["Reverse-order shutdown"]
build --> start
env --> start
start --> boot
boot --> early
early --> app
app --> late
late --> serving
serving --> signal
signal --> shutdown
Three takeaways:
buildis a bundler step,startis justnode. The whole framework dev-time machinery is absent in production.- The bundle decides its own runtime mode. The generated entry sets the runtime strategy to
productionbefore any connector starts —startitself stays dumb. - Connectors shut down in reverse priority order on
SIGINT/SIGTERM(andSIGHUPon Windows), so the HTTP/socket servers close before the database does.
warlock build
Section titled “warlock build”warlock build runs the ProductionBuilder. It generates a handful of combined files under .warlock/production/, hands them to esbuild, writes the bundle to your output directory, then deletes the temporary .warlock/production/ folder. The build is driven entirely by warlock.config.ts > build — there are no build flags to pass.
What the builder generates before bundling:
| Generated file | What it contains |
|---|---|
bootstrap.ts | Calls bootstrap() and sets the runtime strategy + environment to production (see below) |
config-loader.ts | Imports every src/config/* file, registers each into config, and runs its special handlers |
events.ts | Side-effect imports of every **/events/* file — only generated if your app has event files |
locales.ts | Side-effect imports of every **/utils/locales file — only if present |
main.ts | Side-effect imports of every **/main file — only if present |
routes.ts | Side-effect imports of every **/routes file — only if present |
app.ts | The entry point that ties it all together (bootstrap → configs → early connectors → app code → late connectors) |
The app.ts entry imports app code with dynamic await import("./routes") (not static imports) so each module’s side effects fire after the early-phase connectors have finished starting — that’s why the builder turns on esbuild’s splitting: true. The same phase split you read about in Bootstrap and connectors is baked straight into the generated entry.
esbuild settings
Section titled “esbuild settings”The builder bundles with esbuild using these settings (the ones you can influence come from build config):
| Setting | Value | Source |
|---|---|---|
platform | node | fixed |
format | esm | fixed |
target | node22 | fixed (transpiles stage-3 decorators, which Node doesn’t run natively yet) |
bundle / splitting | both true | fixed |
packages | external (your node_modules are not inlined) | fixed |
minify | from build.minify | config — default true |
sourcemap | from build.sourcemap (true becomes "linked") | config — default true |
outdir | from build.outdir | config — default dist |
entryNames | from build.outFile (extension stripped; esbuild adds .js) | config — default app.js |
Because packages: "external", your dependencies are not bundled in — node_modules must be present (installed) wherever you run the result.
The build config keys
Section titled “The build config keys”These live in warlock.config.ts under build. The defaults the runtime actually applies come from the framework’s default configuration, merged over your values by resolveBuildConfig():
| Key | Type | Default | What it does |
|---|---|---|---|
outdir | string | dist/ under the project root (resolved from process.cwd()) | Folder the bundle is written to |
outFile | string | "app.js" | Bundle filename (the extension is normalized to .js by esbuild) |
minify | boolean | true | Minify the output |
sourcemap | boolean | "inline" | "linked" | true | Emit source maps; true is treated as "linked" |
import { defineConfig } from "@warlock.js/core";
export default defineConfig({ build: { outdir: "dist", outFile: "app.js", minify: true, sourcemap: true, },});The entry the bundle produces is {outdir}/{outFile} — by default dist/app.js. warlock start resolves that exact path through the same resolveBuildConfig() helper, so build and start always agree on where the artifact lives.
warlock start
Section titled “warlock start”warlock start does not re-bundle. It resolves the build config to find the entry path, then spawns a child Node process on the bundle:
node --enable-source-maps dist/app.jsSpecifically:
--enable-source-mapsis added unlessbuild.sourcemapisfalse, so production stack traces map back to your TypeScript.- Anything you type after
startis passed through to the Node process — e.g.warlock start --inspectforwards--inspect. - The command inherits stdio and the parent’s
env, in the same working directory, and adds one IPC channel the bundle reports readiness on.
start exits with the child’s exit code, except that a child which never finished booting always exits non-zero. It forwards SIGTERM to the child explicitly and lets SIGINT (Ctrl+C) reach the child naturally; the actual graceful shutdown is handled inside the bundle by the connectors manager (next section).
The build must exist before you call
start.startdoes not build for you — runwarlock buildfirst (typically as a deploy step), thenwarlock starton the server.
Installing with pnpm
Section titled “Installing with pnpm”Two things pnpm 10+ needs that npm and yarn do not. Both bite at deploy time, and neither error mentions Warlock, so they are worth setting once in your app’s pnpm-workspace.yaml:
# esbuild's install script links its platform-native binary. `warlock build`# shells out to that binary, so without this the app installs cleanly and then# cannot build.allowBuilds: esbuild: true[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: esbuild@0.27.7Run "pnpm approve-builds" to pick which dependencies should be allowed to run scripts.That is the error you get without it — at install time, before you ever run warlock build.
The second is a non-issue by design, but worth knowing why: Warlock never asks you to declare a dependency you do not import. Under npm and yarn’s flat hoisting a framework-internal package can be reached from your app by accident; under pnpm’s strict layout it cannot, and code the builder generates into your app is checked at build time to make sure it only imports packages your own package.json declares. If warlock build ever fails naming a package you have never heard of, that is a framework bug — report it rather than adding the dependency.
Knowing the app actually started
Section titled “Knowing the app actually started”A success line on stdout means the app is serving requests. Deployment tooling can depend on that:
| Stream | Carries |
|---|---|
| stdout | the started banner, and start failures. Nothing else. |
| stderr | progress, diagnostics, and the application’s own logs |
The banner prints only when the running application reports a completed boot — after the late-phase connectors (http, socket) are up. It is not printed when the command starts or when the child is spawned, so a health gate cannot mistake an intention for an outcome:
warlock start | grep -q "production server started"A child that dies before reporting readiness is a failed start. The failure is written to both streams — stderr for humans and log collectors, stdout so a supervisor watching for the banner finds a failure instead of silence — and the command exits non-zero even if the child itself exited 0.
Readiness travels as one versioned IPC message, { type: "warlock:ready", version, pid, at, environment, runtimeStrategy, bootDurationMs?, port? }, sent by Application.markBooted() and immediately followed by closing the channel. Three things follow from that:
- A worker app with no http connector still reports. Readiness means a completed boot, not a bound port;
portis simply absent. - Running the bundle any other way is unaffected. Under
node dist/app.js, a DockerCMD, or pm2, the signal is a no-op — Warlock only writes to a channel opened bywarlock startitself, so it never interferes with another supervisor’s protocol. - A bundle built before 4.11.0 has no signal. It runs normally and prints a note on stderr only, asking you to re-run
warlock build. A missing signal never fails a run and never interrupts a slow boot.
Inside the app, Application.onceBooted() and Application.whenBooted() fire from the same latch.
Environment selection
Section titled “Environment selection”There are two distinct notions of “environment”, and it’s worth keeping them straight:
NODE_ENV— a standard process env var ("development" | "production" | "test"). It decides which.envfile gets loaded and whatApplication.environmentreports.- Runtime strategy — an internal Warlock flag (
"development" | "production") that decides whether the dev-server code paths run. It is set by the CLI command / generated bundle, not read fromNODE_ENV.
NODE_ENV and the .env file it picks
Section titled “NODE_ENV and the .env file it picks”bootstrap() calls loadEnv() from @mongez/dotenv as its very first step. loadEnv resolves which file to read based on NODE_ENV:
- If
.env.sharedexists, it is loaded first (shared baseline values). - Then, if
.env.<NODE_ENV>exists (e.g..env.production), that file is loaded. - Otherwise it falls back to plain
.env.
By default loadEnv overrides — values in the loaded file win over whatever is already in process.env. If NODE_ENV is unset, Application.environment defaults to "development", so on a production host you typically set NODE_ENV=production before the process starts (so the right .env.production is picked up) and provide the secrets your config files read.
NODE_ENV=production warlock startNODE_ENV is authoritative — no Warlock command overrides it. Neither build nor start forces production, deliberately: doing so would silently change which .env file an existing NODE_ENV=staging pipeline reads, and would have the framework overriding what the operator explicitly asked for at the moment of deployment. If NODE_ENV is unset, warlock build reads plain .env — set it in your Dockerfile, CI job, or process manager.
Env files are read before warlock.config.ts is evaluated (4.11.0+). That ordering is what makes env() usable in the config file at all. Before 4.11.0 the config module ran against an empty store, so every env("KEY", "default") in warlock.config.ts returned its default under every command — including dev, and including build and start, which never loaded env at all. A project with no .env file is still fine: loading is guarded, so a missing file is a non-event rather than a failed build.
# Whatever your src/config/* files read via env(...)DATABASE_URL=...CACHE_DRIVER=redisMAIL_HOST=...Application exposes read-only getters for the resolved environment:
| Accessor | Returns |
|---|---|
Application.environment | NODE_ENV (or "development" if unset) |
Application.isProduction | true when environment is "production" |
Application.isDevelopment | true when environment is "development" |
Application.isTest | true when environment is "test" |
Runtime strategy: dev vs production
Section titled “Runtime strategy: dev vs production”The runtime strategy is set in exactly one place per run:
- Dev — the
warlock devcommand preloadsruntimeStrategy: "development". - Production — the generated
bootstrap.tsin the bundle callsApplication.setRuntimeStrategy("production")andApplication.setEnvironment("production")before anything else.
import { bootstrap, Application } from "@warlock.js/core";
Application.setRuntimeStrategy("production");Application.setEnvironment("production");
bootstrap();The strategy matters because some connectors branch on it. The clearest case is the HTTP connector: in development it registers routes via router.scanDevServer(...) (the HMR-aware path); otherwise it uses the plain router.scan(...). You don’t toggle this yourself — building for production wires the production path in.
The generated bootstrap forces
Application.setEnvironment("production"), which setsprocess.env.NODE_ENV = "production"from inside the process. But that happens afterloadEnv()has already chosen the.envfile based on theNODE_ENVthe process started with. So the env var you set on the host still determines which.envfile is read — setNODE_ENV=productionbefore launching if you want.env.production.
Graceful shutdown
Section titled “Graceful shutdown”The generated production entry ends by calling connectorsManager.shutdownOnProcessKill(). That installs signal handlers so the process tears subsystems down cleanly instead of dropping connections:
| Signal | Where | Behavior |
|---|---|---|
SIGINT | all platforms | Triggers graceful shutdown (Ctrl+C) |
SIGTERM | all platforms | Triggers graceful shutdown (orchestrators send this) |
SIGHUP | Windows only (win32) | Triggers graceful shutdown |
When a signal arrives, gracefulShutdown runs once (an isShuttingDown re-entry guard ignores repeat signals), prints Exiting..., awaits connectorsManager.shutdown(), then process.exit(0).
shutdown() walks the connectors in reverse priority order — the inverse of startup. Since the connectors start sorted ascending by priority (logger first, access last), shutdown runs from the highest priority back down. The practical effect is that late-phase servers close before early-phase infrastructure: HTTP and socket stop accepting work before the database connection is torn down, so you don’t drop in-flight requests onto a closed database. Each connector’s shutdown() is wrapped in a try/catch — a failure is logged, not thrown, so one misbehaving subsystem can’t block the rest from shutting down.
flowchart LR
sig["SIGINT / SIGTERM / SIGHUP"] --> guard["isShuttingDown guard"]
guard --> rev["shutdown() in reverse priority"]
rev --> a["access"]
a --> n["notifications"]
n --> s["socket"]
s --> st["storage"]
st --> ht["http"]
ht --> c["cache"]
c --> h["herald"]
h --> d["database"]
d --> m["mailer"]
m --> l["logger"]
l --> exit["process.exit(0)"]
The two layers cooperate: warlock start’s parent process forwards SIGTERM to (and lets SIGINT reach) the child, and the child’s shutdownOnProcessKill() does the actual reverse-order teardown.
Which subsystems come up
Section titled “Which subsystems come up”A connector only does anything if its config file exists. All ten built-ins are always registered, but each one’s start() reads its config (config.get("http"), config.get("database"), …) and no-ops if it’s absent. In production the generated config-loader.ts imports every file in src/config/, so a subsystem is active in production exactly when its src/config/*.ts file is present and its env-driven values are set. This is the same activation rule as dev — see Connectors for the full catalog and priorities.
The upshot for deployment: if a subsystem isn’t coming up in production, the first thing to check is whether its config file is in src/config/ and whether the env vars it reads are actually present in the environment you launched with.
Pre-deploy checklist
Section titled “Pre-deploy checklist”Before warlock start on a fresh host:
NODE_ENVis set toproductionbefore the process starts, soloadEnvreads.env.production(if you keep one) andApplication.environmentreportsproduction.- The right env file is on the box —
.env.production(and/or.env.shared, and/or.env).loadEnvthrows if the file it resolves to does not exist, so at minimum the resolved file must be present. - Every secret your config files read is in the environment. Connectors pull values via
config.get(...), which is fed by yoursrc/config/*files readingenv(...). Missing values mean a subsystem silently no-ops (or, for access, fails fast — see gotchas). - The config files for the subsystems you need are present in
src/config/(http.ts,database.ts,cache.ts, …). A subsystem with no config file does not activate. node_modulesis installed on the host. The bundle marks packages as external, so dependencies are resolved at runtime, not inlined.- The bundle exists — run
warlock buildas part of your deploy.warlock startwill not build for you. - A real shutdown signal reaches the process. If you run under a supervisor, make sure it sends
SIGTERM(orSIGINT) so the connectors get to shut down gracefully instead of beingSIGKILL-ed.
Gotchas
Section titled “Gotchas”startdoes not build. Runningwarlock startagainst a missing/staledist/just runs whatever is there (or fails to find the entry). Alwayswarlock buildfirst in your pipeline.NODE_ENVis read before the bundle forces it. The generated bootstrap sets the environment toproductioninside the process, butloadEnvalready chose the.envfile from theNODE_ENVthe process started with. SettingNODE_ENV=productionin the bundle does not retroactively change which.envfile was loaded.loadEnvthrows on a missing file. If.env.<NODE_ENV>doesn’t exist it falls back to.env, but if that is also missing it throws. Make sure at least one resolvable env file is on the host.- Dependencies aren’t bundled.
packages: "external"keepsnode_modulesout of the bundle, so a host without the installed packages will fail at runtime, not at build time. - A subsystem that “won’t start” is usually a missing config file or missing env value, not a framework bug — the connector is registered, it just read an empty config and returned.
- The
accessconnector fails at startup if misconfigured. Unlike the others, it validates that a resolver is present, so an authorization layer that’s half-configured surfaces the error on boot rather than on the first protected request — a feature, but it means a badsrc/config/access.tswill stop the process from coming up. SIGKILLskips shutdown. Graceful teardown only runs forSIGINT/SIGTERM(andSIGHUPon Windows). A hard kill gives connectors no chance to close — configure your supervisor to send a terminable signal and allow a short grace period.
Where to host
Section titled “Where to host”Warlock produces a standard Node ESM bundle plus your node_modules — anywhere that runs Node 22+ will serve it. Build in CI (or on the box), ship dist/ + node_modules + your .env/config, set NODE_ENV, and run warlock start under whatever process supervisor your platform provides. The framework deliberately doesn’t prescribe a container or process-manager setup; the only hard requirements are a Node runtime, the installed dependencies, and the env vars your config files read.
See also
Section titled “See also”- Bootstrap and connectors — the boot sequence the production entry reproduces: bootstrap, early phase, app code, late phase.
- Connectors — the catalog of all ten built-ins, their priorities and phases, and the rule that the config file activates each subsystem.
- How it works — the production builder and the dev/prod loader split in more depth.
- warlock.config.ts — the project-level config, including the
buildblock. - Application — the static gateway to
environment, runtime mode, and the well-known paths. - Configuration — the two config layers and how
.envvalues feedsrc/config/*.