English
Deployment
haze build produces a production bundle for the platform you deploy to. The same project — API-only or full-stack — can target long-running servers, containers, serverless functions or Cloudflare Workers by switching the preset.
Building
bash
haze build # node preset (default)
haze build --preset docker # or -p docker
haze build --preset cloudflare --minify
haze build --compile # standalone executable (binary preset)The preset can also be set in haze.config.ts; --preset takes precedence:
ts
// haze.config.ts
import { defineHazeConfig } from '@hazejs/vite'
export default defineHazeConfig({
preset: 'cloudflare',
})In full-stack projects haze build runs vite build with your vite.config.ts, building dist/client and the server in one go. If you call vite build directly, the preset comes from haze.config.ts or the HAZE_PRESET environment variable.
Other build flags: --minify (default off) and --outDir (API-only projects; full-stack projects use outDir in haze.config.ts). In API-only projects the output directory is emptied before each build (only when it lies inside the project), so files from an earlier build or another preset never end up in a Docker build context.
Presets
| Preset | Output | Run / deploy |
|---|---|---|
node | dist/index.mjs (full-stack: dist/server/index.mjs + dist/client) | node dist/index.mjs or haze start |
bun | Same layout as node | bun dist/index.mjs |
deno | dist/server/index.mjs | deno run -A dist/server/index.mjs |
docker | dist/ is the build context: server/, Dockerfile, runtime package.json | docker build -t app dist |
aws-lambda | dist/lambda/ (full-stack: client inside it) | Zip dist/lambda, handler index.handler |
vercel | .vercel/output/ (Build Output API) | vercel deploy --prebuilt |
netlify | dist/server/, .netlify/v1/functions/haze-server.mjs, netlify.toml | netlify deploy --build |
cloudflare | dist/index.mjs (single-file Worker) + dist/wrangler.json | npx wrangler deploy -c dist/wrangler.json |
binary | dist/<name>-<target> executables (full-stack: plus dist/client) | ./dist/<name>-linux-x64, see Standalone executables |
After each build the CLI prints the exact commands for your project, including migration and worker commands.
For full-stack projects, node, bun, deno, docker, binary and aws-lambda serve dist/client from the same process (with SPA fallback for non-API paths); on vercel, netlify and cloudflare static files are served by the platform's CDN and only the backend prefixes reach your function.
Environment variables and APP_KEY
APP_KEY signs cookies, encrypts sessions, signs JWTs and storage URLs, and must be set in production. Generate one with haze key:generate, and keep it secret.
| Preset | How to provide variables |
|---|---|
| node / bun / deno | .env and .env.production are loaded from the working directory at startup (haze start runs from the project root). Real environment variables always win. |
| docker | docker run -e APP_KEY=<key> … (or your orchestrator's secrets). .env is not copied into the image. |
| aws-lambda | Lambda environment variables. |
| vercel | vercel env add APP_KEY |
| netlify | netlify env:set APP_KEY <key> |
| cloudflare | npx wrangler secret put APP_KEY -c dist/wrangler.json; plain values go in vars. |
Long-running presets listen on PORT (default: the port in haze.config.ts, else 3000) and HOST (default 0.0.0.0).
Dependencies
Like Nitro, hazejs bundles every dependency it can. Database drivers, native modules and a few packages that cannot be bundled stay external, and after the build their files are traced (with nf3, the tracer Nitro uses) and copied into the output's node_modules. The result is self-contained: you can copy dist/ to a server and run it without pnpm install.
| Preset | Bundled | External packages |
|---|---|---|
| node / bun / deno | Everything except drivers | Traced into node_modules next to the entry |
| aws-lambda / vercel | Everything except drivers | Always traced |
| docker | Everything except drivers | Installed inside the image (npm install), so native binaries match Linux |
| netlify | Everything except drivers | Bundled by Netlify at deploy time |
| cloudflare | Everything | Unsupported drivers are replaced by stubs |
Only drivers your project actually installs are included. Fine-tune this in haze.config.ts:
ts
// haze.config.ts
import { defineHazeConfig } from '@hazejs/vite'
export default defineHazeConfig({
externals: {
traceDeps: ['sharp', 'some-pkg*'], // keep external and trace (a trailing * copies the whole package)
inline: ['postgres'], // force into the bundle
// trace: false, // node / bun: keep dependencies external and use the project's node_modules
},
})Build Lambda and Vercel bundles on Linux
aws-lambda and vercel run on Linux. If tracing picks up platform-specific native binaries while building on macOS or Windows, the build warns you — build those presets in CI or Docker so the right binaries are copied.
Production migrations
Migration files are bundled into the output, so production needs neither the source tree nor drizzle-kit.
| Preset | Run migrations |
|---|---|
| node / bun | node dist/migrate.mjs (bun dist/migrate.mjs); full-stack: dist/server/migrate.mjs. Flags: --connection <name>, --all |
| deno | deno run -A dist/server/migrate.mjs |
| docker | docker run --rm <image> node server/migrate.mjs |
| aws-lambda | Invoke the same bundle with handler index.migrate (event { "connection"?, "all"? }) |
| vercel / netlify | Run npx haze db:migrate before the build command, with the production DATABASE_URL |
| cloudflare (D1) | npx wrangler d1 migrations apply <db-name> --remote -c dist/wrangler.json |
| cloudflare (Turso) | Run npx haze db:migrate in CI |
migrate.mjs is only generated when the project uses @hazejs/orm. See Database.
Queue workers, the scheduler and commands
The node, bun, deno and docker presets also emit worker.mjs:
bash
node dist/worker.mjs queue # consume jobs (default queue: default)
node dist/worker.mjs queue --queue emails,default --concurrency 5 --connection redis
node dist/worker.mjs schedule # long-running scheduler
node dist/worker.mjs schedule:run # run due tasks once (call from system cron every minute)
node dist/worker.mjs command # list your commands (app/command)
node dist/worker.mjs command inspire --all # run one; exits with the command's exit codeWorkers stop gracefully on SIGTERM / SIGINT, letting running jobs finish. In full-stack projects (and always with deno) the path is dist/server/worker.mjs; in Docker, docker run <image> node server/worker.mjs queue.
The web server shuts down on SIGTERM / SIGINT too: it stops accepting connections, waits for in-flight requests, then disposes the application (queue connections, database pools, in-process workers). Long-lived connections — WebSockets, SSE streams, keep-alive sockets — never finish on their own, so after app.shutdownTimeout (default 10 seconds; seconds or '30s') they are closed forcibly. Keep the orchestrator's grace period above it (Docker's stop_grace_period defaults to 10 seconds; Kubernetes' terminationGracePeriodSeconds to 30).
--connection is the queue connection from config/queue.ts. The worker reads the same REDIS_URL (or config/redis.ts) as the web process, so the Redis settings only need to be set once per environment.
worker.mjs command runs the console commands in app/command without the CLI or the source, which makes it the place for one-off and cron-driven maintenance in production. Serverless and edge presets have no command entry.
On the other platforms scheduled tasks are triggered by the platform: Cron Triggers on Cloudflare, an EventBridge rule on Lambda, Vercel Cron on Vercel. See Queues and Task Scheduling.
Microservice listeners
Microservice handlers in app/message only receive messages while a listener runs. haze microservice:listen is a development command: it needs the CLI and the source. In production, either set autoListen: true in config/microservices.ts so every web instance also listens, or run a dedicated process. The build has no microservice entry of its own; a long-running command works:
ts
// app/command/ListenMessages.ts — node dist/worker.mjs command listen:messages
import process from 'node:process'
import { defineCommand } from '@hazejs/core'
import { listenMicroservices } from '@hazejs/microservices'
export default defineCommand({
description: 'Listen for microservice messages',
async run(_args, { app }) {
const listener = await listenMicroservices(app)
await new Promise((resolve) => {
process.once('SIGTERM', resolve)
process.once('SIGINT', resolve)
})
await listener.close() // stop consuming and let running handlers finish
},
})Like queue workers, listeners need a long-running process, so they are not available on serverless or edge presets.
Maintenance mode
Take the application down while you deploy, migrate or fix data. With the process-based presets the build has the commands built in:
bash
node dist/worker.mjs down --secret=deploy-2026 --retry=60 # 503 for everyone except /deploy-2026 holders
node dist/migrate.mjs
# ...restart the web processes...
node dist/worker.mjs upEvery instance reads the state from the cache store (config/maintenance.ts), so with a shared store such as Redis one command takes down the whole fleet, and up reaches every instance within two seconds. With the default in-process memory store the state is kept in storage/framework/down, which only affects that machine. Health checks keep returning 200, so load balancers don't remove the instances; queue workers pause between jobs and scheduled tasks are skipped.
Rolling deploys that keep old instances serving rarely need maintenance mode; use it for migrations that old code cannot run against. On Workers, Vercel, Netlify and Lambda there is no worker.mjs: use a shared store (KV, Upstash) and set the key from CI. See Maintenance Mode.
Health checks
Every build serves /health/live (liveness, always 200) and /health / /health/ready (readiness: runs the checks in config/health.ts and returns 503 when a critical one fails). They bypass global middleware, so load balancers don't need credentials:
ts
// config/health.ts
import { cacheCheck, dbCheck, defineHealthConfig } from '@hazejs/core'
import { Db } from '@hazejs/orm'
export default defineHealthConfig({
checks: { db: dbCheck(Db), cache: cacheCheck() },
})dockerfile
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:3000/health/ready || exit 1Point Kubernetes livenessProbe at /health/live and readinessProbe at /health/ready. In production, failure details are logged instead of returned. See Observability for the built-in checks and OpenTelemetry setup.
Previewing a build
bash
haze build --preset vercel
haze preview # or pnpm preview; full-stack projects can also use vite previewhaze preview runs the build output for the preset it was built with (recorded in .haze/build.json) on port 4173 (--port, --host, --open are available):
| Preset | How it runs |
|---|---|
| node / docker | Starts the output as a process and proxies requests to it (loads .env from the project root) |
| bun / deno | Same, using Bun or Deno (Deno can also come from the deno npm package) |
| aws-lambda / vercel / netlify | Calls the function handler in-process |
| cloudflare | Runs the Worker in Miniflare (pnpm add -D miniflare) |
For cloudflare, vars, D1, KV and Durable Object bindings are read from dist/wrangler.json, values from .env / .env.production are passed as bindings, local data persists in .haze/preview, and D1 migrations are applied automatically. Without Miniflare you can run npx wrangler dev -c dist/wrangler.json instead.
Docker
bash
haze build --preset docker
docker build -t my-app dist
docker run -p 3000:3000 -e APP_KEY=<key> my-appThe generated dist/Dockerfile is based on node:22-slim. Your code and pure-JS dependencies are already bundled; the runtime package.json lists only the external drivers, which are installed inside the image with npm install --omit=dev so native binaries are built for Linux. A .dockerignore is generated too, and the image has a HEALTHCHECK on /health/live (adjust it if you change the health path in config/health.ts).
A backend-only project can still serve a frontend built elsewhere: copy its build into the image and point config/static.ts at it (relative paths resolve against the project root; nothing is served while dir is unset):
ts
// config/static.ts
import { defineConfig, env } from '@hazejs/core'
export default defineConfig(() => ({
dir: env('WEB_DIST_DIR'),
spaFallback: true,
precompressed: true,
exclude: ['/api', '/socket.io'],
}))Standalone executables
haze build --compile (the binary preset) compiles the backend into one executable per platform with Bun's bun build --compile. Each executable contains the Bun runtime, your code, every installed dependency and the migrations, so the server needs no Node.js, Bun or node_modules. The build machine needs Bun on its PATH. Every target can be built from any OS.
bash
haze build --compile # the current platform
haze build --compile --target linux-x64,darwin-arm64,windows-x64| Target | Runs on |
|---|---|
linux-x64, linux-arm64 | glibc Linux (Debian, Ubuntu, RHEL…) |
linux-x64-musl, linux-arm64-musl | Alpine and other musl distributions; install libstdc++ and libgcc (apk add libstdc++ libgcc) |
darwin-x64, darwin-arm64 | macOS on Intel / Apple silicon |
windows-x64, windows-arm64 | Windows (.exe) |
The executables are written to dist/<name>-<target> (plus .exe on Windows), named after name in package.json. Intermediate JavaScript goes to .haze/binary, so dist only contains what you deploy. Targets and the name can also be set in haze.config.ts. --target takes precedence over binary.targets, and preset: 'binary' works the same as --compile:
ts
// haze.config.ts
import { defineHazeConfig } from '@hazejs/vite'
export default defineHazeConfig({
binary: {
targets: ['linux-x64', 'windows-x64'],
name: 'my-api', // default: the package.json name
},
})The web server, migrations and workers are subcommands of the same executable:
bash
./my-api-linux-x64 # web server (PORT / HOST; .env is read from the working directory)
./my-api-linux-x64 migrate # production migrations (with @hazejs/orm; --connection, --all)
./my-api-linux-x64 worker queue # queue worker; also schedule, schedule:run, command <name>, down, up
./my-api-linux-x64 --helphaze start and haze preview run the executable for the current platform. In full-stack projects, deploy dist/client next to the executable: static files are served from <executable dir>/client.
Drivers in executables
Bundlers cannot follow every dependency, so the compile step handles the ones that would otherwise break:
| Driver | In the executable |
|---|---|
postgres, node-postgres, mysql, mongodb, turso | Bundled (pure JavaScript) |
sqlite (libsql) | The libsql native module for each target is embedded. Modules for other platforms are downloaded from the npm registry (npm_config_registry is honored) and cached in node_modules/.cache/hazejs/native. libsql has no windows-arm64 build |
pglite | Its WASM and data files are embedded (so is any asset a package loads with new URL('./file', import.meta.url)) |
Redis (ioredis), queues (bullmq) | Bundled. msgpackr-extract is optional and falls back to JavaScript |
d1 | Cloudflare only |
Drivers you have not installed stay external and only fail when used, just like the other presets. Other native packages (better-sqlite3, sharp, bcrypt…) load platform binaries at runtime in ways that cannot be embedded; the build warns when your code imports one. Prefer a JavaScript or WASM alternative (Hash already uses node:crypto).
Keep in mind:
- The executable runs on Bun, not Node.js. The
bunpreset uses the same runtime, sohaze build --preset bun && bun dist/index.mjsis a quick compatibility check. - Bun's own
.envloading is turned off. The app loads.env/.env.productionfrom the working directory, as the other presets do. - Executables are about 60–120 MB, because they include the runtime.
- Executables built on macOS are ad-hoc signed. To distribute them, sign (and notarize) macOS builds with your Developer ID, and sign Windows builds to avoid SmartScreen warnings.
AWS Lambda
dist/lambda contains index.mjs, its traced node_modules and (for full-stack apps) the client build. Zip the folder and create a function with runtime nodejs22.x:
| Handler | Purpose |
|---|---|
index.handler | HTTP: API Gateway (HTTP API and REST API), ALB and Function URL events |
index.schedule | Scheduled tasks, triggered by an EventBridge rule rate(1 minute) |
index.migrate | Production migrations (only with @hazejs/orm) |
All three use the same bundle. WebSocket and Socket.IO are not available on Lambda. ctx.ip (and so rate limiting) uses the event's sourceIp for API Gateway and Function URL events; ALB events carry no source address, so behind an ALB set trustProxy: true in config/app.ts.
Vercel
The vercel preset writes the Build Output API layout to .vercel/output: static files in static/, the server in functions/__server.func (Node.js 22) and routing in config.json. Deploy it with vercel deploy --prebuilt. Vercel rewrites X-Forwarded-For, so set trustProxy: true in config/app.ts for ctx.ip and rate limiting to see the real client.
If the project has scheduled tasks, a Vercel Cron job calls /__haze/schedule every minute. Set CRON_SECRET (vercel env add CRON_SECRET); requests without Authorization: Bearer $CRON_SECRET get a 401. Hobby plans only allow daily crons, so adjust the expression:
ts
// haze.config.ts
import { defineHazeConfig } from '@hazejs/vite'
export default defineHazeConfig({
preset: 'vercel',
vercel: { cron: '0 3 * * *' },
})Netlify
The netlify preset builds the server to dist/server and writes a Netlify Functions (v2) entry at .netlify/v1/functions/haze-server.mjs. If there is no netlify.toml, one is created with the build command and publish directory; full-stack builds also get a _redirects SPA fallback. Deploy with netlify deploy --build. Netlify rewrites X-Forwarded-For, so set trustProxy: true in config/app.ts for ctx.ip and rate limiting to see the real client.
Cloudflare
The cloudflare preset bundles everything into a single dist/index.mjs and generates dist/wrangler.json with nodejs_compat enabled. For full-stack projects, dist/client is served by Workers Assets with SPA fallback, and the backend prefixes are routed to the Worker first. The generated entry passes cf-connecting-ip as the client address, so ctx.ip and rate limiting work without trustProxy.
bash
haze build --preset cloudflare
npx wrangler secret put APP_KEY -c dist/wrangler.json
npx wrangler d1 migrations apply my-db --remote -c dist/wrangler.json # when using D1
npx wrangler deploy -c dist/wrangler.jsonBindings
Anything in cloudflare.wrangler is merged into the generated wrangler.json, so this is where D1, KV, R2, Queues, vars and routes go:
ts
// haze.config.ts
import { defineHazeConfig } from '@hazejs/vite'
export default defineHazeConfig({
preset: 'cloudflare',
cloudflare: {
wrangler: {
vars: { DB_DRIVER: 'd1', CACHE_STORE: 'kv', QUEUE_CONNECTION: 'cloudflare' },
d1_databases: [
{ binding: 'DB', database_name: 'my-db', database_id: '<id>' },
],
kv_namespaces: [
{ binding: 'CACHE', id: '<id>' },
],
r2_buckets: [
{ binding: 'BUCKET', bucket_name: 'my-bucket' },
],
queues: {
producers: [{ binding: 'QUEUE', queue: 'jobs' }],
consumers: [{ queue: 'jobs' }],
},
},
},
})Each driver reads its binding by name:
| Feature | Config | Default binding |
|---|---|---|
| Database | { driver: 'd1', binding: 'DB' } in config/database.ts, or turso over HTTP | DB |
| Cache | { driver: 'kv', binding: 'CACHE' } in config/cache.ts | CACHE |
| Storage | { driver: 'r2', binding: 'BUCKET' } in config/filesystem.ts | BUCKET |
| Queues | { driver: 'cloudflare', binding: 'QUEUE' } in config/queue.ts | QUEUE |
The project templates already contain the kv cache store and cloudflare queue connection as commented-out entries — uncomment them before switching CACHE_STORE / QUEUE_CONNECTION. The vars above only take effect if your config files read them. A common pattern for the database is SQLite locally and D1 on Workers:
ts
// config/database.ts
import { env } from '@hazejs/core'
import { defineDatabaseConfig } from '@hazejs/orm'
export default defineDatabaseConfig(() => ({
default: 'main',
connections: {
main: env('DB_DRIVER', 'sqlite') === 'd1'
? { driver: 'd1', binding: 'DB' }
: { driver: 'sqlite', url: env('DATABASE_URL', 'file:./data/app.db') },
// Turso: { driver: 'turso', url: env('DATABASE_URL'), authToken: env('DATABASE_AUTH_TOKEN') }
},
}))D1 databases without migrations_dir use the migrations/ folder copied into dist/. D1 does not support interactive transactions — use (await Db.drizzle()).batch([...]) for atomic writes.
The generated Worker also wires up the rest of the platform:
- Queues — the Worker exports
queue(), which consumes Cloudflare Queues batches; failed jobs are retried withmessage.retry({ delaySeconds }). - Cron Triggers — when the project has scheduled tasks,
triggers.cronsis set to every minute (unless you settriggersyourself) andscheduled()runs the due tasks. - Durable Objects — when routes use
r.ws()(orcloudflare.websocket: true), aHazeWebSocketDurable Object and its migration are added; all WebSocket connections live in it soSocket.publishreaches everyone. See WebSocket.
Environment and platform bindings
Workers have no .env. Before handling each request, the entry calls setPlatformBindings(env) so drivers can look up bindings, and on the first request it copies string bindings (vars and secrets) into process.env, so env('APP_KEY') works as usual. Read a binding in your own code with usePlatformBinding() (setPlatformBindings() is exported too, for custom entries and tests):
ts
// app/service/ImageService.ts
import { usePlatformBinding } from '@hazejs/core'
export class ImageService {
images() {
return usePlatformBinding<{ fetch: typeof fetch }>('IMAGES')
}
}Unsupported drivers
Packages that need TCP sockets or native code cannot run on Workers. They are replaced at build time with modules that throw when used: ioredis, bullmq, mysql2, mongodb, libsql, @electric-sql/pglite, nodemailer, socket.io, @socket.io/bun-engine and @socket.io/redis-adapter. Use the edge-friendly alternatives instead: D1 or Turso for the database, KV or Upstash for cache, Cloudflare Queues, an HTTP mail driver, R2 for storage, and r.ws() instead of Socket.IO.