Docs

Deployment

app.fetch(request) is a pure Web-standard handler, so the same app runs anywhere. Pick a runtime; the code doesn't change.

One command per target

create-nifra's site template deploys to one target, chosen with --target (default bun). The app carries no server entry or build script: nifra build generates the target's entry from backend/ and routes/. The target is one line of nifra.config.ts (export const target = "vercel"), so nifra target <t> switches it and every command reads the same value.

TS
# Scaffold a site for one deploy target (bun is the default):
bun create nifra my-app --target vercel     # or: bun | node | deno | cloudflare
# add --framework to pick the UI too (react default), --docker for a bun/node image:
bun create nifra my-app --framework svelte --target node --docker

cd my-app && bun install
bun run dev       # local preview
bun run build     # nifra build: generates the target's server entry and bundles it
bun run deploy    # runs that target's deploy CLI (you stay logged-in to the vendor)

# The target lives in nifra.config.ts; switch it with one command:
bunx nifra target cloudflare

# Or add --ci github to also emit a deploy-on-push GitHub Actions workflow:
bun create nifra my-app --target cloudflare --ci github
#   → .github/workflows/deploy.yml (builds on every push/PR, deploys on push to main)
targetbuild →deployconfig scaffolded
Bun (flagship)dist/server.jsbun run start (any host), or a Docker image with --dockerDockerfile + .dockerignore with --docker
Nodedist/server.jsbun run start (any host), or a Docker image with --dockerDockerfile + .dockerignore with --docker
Deno Deploydist/server.jsdeployctl deploydeno.json
Cloudflare Pagesdist/_worker.jswrangler pages deploywrangler.toml
Vercel (Edge by default; Node opt-in).vercel/output/vercel deploy --prebuiltnone (Build Output API v3)

nifra build --target static also builds a site with no server: the prerendered pages, the client bundle and public/, for any static host. See SSG & ISR.

Public build-target adapters

A deployment adapter adds host packaging around an existing Nifra build target. It does not replace the runtime, generated server entry, or Nifra's build checks. The versioned contract in @nifrajs/web/build lets an adapter declare its base target, validate options, and return extra files plus deploy guidance. Nifra checks paths and collisions, scans text artifacts for credentials, and only swaps the output directory after the full build passes.

Install the Fly adapter with its required Node runtime and create the named Fly app before its first deploy:

Shell
bun add @nifrajs/deploy-fly @nifrajs/node
fly apps create my-nifra-app # once, before the first deploy
TS
// nifra.config.ts - keep your existing adapter and clientModule exports, and add:
import { flyAdapter } from "@nifrajs/deploy-fly"

export const target = "fly"
export const deploymentAdapter = {
  adapter: flyAdapter,
  options: { app: "my-nifra-app", primaryRegion: "iad" },
}

// Build locally; then deploy the output directory yourself.
// nifra build
// cd dist && fly deploy

The Fly reference package wraps the ordinary node target and emits a Dockerfile plus fly.toml. The deploymentAdapter configuration above registers the custom fly target. Run fly deploy from the emitted output directory; Nifra never logs in or deploys for you. Adapter code is trusted build-time code running in the CLI process. Each adapter must explicitly declare whether it supports the coordinated component pipeline; unsupported combinations fail before the previous output is replaced. Set Fly's optional healthCheckPath only when that route returns 2xx while the app is ready.

The emitted Dockerfile sets NODE_ENV=production, and production refuses in-memory stores. An app that uses one, such as the site scaffold's rate limiter, must configure a shared store, or for a single machine allow it explicitly (the scaffold reads NIFRA_ALLOW_MEMORY_RATE_LIMIT=true, set with fly secrets set). An in-memory limiter keeps one quota per machine, and fly deploy adds a spare machine by default. Deploy a new app with fly deploy --ha=false; for an app that already has several machines, run fly scale count 1. Confirm with fly status, or use a shared store.

The generated Node server derives c.clientIp from the socket peer only, which behind Fly's proxy is the proxy. Visitors then share one rate-limit bucket and get 429 once it is spent, and a shared store does not change the keying. Passing header: "fly-client-ip" to rateLimit() keys requests that arrive over HTTP per visitor, but only while Fly's proxy is the sole path to the app; behind a CDN or another proxy the header holds that proxy's address. The server's own page-loader calls to the backend carry no such header, so they stay keyed by the proxy peer. Raise max for an app that serves its pages from the same server.

The Bun, Node and Deno entries serve the client bundle from disk next to themselves; on Cloudflare and Vercel the platform serves it.

Vercel: Edge or Node.js

Ordinary SSR keeps the existing Edge output for nifra build --target vercel. For Node built-ins and Node-compatible server libraries, keep the target and opt into Node in nifra.config.ts (there is no separate runtime CLI flag):

TS
// nifra.config.ts (alongside your adapter/clientModule exports)
export const target = "vercel"
export const vercelRuntime = "node" // "edge" or omitted preserves the Edge function

// Build and deploy from the project root:
// bunx nifra build             # or: bunx nifra build --target vercel
// vercel deploy --prebuilt

