Skip to content

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.watch watches your source tree and triggers live reload via SSE. TypeScript files are served with Cache-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 get max-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:

  1. Allow jspm.io in CSP: add https://ga.jspm.io to your script-src directive. 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.
  2. Switch to --download mode: run webjs vendor pin --download at deploy-prep time and commit the resulting .webjs/vendor/<pkg>@<version>.js bundle 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: nosniff
  • X-Frame-Options: SAMEORIGIN
  • Referrer-Policy: strict-origin-when-cross-origin
  • Permissions-Policy: camera=(), microphone=(), geolocation=()
  • Strict-Transport-Security: max-age=63072000; includeSubDomains in 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:

  1. Stops accepting new connections.
  2. Closes all SSE (live reload) clients.
  3. Waits for in-flight requests to drain.
  4. 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 http2 module.

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 a PORT in the app's .env) > 8080. A real exported PORT wins over the .env value, matching the auto-load's shell-wins-over-file rule.
  • NODE_ENV: not directly used by webjs (it uses the dev flag 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.env at runtime.
  • Docker / Compose: pass --env-file at run time (a file that lives on the host, never in the image), or use Docker / Compose secrets for files mounted at /run/secrets. Do not COPY a .env into the image, and keep .env in .dockerignore (the scaffold does).
  • Kubernetes: a Secret surfaced as env vars (or a mounted file) via the pod spec.
  • systemd / bare VM: an EnvironmentFile= directive pointing at a root-owned, 0600 file 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:slim works fine. WebJs strips TypeScript via the runtime's stripper (Node's built-in module.stripTypeScriptTypes, or amaro on a Bun image), so no extra system packages are needed.
  • Serve on Bun (the scaffold's --runtime bun Dockerfile). webjs create my-app --runtime bun (or bun create webjs my-app) generates a pure oven/bun:1 Dockerfile (no Node): bun install and CMD ["bun", "--bun", "run", "start"]. This works because webjs db / webjs test resolve their tools (drizzle-kit, wtr) and run them under the current runtime instead of npx (#570), so the boot-time webjs db migrate runs under Bun with no Node toolchain. SQLite uses the built-in bun:sqlite (no native module), so no build toolchain or trustedDependencies is needed. If you prefer a Node base instead, copy the Bun binary into a Node image with COPY --from=oven/bun:1-alpine /usr/local/bin/bun /usr/local/bin/bun and start with bun node_modules/@webjsdev/cli/bin/webjs.js start (startServer selects the Bun.serve shell either way). Note: a direct bun webjs.js start bypasses npm lifecycle hooks, so any per-app asset a prestart hook generates (Tailwind css, generated component sources) must be baked at BUILD time in the image; only runtime-dependent steps (a DB webjs db migrate) belong in the start command. One trade-off: Bun.serve has 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=dev skips dev dependencies. @webjsdev/server is a runtime dependency. WebJs is buildless end-to-end: there is no bundler or transpiler at deploy time.
  • Set HEALTHCHECK to 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 migrate under webjs.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.json and npm ci before 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 .ts files comes from Node's built-in on Node, or amaro on Bun).
  • npm ci --omit=dev to install only runtime dependencies.
  • No database client codegen step (Drizzle). Pending migrations apply via webjs db migrate, which the scaffold runs under webjs.start.before.
  • No build step. Source .js / .ts files 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 (not webjs 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 start with nginx, Caddy, or Traefik.
  • Set up log aggregation (WebJs outputs structured JSON in production).