Deployment
WebJs runs as a standard server on Node 24+ or Bun. There is no static export, no serverless adapter, and no edge runtime yet. Deploy it anywhere you can run Node or Bun: a VPS, a container, a PaaS like Fly.io or Railway, or behind a reverse proxy on bare metal. On Node the minimum is set by the built-in TypeScript type-stripping; on Bun the stripping comes from amaro automatically, so the same source runs on either.
Dev vs Prod
WebJs has two modes, controlled by the npm script (which wraps the underlying webjs dev / webjs start CLI):
# Development: live reload, no compression, no caching, verbose errors npm run dev -- --port 8080 # Production: compression, ETags, cache headers, graceful shutdown npm run start -- --port 8080
Key differences:
- Dev: Node's built-in
fs.watchwatches your source tree and triggers live reload via SSE. TypeScript files are served withCache-Control: no-cache. Errors include full stack traces. No compression. - Prod: no file watcher, no SSE endpoint. Static files get ETags and
Cache-Control: public, max-age=3600. Auto-vendored npm packages getmax-age=31536000, immutable. Gzip and Brotli compression are enabled. Error responses omit stack traces.
No build step
Recommended for production: HTTP/2 at the edge
webjs's per-file-ESM model rides HTTP/2 multiplex to be competitive with bundling. PaaS edges already serve HTTP/2 for free. Railway, Fly, Render, Vercel, Cloudflare Pages, and Heroku all terminate TLS + HTTP/2 at the edge and proxy plain HTTP/1.1 to your container. For bare-VM deploys, put nginx, Caddy, or Traefik in front to do the same job. The production server (npm run start) only speaks plain HTTP/1.1, so TLS termination is the proxy's responsibility, not the framework's.
The same .js / .ts source files that ran in npm run dev run in npm run start. There is no compile, bundle, or "prepare for production" phase. Production performance comes from HTTP/2 multiplex plus SSR-time modulepreload hints, not concatenation.
The full mechanism (importmap, module graph, vendor bundling, 103 Early Hints, granular cache invalidation) lives in No-Build Model. This page covers the deployment-side concerns.
Production Features
Compression
In production mode, WebJs automatically negotiates Accept-Encoding and compresses responses with Brotli (quality 4) or Gzip (level 6). Compression applies to text-based content types: HTML, JavaScript, JSON, CSS, SVG, XML. Binary assets (images, fonts) are served uncompressed.
ETags and Cache Headers
Static files are served with a SHA-1 ETag and a 1-hour max-age. Vendor npm packages resolve through importmap to jspm.io URLs (default) or to local /__webjs/vendor/<pkg>@<version>.js paths (after webjs vendor pin --download). Direct jspm.io URLs use jspm.io's own immutable headers; locally-served --download bundles use max-age=31536000, immutable. In dev, all files use Cache-Control: no-cache.
Conditional GET (ETag + If-None-Match)
Every cacheable response carries a content-hash ETag, and a repeat request whose If-None-Match matches it gets a 304 Not Modified with no body (RFC 7232). A client holding an identical copy revalidates with a tiny 304 instead of re-downloading the whole body. This applies uniformly to static assets, app source modules, the core / vendor runtime, and to SSR HTML pages that opt into public caching via metadata.cacheControl. The ETag is a weak validator (W/"..."), since it is computed over the uncompressed body and reused across the identity, gzip, and Brotli encodings, which a strong validator may not do (RFC 7232 2.3.3).
Private content is excluded. A page with the default Cache-Control: no-store (every dynamic / per-user page) gets no ETag and never returns a 304, so a shared cache can never replay one user's validator to another. A private response is excluded for the same reason. Streaming responses are excluded too, both streamed Suspense pages and a route.{js,ts} handler that returns a ReadableStream (including an SSE text/event-stream). Their bodies are never buffered to be hashed, so a long-lived or never-ending stream is never read into memory and never stalls the response. A 304 preserves the validators and caching headers (ETag, Cache-Control, Vary) and drops only the body.
Set a page's metadata.cacheControl to a public value to enable conditional GET on it:
export const metadata = { cacheControl: 'public, max-age=60' };
Content Security Policy (CSP) and vendor packages
The default vendor mode serves bundles from https://ga.jspm.io (the jspm.io CDN). If your app sets a strict Content-Security-Policy header with script-src 'self', the browser blocks the jspm.io script and vendor imports fail to load.
Two ways to handle this:
- Allow jspm.io in CSP: add
https://ga.jspm.ioto yourscript-srcdirective. Example:script-src 'self' https://ga.jspm.io. Browsers fetch bundles from jspm.io's CDN. Same-origin-only consumers (compliance-locked, air-gapped) cannot use this mode. - Switch to
--downloadmode: runwebjs vendor pin --downloadat deploy-prep time and commit the resulting.webjs/vendor/<pkg>@<version>.jsbundle files. The importmap then points at local/__webjs/vendor/paths served by your own origin.script-src 'self'alone is sufficient; no third-party allowlist needed. Suitable for compliance-locked, air-gapped, or strictest-CSP environments.
Pick the mode that matches your security posture. The choice is per-deploy, not per-package: either everything goes through jspm.io or everything is locally vendored. Mixing modes per-package is not supported.
Secure response headers
WebJs sets a baseline of standard security headers on every response, so a deployed app is not clickjackable or MIME-sniffable without any reverse-proxy configuration. The defaults are literal HTTP headers:
X-Content-Type-Options: nosniffX-Frame-Options: SAMEORIGINReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: camera=(), microphone=(), geolocation=()Strict-Transport-Security: max-age=63072000; includeSubDomainsin production over HTTPS only
HSTS is gated to production AND HTTPS. WebJs detects the original scheme from X-Forwarded-Proto (the header the trusted edge proxy forwards after terminating TLS), honoring the same proxy-trust posture as the rest of the framework, so HSTS is never set on a plain-HTTP hop or in dev. Set WEBJS_NO_TRUST_PROXY=1 to stop trusting forwarded headers when the container is directly exposed.
A default is set only when the response does not already carry that header, so anything your middleware, a route.{js,ts} handler, or expose sets always wins.
Per-path overrides
Declare per-path header rules in package.json under "webjs": { "headers": [...] }, shaped like Next's. The source is a path pattern matched with the native URLPattern API, so :param and :rest* tokens work:
{
"webjs": {
"headers": [
{ "source": "/embed/:path*", "headers": [{ "key": "X-Frame-Options", "value": null }] },
{ "source": "/app/:path*", "headers": [{ "key": "X-Frame-Options", "value": "DENY" }] }
]
}
}
A rule can ADD a header, OVERRIDE a default by giving a new value, or DISABLE a default on a path with a null value (the first example drops X-Frame-Options so a public-embed route can be framed). Precedence, lowest to highest, runs secure defaults, then the webjs.headers path config, then app middleware (which always wins, since its headers are already on the response when WebJs merges).
Content-Security-Policy (nonce, opt-in)
WebJs can mint a fresh per-request CSP nonce and emit a matching Content-Security-Policy response header. It is OFF by default (a strict policy would break an app with third-party inline scripts/styles, so you opt in). Enable it with a webjs.csp key in package.json:
{
"webjs": { "csp": true }
}
true turns on a strict-by-default policy: script-src 'nonce-<minted>' 'strict-dynamic' 'self' https: plus default-src 'self', object-src 'none', frame-ancestors 'self', and an inline-style allowance for the Tailwind runtime. On every request the framework mints a CSPRNG nonce (16 random bytes, base64), stamps it on every inline <script>, the importmap, and the modulepreload hints it emits (the same value cspNonce() returns during SSR), and sets the header carrying that exact nonce. The nonce on the header and the nonce on the scripts are one minted value, so there is no drift, and it changes every request.
For a custom policy, give an object. directives is merged over the strict defaults (override one directive without restating the rest; a null value drops a default directive), and reportOnly: true emits Content-Security-Policy-Report-Only for a staged rollout:
{
"webjs": {
"csp": {
"directives": { "connect-src": "'self' https://api.example.com" },
"reportOnly": true
}
}
}
The __NONCE__ placeholder inside a directive value (e.g. in a custom script-src) is substituted with the minted nonce per request. A CSP header your app already set (in middleware, a route.{js,ts} handler, or the webjs.headers config) is never clobbered, so an explicit app policy still wins. Inside layouts/pages, read the nonce with import { cspNonce } from '@webjsdev/core' to stamp it on your own inline <script> tags; it is isomorphic (returns '' in the browser, so the same source is safe to ship).
Request body limits & server timeouts
The server hardens its request ingress by default. Every request body it reads (the action RPC endpoint, route.{js,ts} handlers via readBody, and the no-JS page-action form path) is size-capped: 1 MiB for JSON / RPC (webjs.maxBodyBytes / WEBJS_MAX_BODY_BYTES) and 10 MiB for form / multipart (webjs.maxMultipartBytes / WEBJS_MAX_MULTIPART_BYTES). An over-limit body responds 413 Payload Too Large without being buffered whole, so a hostile large upload cannot exhaust memory.
The HTTP server also sets node:http timeouts to defend against slowloris and hung connections: requestTimeout (30s, webjs.requestTimeoutMs / WEBJS_REQUEST_TIMEOUT_MS), headersTimeout (20s, webjs.headersTimeoutMs / WEBJS_HEADERS_TIMEOUT_MS), and keepAliveTimeout (5s, webjs.keepAliveTimeoutMs / WEBJS_KEEP_ALIVE_TIMEOUT_MS). Per node semantics headersTimeout must be under requestTimeout to fire; an inconsistent config is clamped automatically. A value of 0 disables any of these (e.g. when an edge proxy already enforces them). On Bun the server uses Bun.serve (see below), which has one inactivity bound rather than three; requestTimeout maps to Bun's idleTimeout, clamped above the live-reload keepalive so a dev SSE stream is never reaped. Note that an inactivity bound is weaker against a slow-but-steady trickle body than node's total-request cap, and Bun has no separate headers/keep-alive timeout, so on Bun put an edge proxy in front for hard request caps if slowloris is a concern.
Graceful Shutdown
On SIGINT or SIGTERM, webjs:
- Stops accepting new connections.
- Closes all SSE (live reload) clients.
- Waits for in-flight requests to drain.
- Exits cleanly after drain completes, or force-exits after a 10-second timeout.
Unhandled promise rejections are logged but do not crash the process. Uncaught exceptions trigger an orderly shutdown (state may be corrupted, so continuing is unsafe).
Health and readiness probes
WebJs answers two built-in probe endpoints, and the distinction matters under runtime-first boot:
GET /__webjs/health # liveness: always 200 once the process is listening GET /__webjs/ready # readiness: 503 until the instance is fully warm, then 200
/__webjs/health is liveness. It returns 200 { "status": "ok" } as soon as the process is accepting connections, so an orchestrator can tell the process is alive. It never waits on the analysis.
/__webjs/ready is readiness. Because boot is instant and the whole-app analysis runs lazily on the first request, /ready returns 503 { "status": "pending" } until the instance is fully warm, then 200 { "status": "ok" }. Fully warm means both the deterministic analysis and the first vendor attempt have completed, so the importmap and its build id are settled. Point your readinessProbe at it and the orchestrator holds traffic off an instance until then, instead of routing the first user request into the cold analysis OR into the brief window where the importmap is still resolving. A background warm-up runs automatically once the server is listening, so on a rolling deploy the prior instance keeps serving until the new one is fully warm. The first vendor attempt is bounded by the jspm fetch timeout, so a vendor-CDN hiccup does not hold readiness down indefinitely: the instance becomes ready shortly after the timeout and serves with the resolved-or-best-effort importmap, and a transient failure is re-attempted on the next request.
Both responses carry Cache-Control: no-store. Use them for Kubernetes probes, Docker HEALTHCHECK, load-balancer health checks, or uptime monitoring.
# Kubernetes deployment
livenessProbe:
httpGet:
path: /__webjs/health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet:
path: /__webjs/ready
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
Gating readiness on dependencies (optional)
Warm-complete does not by itself prove the database or a queue is reachable: the database driver connects lazily on the first query (node:sqlite opens the file, pg connects), not at warm-up. To gate readiness on live dependency health, add a readiness.{js,ts} file at the app root that default-exports an async function. Once the analysis is warm, /ready runs it on every probe; returning false or throwing reports 503 { "status": "unready" }, so the orchestrator holds traffic off an instance whose dependencies are down.
// readiness.ts
import { db } from './db/connection.server.ts';
export default async function ready() {
await db.query.users.findFirst(); // throws if the database is unreachable
return true;
}
HTTP/2: at the edge, not in webjs
WebJs delegates TLS termination and HTTP/2 negotiation to whatever sits in front of npm run start. The framework's HTTP server speaks plain HTTP/1.1. ALPN, certificates, and h2 framing are entirely the proxy's concern. Two reasons:
- PaaS already gives you HTTP/2. Railway, Fly, Render, Vercel, Cloudflare Pages, and Heroku all terminate TLS + HTTP/2 at their edge and proxy plain HTTP/1.1 to your container. Zero framework configuration: you get HTTP/2 to the browser the moment you deploy.
- For bare-VM, reverse proxies do it better. nginx, Caddy, and Traefik are battle-tested for TLS termination. They handle cert renewal (ACME), OCSP, ALPN, HTTP/3, and h2-to-h1 downgrade more capably than Node's
http2module.
Multiplexed streams and header compression (HPACK) are what make per-file ESM competitive with bundling. No-Build Model explains why, and which transport features matter for the import graph.
Forwarding 103 Early Hints. WebJs sends a 103 Early Hints response carrying Link: rel=modulepreload headers before SSR begins, so the browser can start fetching JS while the server renders. Most major edges (Cloudflare, fly-proxy, Fastly) forward 103 responses to the client transparently. If yours doesn't, the page still works (the headers are just lost) but you skip the head-start. Early Hints are disabled in dev because file churn could send stale URLs.
Pluggable Logger
WebJs includes a minimal logger that writes structured JSON in production and human-readable lines in development:
# Dev output:
[webjs] webjs dev server ready on http://localhost:8080
# Prod output (one JSON line per event):
{"level":"info","msg":"webjs prod server ready on http://localhost:8080","time":"2026-01-15T10:30:00.000Z"}
Replace it with your own logger by passing any object with info, warn, and error methods to createRequestHandler:
import { createRequestHandler } from '@webjsdev/server';
import pino from 'pino';
const logger = pino({ level: 'info' });
const app = await createRequestHandler({
appDir: process.cwd(),
dev: false,
logger: {
info: (msg, meta) => logger.info(meta, msg),
warn: (msg, meta) => logger.warn(meta, msg),
error: (msg, meta) => logger.error(meta, msg),
},
});
Observability: access log, request id, error hook, build-info
Day-2 ops needs more than liveness probes. WebJs ships four standards-native observability surfaces, all wired at the single response funnel so they apply uniformly across pages, route handlers, server actions, and assets.
Per-request access log
Every handled request emits ONE structured info line through the pluggable logger after the response is produced, carrying method, path, status, durationMs, and requestId. It never logs request bodies or secrets. In prod the default logger writes it as one JSON object per line; in dev it is a readable line.
{"level":"info","msg":"request","time":"2026-06-03T10:30:00.000Z","requestId":"4f1c…","method":"GET","path":"/dashboard","status":200,"durationMs":12.4}
The framework's own /__webjs/* probe and static traffic is suppressed from the access log so it does not spam. App routes (including your /api/*) are logged. Swap in pino / your aggregator via the pluggable logger above and these lines flow straight into it.
durationMs is time-to-response-headers (a TTFB-like measure), not full-stream completion. For a streaming / Suspense response it reflects when the headers were produced, not when the last chunk flushed.
Request id / correlation id (X-Request-Id)
Each request gets a correlation id, minted with the native crypto.randomUUID(). An inbound X-Request-Id from a trusted upstream proxy is honored instead (so one trace id propagates across services); a missing or malformed value falls back to a minted id. The id is set on the response as X-Request-Id, included in the access log and the error log, and readable inside any server-side code (pages, layouts, server actions, route handlers, middleware) via requestId():
import { requestId } from '@webjsdev/server';
export async function GET() {
const id = requestId(); // same id the response's X-Request-Id carries
return Response.json({ traceId: id });
}
onError hook (APM / Sentry integration point)
Register an error sink to forward unhandled errors to your APM. createRequestHandler({ onError }) (and startServer({ onError })) calls it whenever the request pipeline catches an unhandled error: the 500 path (a thrown route handler, middleware, or page render), or a server action that throws unexpectedly. The sink receives the original error plus a context object with the request, the requestId, and a coarse phase label, so you can correlate the report with the access log line.
import { createRequestHandler } from '@webjsdev/server';
import * as Sentry from '@sentry/node';
const app = await createRequestHandler({
appDir: process.cwd(),
onError(error, { request, requestId, phase }) {
Sentry.captureException(error, { tags: { requestId, phase } });
},
});
The contract is best-effort. A throwing onError is caught and ignored so it can never crash the response, and the hook is purely additive: webjs's existing behavior (the sanitized 500, with only error.message in prod and never the stack) is unchanged. The hook fires BEFORE the sanitized response is sent, so the sink always sees the real error.
Build-info endpoint
GET /__webjs/version returns JSON describing the live build, alongside the health and readiness probes. A deploy can curl it to confirm which build is serving. It carries no secrets, and it is answered before the analysis warms (like the other probes), so it responds on a cold instance.
GET /__webjs/version
{ "version": "0.8.10", "build": "<importmap-hash>", "node": "v24.4.0", "uptime": 38.21 }
version is the @webjsdev/server framework version, build is the published build id (the reload signal the client router reads from data-webjs-build, the importmap hash folded with the installed @webjsdev/core version; empty until the vendor map resolves). An app-source or SSR-only deploy is carried by a separate X-Webjs-Src / data-webjs-src signal (an automatic content hash of the app source) that evicts client caches softly; both are automatic, needing no configuration. node is the running Node version, and uptime is process uptime in seconds. The response carries Cache-Control: no-store.
createRequestHandler for Embedding
If you need to embed WebJs inside an existing server (Express, Fastify, Bun, Deno, serverless), use createRequestHandler directly. It returns a handle(req: Request) => Promise<Response> function that takes a standard Web API Request and returns a standard Response:
import { createRequestHandler } from '@webjsdev/server';
const app = await createRequestHandler({
appDir: '/path/to/your/app',
dev: false,
});
Express
import express from 'express';
import { createRequestHandler } from '@webjsdev/server';
const app = await createRequestHandler({ appDir: process.cwd(), dev: false });
const server = express();
server.all('*', async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const webReq = new Request(url, {
method: req.method,
headers: Object.fromEntries(
Object.entries(req.headers)
.filter(([, v]) => v != null)
.map(([k, v]) => [k, Array.isArray(v) ? v.join(',') : String(v)])
),
body: ['GET', 'HEAD'].includes(req.method) ? undefined : req,
duplex: 'half',
});
const resp = await app.handle(webReq);
res.status(resp.status);
resp.headers.forEach((v, k) => res.setHeader(k, v));
if (resp.body) {
const reader = resp.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
}
res.end();
});
server.listen(8080);
Bun
Running a WebJs app with bun --bun run start already uses Bun.serve natively: startServer detects Bun and selects a Bun.serve listener shell (skipping the node:http compatibility bridge for ~1.9x more requests/sec on the listening path), with near-complete feature parity (SSR, route.ts, SSE live-reload, WebSocket upgrade, brotli/gzip compression, timeouts, proxy-IP, and the X-Forwarded-Proto / X-Forwarded-Host URL correction so absolute URLs carry the original scheme and host behind a TLS-terminating proxy). The one node-only exception is 103 Early Hints, which Bun.serve cannot send (no informational-response API). So you only need the snippet below to embed WebJs inside your own Bun.serve alongside other routes:
Embedding moves the proxy-header responsibility to you. The parity list above describes startServer, which owns the listener. createRequestHandler takes the Request you hand it and derives every absolute URL from its url, so behind a TLS-terminating proxy you must rebuild that Request with the original scheme and host yourself, or ctx.url stays on the internal http:// hop and og:image tags, canonical links, and OAuth callbacks come out wrong. The same applies to the trusted client IP. If you do not need to share the port with other routes, prefer startServer and let the framework handle it.
import { createRequestHandler } from '@webjsdev/server';
const app = await createRequestHandler({ appDir: process.cwd(), dev: false });
Bun.serve({
port: 8080,
fetch: (req) => app.handle(req),
});
Deno
import { createRequestHandler } from '@webjsdev/server';
const app = await createRequestHandler({ appDir: Deno.cwd(), dev: false });
Deno.serve({ port: 8080 }, (req) => app.handle(req));
Environment Variables
WebJs reads the following environment variables:
- PORT: server port (default: 8080). Resolved with precedence
--port>PORT(a real exported env var or aPORTin the app's.env) >8080. A real exportedPORTwins over the.envvalue, matching the auto-load's shell-wins-over-file rule. - NODE_ENV: not directly used by webjs (it uses the
devflag from the CLI command), but your app code and dependencies may read it.
There is no deploy-id env var to set. WebJs detects a deploy automatically from the CONTENT of what it serves (the no-build model, where the source hashes ARE the version): a change to your app source or a @webjsdev/server upgrade turns over the client's stale caches on the next navigation (soft, no reload), and a vendor pin or a @webjsdev/core upgrade hard-reloads. A framework upgrade reflects once the app has installed the new @webjsdev/* version (governed by your dependency range and lockfile).
For app-specific environment variables, use process.env in server-side code (pages, server actions, middleware, API routes). These are never exposed to the client.
# .env at the app root (auto-loaded at boot) DATABASE_URL="postgresql://user:pass@localhost:5432/mydb" SESSION_SECRET="change-me" API_KEY="sk-..."
WebJs auto-loads <appDir>/.env into process.env on boot via Node 24+'s built-in process.loadEnvFile. No dotenv dependency. Shell-exported values take precedence over the file, so production platforms (Railway, Fly, Render, Docker, systemd) keep injecting secrets the same way they already do. See Configuration for the full precedence rules.
Secrets: platform injection, not a committed file
WebJs deliberately stays out of secret management. There is no encrypted credentials file and no bespoke crypto subsystem. Secrets are plain environment variables, the 12-factor way, and the safe production posture is the standard one your platform already supports.
Never commit .env
The scaffold gitignores .env for you. Keep it that way. A committed .env leaks every secret to anyone with repo access and into your git history forever. Commit .env.example (keys with placeholder values) instead, so a new contributor knows what to set without seeing real values.
.env is for local development only. It is the convenient way to set DATABASE_URL, AUTH_SECRET, and the rest on your own machine. It is NOT how production secrets should reach a deployed app.
Inject production secrets through the host platform's secret store. Because a shell-exported or platform-injected value takes precedence over the .env file (and you do not ship .env to production at all), every platform's native mechanism works with no webjs-specific step:
- Railway / Fly / Render / Vercel / Heroku: set variables in the project's environment settings (or their CLI / dashboard secret store). They arrive as
process.envat runtime. - Docker / Compose: pass
--env-fileat run time (a file that lives on the host, never in the image), or use Docker / Composesecretsfor files mounted at/run/secrets. Do notCOPYa.envinto the image, and keep.envin.dockerignore(the scaffold does). - Kubernetes: a
Secretsurfaced as env vars (or a mounted file) via the pod spec. - systemd / bare VM: an
EnvironmentFile=directive pointing at a root-owned,0600file outside the repo.
Server-side code reads these with process.env.X. They never reach the browser: only names prefixed WEBJS_PUBLIC_ are exposed client-side, and the boundary is fail-closed, so a secret cannot leak by a typo. See Security for the full env boundary.
Rotate AUTH_SECRET
AUTH_SECRET signs session cookies and auth tokens, so treat it like any signing key: use 32 or more random characters, keep it only in the platform secret store, and rotate it periodically and immediately on any suspected exposure. Rotating it invalidates existing sessions and tokens (everyone is signed out), which is the point. For a zero-downtime rotation, deploy the new value during a low-traffic window and accept that active sessions end. The same applies to any SESSION_SECRET and to OAuth provider secrets.
See the Configuration page for the precedence rules and the optional env.{js,ts} boot-time validation that fails fast on a missing or malformed secret.
Database connections (Drizzle + Postgres)
SQLite needs no pool tuning. When you move to Postgres in production, size the connection pool, because connection exhaustion is the most common scaling surprise and WebJs gives no prior signal in dev (SQLite has no pool).
A WebJs server is ONE Node process per instance, and the pg driver behind Drizzle opens its own connection pool inside that process. A pg.Pool defaults to a max of 10 connections, which is fine for a single instance but multiplies as you scale: with N instances the database sees up to N times the per-instance pool connections at once. Postgres caps total connections (often 100 on a small managed plan), so a few instances on the default pool can exhaust it.
Bound the per-instance pool with max in the Pool config (the snippet below), sized so instances * max stays comfortably under the database's max_connections (leave headroom for migrations and admin tools). The pg driver behind Drizzle takes its pool size from new Pool({ max }) in code, not from a DATABASE_URL query parameter:
# The URL carries no pool-size hint; db/connection.server.ts sets the pool max. DATABASE_URL="postgresql://user:[email protected]:5432/app"
Front Postgres with a pooler when instance count is high or variable (autoscaling, many small instances, or a low max_connections plan). Point DATABASE_URL at a transaction-mode pooler (PgBouncer, or a managed pooler like Supabase, Neon, or PlanetScale) so many app connections share a small set of real database connections. With PgBouncer in transaction mode, the pg pool must NOT use prepared statements, and migrations need a DIRECT connection (the pooler does not support the session features migrations need):
# App traffic goes through the pooler (port 6543), migrations go direct (5432). DATABASE_URL="postgresql://user:[email protected]:6543/app?pgbouncer=true" DIRECT_URL="postgresql://user:[email protected]:5432/app"
// db/connection.server.ts (Postgres variant)
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import * as schema from './schema.server.ts';
// max bounds the per-instance pool; 1 behind a transaction pooler.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
export const db = drizzle({ client: pool, relations: schema.relations });
Behind a transaction pooler, set max: 1 per instance (the pooler does the multiplexing). Without a pooler, set max to a per-instance budget and keep the instance count bounded. Point drizzle-kit (via DIRECT_URL) at the direct connection for migrations. Either way, import db from the scaffolded db/connection.server.ts singleton, never construct a new Pool per request, so a process opens one pool, not one per call.
Docker / Containerisation
A minimal Dockerfile for a WebJs app:
FROM node:24-slim WORKDIR /app # Install dependencies (no native build step needed, since webjs ships no bundler) COPY package.json package-lock.json ./ RUN npm ci --omit=dev # Copy app source as-is; the server serves it directly COPY . . EXPOSE 8080 HEALTHCHECK CMD curl -f http://localhost:8080/__webjs/health || exit 1 CMD ["npx", "webjs", "start"]
Tips:
node:slimworks fine. WebJs strips TypeScript via the runtime's stripper (Node's built-inmodule.stripTypeScriptTypes, oramaroon a Bun image), so no extra system packages are needed.- Serve on Bun (the scaffold's
--runtime bunDockerfile).webjs create my-app --runtime bun(orbun create webjs my-app) generates a pureoven/bun:1Dockerfile (no Node):bun installandCMD ["bun", "--bun", "run", "start"]. This works becausewebjs db/webjs testresolve their tools (drizzle-kit, wtr) and run them under the current runtime instead ofnpx(#570), so the boot-timewebjs db migrateruns under Bun with no Node toolchain. SQLite uses the built-inbun:sqlite(no native module), so no build toolchain ortrustedDependenciesis needed. If you prefer a Node base instead, copy the Bun binary into a Node image withCOPY --from=oven/bun:1-alpine /usr/local/bin/bun /usr/local/bin/bunand start withbun node_modules/@webjsdev/cli/bin/webjs.js start(startServerselects theBun.serveshell either way). Note: a directbun webjs.js startbypasses npm lifecycle hooks, so any per-app asset aprestarthook generates (Tailwind css, generated component sources) must be baked at BUILD time in the image; only runtime-dependent steps (a DBwebjs db migrate) belong in the start command. One trade-off:Bun.servehas no informational-response API, so the 103 Early Hints modulepreload head-start (covered above) is node-only. The preload hints still ship in the document head, so this costs a small first-load latency edge only where your edge forwards 103, not correctness. npm ci --omit=devskips dev dependencies.@webjsdev/serveris a runtime dependency. WebJs is buildless end-to-end: there is no bundler or transpiler at deploy time.- Set
HEALTHCHECKto the built-in health endpoint for container orchestrators. - Drizzle has no client codegen step, so nothing to run at build time. Apply migrations at start instead. The scaffold puts
webjs db migrateunderwebjs.start.before, which runs before the server serves (a read-only prod container still applies pending migrations against its writable database). - Layer-cache deps separately: copy
package.json+package-lock.jsonandnpm cibefore copying the rest of the source, so application edits don't bust the deps layer.
Reverse Proxy (nginx / Caddy), recommended for HTTP/2
For production deployments, a reverse proxy handles TLS termination, HTTP/2, static asset caching, and load balancing. WebJs runs as an HTTP/1.1 upstream, and the proxy speaks HTTP/2 to clients.
nginx
upstream webjs {
server 127.0.0.1:8080;
}
server {
listen 443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# WebSocket upgrade
location / {
proxy_pass http://webjs;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Caddy
example.com {
reverse_proxy localhost:8080
}
Caddy automatically provisions TLS certificates via Let's Encrypt and enables HTTP/2. It also handles WebSocket upgrades transparently.
Process Managers
For non-containerised deployments, use a process manager to keep WebJs running:
# systemd unit [Unit] Description=webjs app After=network.target [Service] Type=simple User=www WorkingDirectory=/srv/my-app ExecStart=/usr/bin/webjs start --port 8080 Restart=on-failure RestartSec=5 Environment=NODE_ENV=production Environment=DATABASE_URL=postgresql://... [Install] WantedBy=multi-user.target
# Or with PM2 pm2 start "webjs start" --name my-app
Deployment Checklist
- Node 24+ or Bun installed (the TypeScript type-stripping for both server-side imports and browser-bound
.tsfiles comes from Node's built-in on Node, oramaroon Bun). npm ci --omit=devto install only runtime dependencies.- No database client codegen step (Drizzle). Pending migrations apply via
webjs db migrate, which the scaffold runs underwebjs.start.before. - No build step. Source
.js/.tsfiles are deployed as-is. TypeScript types are stripped on first request via Node's built-in stripper (whitespace replacement, byte-exact positions, no sourcemap overhead) and cached by mtime. - Set environment variables (
DATABASE_URL,SESSION_SECRET, etc.). - Use
webjs start(notwebjs dev) for production. - Configure health checks against
/__webjs/health. - HTTP/2 at the edge is recommended. PaaS deploys (Railway, Fly, Render, Vercel, Cloudflare Pages, Heroku) give you HTTP/2 to the browser automatically. For bare-VM deploys, front
npm run startwith nginx, Caddy, or Traefik. - Set up log aggregation (WebJs outputs structured JSON in production).