Both Bun and Vite production pipelines emit Build Output API v3 at .vercel/output: static/ holds the client chunks and public files;functions/index.func/ holds the SSR function. Node mode declares nodejs22.x and exports an HTTP handler without listening at import. It serves pages and the backend mounted with apiPrefix. Programmatic builds use buildTarget("vercel", { ...options, vercelRuntime: "node" }), or the same option on buildTargetVite. A different target or an unsupported runtime is refused before clearing the previous build; remove the selector when switching targets.

This is serverless Node, not the self-hosted node target: no Bun APIs, listening sockets, persistent in-process jobs or WebSocket server. Execution duration, streaming and filesystem availability remain subject to Vercel limits; native dependencies must be deploy-compatible. selfHost does not start a server here. Builds still require Bun. Static files are served first by Vercel, so host policy in the function cannot protect CDN files. The component pipeline retains its existing Vercel Node output and does not take this ordinary-SSR selector. The generated handler is tested locally under Node; this is not a claim of a live Vercel deployment test.

The Bun, Node and Deno entries pass the socket peer as c.clientIp. Cloudflare and Vercel expose no socket, so an edge build has no caller address unless nifra.config.ts declares export const clientIp = "platform": then the entry trusts the header that platform's edge overwrites (cf-connecting-ip, x-real-ip), and per-caller middleware such as rateLimit works there. The scaffold declares it. The trust holds only while that edge is the one way in: the same bundle served by wrangler pages dev or a self-hosted workerd takes the header from the client, so drop the line before serving it publicly any other way.

A SIGTERM or SIGINT kills the Bun, Node and Deno servers at once unless nifra.config.ts exports a selfHost that sets gracefulSignals, such as export const selfHost = { gracefulSignals: { drainMs: 8_000 }, idleTimeoutSec: 60 }. With gracefulSignals the server drains instead (docker stop, Ctrl-C): in-flight requests get drainMs to finish, and delayMs keeps it serving first while a load balancer sees it go. idleTimeoutSec is how long Bun keeps a connection that moves no bytes, a request whose handler is still working included: a whole number from 0 (never) to 255, 10 by default. Node and Deno do not close a connection while its request is being handled, so they ignore it, and an edge build ignores selfHost. nifra start runs the built server, so it applies there too.

Nifra never runs the deploy or enters your cloud credentials - it scaffolds the config + a deploy script that shells out to the vendor CLI you've already authed.

Offline & service workers

@nifrajs/web/service-worker turns a build manifest into a service worker. It is opt-in and generated at build time, so an app that never calls it ships nothing.

TS
// build.ts - after buildClient(), write the worker to the ORIGIN ROOT.
import { generateServiceWorker, serviceWorkerRegistration } from "@nifrajs/web/service-worker"

export async function emitServiceWorker(manifest: {
  entry: string
  assets: readonly string[]
  css?: readonly string[]
}): Promise<string> {
  const sw = generateServiceWorker(manifest, {
    // Anything that changes exactly when the assets do: a content hash, a commit sha, a release.
    buildId: Bun.env.GIT_SHA ?? "dev",
    offlineUrl: "/offline",
  })
  await Bun.write("dist/sw.js", sw)
  // Put this in a <script> in your document shell.
  return serviceWorkerRegistration("/sw.js")
}

Serve the file from the origin root. A worker's default scope is its own directory, so one served from /assets/ could never control the pages it exists for.

The generated worker is deliberately narrow, because a service worker outlives a deploy and can hand one visitor a response produced for another:

  • Only content-hashed assets are precached. A hashed URL names its bytes, so serving it from cache forever is correct by construction. Unhashed URLs are left to the network.
  • Only the build's own URLs are answered, from the build's own cache. Any other request, an API call included, goes to the network untouched, and a cache the page or another app stored on the origin is never read.
  • Documents are never cached. Only navigations that FAIL are answered, and only with the offline page you nominate - which must be static and user-independent, since every visitor gets the same bytes. Caching HTML is how a worker serves one signed-in user the page rendered for another.
  • GET, same-origin, ok, not no-store. Anything else goes straight to the network.
  • The cache name carries the build id, and activation deletes every older cache named <cacheName>:<buildId> - so a stale worker cannot pin an old build indefinitely, and the origin's other caches are left alone.

Omit offlineUrl and a failed navigation simply fails, which is the honest default: an offline page you have not written is not better than a browser error.

CI: deploy on push

Add --ci github to emit a .github/workflows/deploy.yml tuned to the chosen target - it builds on every push/PR and deploys on a push to main. cloudflare uses cloudflare/wrangler-action, vercel the prebuilt vercel deploy, deno deployctl (OIDC). The workflow's header comment lists the exact repo secrets to set (e.g. CLOUDFLARE_API_TOKEN, VERCEL_TOKEN). Self-hosted bun/node have no universal push-to-deploy, so their workflow builds + uploads the bundle as an artifact and leaves a clearly-marked, host-specific deploy step for you to fill in (Fly, a registry push, SSH, …).

Bun (its home)

app.listen(3000) - the native Bun server. Bun is the runtime where Nifra measures fastest (see benchmarks).

