Deployment
What you deploy
s200 is a function before it is a server. The entire runtime contract is handle(app, request) — a Web Standard Request in, a Response out:
import { createApp, handle } from 's200';
const app = createApp();
const response = await handle(app, request);Every adapter is a thin wrapper over that call — build the Request from the platform's event, call handle, write the Response back (see adapters). Any runtime that can construct a Request is already a deployment target; the rest of this guide is production wiring, not porting.
Node in production
A production entry is serve + lifecycle. serve(app, options) binds the port (port defaults to 0 — ephemeral, so set it explicitly; host omitted binds all interfaces). lifecycle(server, options) turns SIGTERM/SIGINT into a graceful drain in the load-balancer-safe order: the readiness gate closes FIRST (probes answer 503, traffic stops arriving), then the server stops accepting connections, in-flight requests get the budget to finish, stragglers are hard-killed on deadline — or on a second signal — and only then onShutdown runs:
// server.mjs — the whole production entry
import { createApp, get } from 's200';
import { serve } from 's200/node';
import { lifecycle } from 's200/lifecycle';
import { createGate, health, readiness } from 's200/health';
const gate = createGate();
const app = createApp();
get(app, '/live', health());
get(app, '/ready', readiness({
db: () => db.query('select 1'), // any () => void | Promise<void>
drain: gate.check, // 503 "draining" from the first drain step
}));
const server = await serve(app, {
port: Number(process.env.PORT ?? 3000),
host: process.env.HOST ?? '0.0.0.0', // bind 127.0.0.1 behind a local reverse proxy
});
const drain = lifecycle(server, {
timeout: 15_000, // in-flight budget in ms — default 10_000
readiness: gate, // closed with "draining" before any socket closes
onShutdown: () => db.close(),
});
await drain.stopped; // signals ride the same path as stop(); never rejectslifecycle duck-types the raw server the adapter exposes: the node path reaps keep-alive sockets that go idle mid-drain and force-closes on deadline; the Bun path maps to stop(false)/stop(); anything else falls back to the adapter's close(). Options: signals (default ['SIGTERM', 'SIGINT'], pass [] to keep them to yourself), timeout (drain budget, default 10 000 ms), readiness (the gate — structural, anything with close(reason?)), onShutdown (your cleanup hook).
Dockerfile
Multi-stage keeps the image to node_modules + what your build emits — no toolchain in the final layer:
# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build # your build — emits server.mjs and friends
FROM node:22-alpine AS prod-deps
WORKDIR /app
RUN corepack enable pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
FROM node:22-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/server.mjs ./
USER node
EXPOSE 3000
CMD ["node", "server.mjs"]Run with docker run --init (or Compose init: true) so signals reach node properly as PID 1.
systemd
KillSignal=SIGTERM matches what lifecycle listens for by default, and TimeoutStopSec must exceed the drain budget — otherwise systemd SIGKILLs the process mid-drain:
[Unit]
Description=s200 app
After=network-online.target
Wants=network-online.target
[Service]
User=app
WorkingDirectory=/srv/app
ExecStart=/usr/bin/node server.mjs
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=HOST=127.0.0.1
KillSignal=SIGTERM
TimeoutStopSec=20
Restart=on-failure
[Install]
WantedBy=multi-user.targetHealth probes
s200/health gives the two-signal split orchestrators want. health() is liveness — a process that can answer at all is alive, zero dependencies, so one bad database never gets the container restarted:
import { get } from 's200';
import { health, readiness } from 's200/health';
get(app, '/live', health()); // 200 {"status":"ok"}
get(app, '/ready', readiness({
db: () => db.query('select 1'), // each check: returns/resolves = healthy
drain: gate.check, // the gate trips while draining
}, { timeout: 500 })); // per-check budget in ms — default 1000readiness(checks, options) runs every check concurrently under one per-check budget (default 1000 ms): all healthy → 200 {"status":"ok","checks":{"db":"ok",...}}, any failure → 503 with the failing checks' reasons. The drain: gate.check entry is what ties probes to graceful drain: wire the same createGate() gate into lifecycle's readiness option, and /ready starts answering 503 the moment the drain begins — before any socket closes, so the load balancer or the kubelet stops routing to the pod first.
Kubernetes one-liners:
livenessProbe:
httpGet: { path: /live, port: 3000 }
readinessProbe:
httpGet: { path: /ready, port: 3000 }Serverless / edge
Cloudflare Workers — s200/cloudflare builds the module worker's default export, with the client-disconnect signal wired through:
import { createHandler } from 's200/cloudflare';
export default createHandler(app);Vercel — s200/vercel builds the platform's entry from the same app: createVercelHandler(app) is handle under the Web Standard fetch export, for both the Edge Runtime and Node.js (Fluid Compute included):
// api/index.ts
import { createVercelHandler } from 's200/vercel';
export default createVercelHandler(app);AWS Lambda — s200/aws-lambda translates the API Gateway HTTP event both ways (payload format v2 only): cookies are joined into the request's Cookie header and a base64-flagged body is decoded on the way in; on the way out, each Set-Cookie becomes its own cookies array entry, textual bodies go back as strings, and everything else returns base64-encoded:
// src/lambda.ts
import { createLambdaHandler } from 's200/aws-lambda';
export const handler = createLambdaHandler(app);Point the function at <file>.handler — a function URL or an HTTP API Lambda proxy integration both work. The full event/result mapping is on the serverless adapters page. Any runtime without a shipped adapter still speaks the plain handle(app, request) contract above — wire it into whatever entry shape the platform wants.
Bun
s200/bun serves through Bun.serve — same serve(app, { port }) shape, see adapters. lifecycle works on its result unchanged (stop(false) graceful, stop() forced). For a single-file binary, compile the entry:
bun build --compile server.ts --outfile app
./appProduction checklist
- Behind a proxy? Register
trustProxy({ hops: 1 })froms200/trust-proxybefore anything that reasons about the origin — it rewritesctx.urlfromX-Forwarded-Proto/X-Forwarded-Hostand puts the client IP onctx.state.proxy.clientIp.hopsis the number of proxies you trust (default 1). - Rate-limit at the edge —
rateLimitfroms200/rate-limit(sliding window, injectable store) before the routes anonymous traffic can reach. - Correlate everything —
requestIdfroms200/request-idstamps a unique header the logger and your logs can join on. - Compress and cache —
compress(s200/compress,Content-Encodingnegotiation) andetag(s200/etag, conditional 304s on byte-backed bodies). - Bound slow handlers —
timeout(ms)froms200/timeoutanswers 504 instead of pinning a connection. - Set browser-facing headers centrally —
secureHeadersfroms200/secure-headers(HSTS belongs at the TLS edge) andcorsfroms200/corsfor cross-origin browser APIs. - Wire the drain —
serve+lifecycle+ thecreateGate()gate in/ready(see above), and align the orchestrator's stop timeout withlifecycle'stimeout. - Bind deliberately —
HOST=127.0.0.1when a local reverse proxy terminates TLS; exposePORTas the one knob your platform sets.