Every core: Bun is single-threaded per process - for multi-core boxes, spawn one process per core, each binding the same port with app.listen(PORT, { reusePort: true }); the kernel load-balances connections across them (Linux balances ~evenly). A supervisor that spawns + restarts workers is ~20 lines - see examples/cluster.ts. Anything shared across workers (rate limits, sessions, pub/sub) needs a shared store, as in any multi-instance deploy. So do an ISR page store (signed with one storeSecret in every process - see SSG & ISR) and live refresh (one Redis Streams bus for every process).

Node & Deno

  • @nifrajs/node - serve(app, { port }) bridges to node:http.
  • @nifrajs/deno - serves app.fetch on Deno.serve.

The servers that nifra build --target node and --target deno emit run on these adapters, so the app installs the one for its target, from the same release as @nifrajs/web. Each server's static sees every request first: it answers with a file's response, or undefined to let the app route it. The generated servers serve their client bundle and public/ files that way.

Self-hosting (no CDN in front)? Hand serve a serveStatic mount, from @nifrajs/node or @nifrajs/deno (on Bun, @nifrajs/core/serve-static for listen()), and it serves the client bundle from disk - traversal-guarded, content-typed, with an immutable cache - before the app runs, leaving the SSR fast path untouched: serve(app, { port, static: serveStatic({ dir: new URL("./assets/", import.meta.url) }) }). On Cloudflare/Vercel the platform serves assets, so you don't need it there.

Every file under the mount is cached as immutable by default. If the directory also holds files that keep their name across deploys, pass the build manifest's assets as hashedFiles: only those URL paths stay immutable, and every other file is sent with cacheControl when you set one. Files up to 256 KiB carry an ETag from the exact snapshot served, so a deploy that keeps timestamps still revalidates changed bytes. Larger files stream without an ETag and revalidate by Last-Modified once their last change has settled; ordinary ranges work, while an If-Range request gets the whole file. The server that nifra build --target bun or node generates does this already. It caches only the client build's own content-hashed outputs as immutable. A file copied from public/, even under public/assets/, gets a one-day policy.

static also takes an array of mounts; a path goes to the first mount that takes it, by its prefix and, when set, its files. Give a mount files to serve only those URL paths from a directory that holds other things too; any other path passes to the next mount, so a later mount over the same paths can still serve it. The generated Node server mounts /assets and, at the root, only the public/ files the build copied, so server.js beside them is never served. Dotfiles are refused, except /.well-known.

HTTPS without a proxy

Most deploys end TLS in front of the app - a load balancer, a CDN, the platform - and the app speaks plain HTTP behind it. To serve HTTPS from the process itself, pass tls with the certificate chain and private key. Requests then arrive with https: URLs, and WebSocket upgrades ride the same port.

TS
import { readFileSync } from "node:fs"
import { server } from "@nifrajs/core"

const app = server().get("/", () => ({ ok: true }))

// PEM text or the files' bytes.
const tls = { cert: readFileSync("cert.pem"), key: readFileSync("key.pem") }

app.listen(443, { tls }) // Bun: https://, and wss:// for WebSockets, on one port

Node and Deno take the same option: serve(app, { port: 443, tls }). On Node it accepts any node:tls server option beside cert and key - ca and requestCert for client certificates, minVersion, SNICallback for several hostnames. Bun also takes a passphrase for an encrypted key.

AWS Lambda

@nifrajs/aws-lambda - API Gateway HTTP APIs (payload v2) and Lambda Function URLs. handle(app) is the buffered handler; streamHandle(app) streams responses on Function URLs with InvokeMode: RESPONSE_STREAM. Build the app at module scope (once per container). The adapter decodes the body and checks its real size before a Request exists, assembles headers + the v2 cookies array in one place, decides response base64 by what the bytes actually are, and feeds sourceIp into c.clientIp. waitUntil work settles before the container freezes. REST APIs (payload v1) and ALB are not supported.

TS
// handler.ts - one app per container, built at module scope
import { handle, streamHandle, type LambdaEnv } from "@nifrajs/aws-lambda"
import { server } from "@nifrajs/core"

const app = server<LambdaEnv>()
  .get("/", (c) => c.json({ ip: c.clientIp, requestId: c.env.context?.awsRequestId }))

export const handler = handle(app) // API Gateway HTTP API (v2) + Function URLs
// or, on a Function URL with InvokeMode: RESPONSE_STREAM:
// export const handler = streamHandle(app)

Cloudflare Workers / Pages (the edge)

For the cloudflare target, nifra build bundles the server with edge conditions (workerd, edge-light) into dist/_worker.js, wrapped in toFetchHandler(app), and writes a _routes.json so Pages serves the client bundle statically. SSR verified on real workerd - this very site runs there. A Worker you assemble yourself uses the same adapter:

TS
// worker.ts - any nifra app as a Workers fetch handler
import { server, toFetchHandler } from "@nifrajs/core/server"

const app = server().get("/", () => ({ ok: true }))
export default toFetchHandler(app)

Deploy: wrangler pages deploy dist (Pages) or wrangler deploy (Workers). The vercel target defaults to an Edge Build Output API function under .vercel/output; opt into Node.js for Node-compatible server dependencies.