Deployment

Furin ships a native Vercel Build Output target plus four Bun-native production shapes: a bundled server, a compiled server with assets on disk, a self-contained executable, and a pure static export. The package output composes a page tree into another Elysia application.

Choose An Output

RequirementRecommended output
Vercel Functions, CDN assets, and native ISRVercel Build Output
SSR, ISR, APIs, or Sync in a normal Bun environmentBun server bundle
Small operational artifact with assets mounted beside itCompiled server
One distributable executableCompiled embed
Only SSG pages and no request runtimeStatic export
Publish a Furin app for another Elysia hostPackage target

Furin intentionally exposes only adapters it can build and verify today. Node.js and Cloudflare are not accepted build targets; use Bun, Vercel's Bun runtime, a static export, or the package target.

Elysia 2 / Kiana Migration

Furin pins the Elysia 2 beta line and its matching official packages. Existing applications should run the official codemod first, then let TypeScript expose the Furin-specific integration points:

sh
bunx @elysia/codemod@latest
bun add elysia@2.0.0-beta.32 @elysia/eden@2.0.0-beta.5 @elysia/static@2.0.0-beta.3 typebox@1.3.34 exact-mirror@1.2.6

typebox and exact-mirror are direct application dependencies because Kiana declares them as peers rather than embedding private copies. This keeps one compatible validator/normalizer graph for Elysia, its plugins, Eden, and Furin; typebox is also part of Furin's public schema contract. Exact Mirror itself contributes only a small runtime module—the larger compiled-binary increase in this beta comes primarily from the TypeBox 1 runtime and Bun bytecode amplification.

The important manual checks are:

  • route arguments are now (path, hooks, handler);
  • lifecycle registration drops the on prefix (beforeHandle, afterHandle, error, request, and afterResponse);
  • lifecycle scope scoped is now plugin;
  • official plugins move from @elysiajs/* to @elysia/*;
  • TypeBox 1 uses the typebox package;
  • WebSocket support is explicit through elysia/websocket.

See the official Elysia 2 DayDream release post for the complete breaking-change list.

Furin automatically enables elysia/plugin/aot/bun for Bun server bundles, compiled executables, and Vercel Bun Functions. Applications using .mount() skip AOT because Elysia's AOT plugin cannot compile mounted handlers; beta.19 bundles its runtime TypeBox dependencies without Furin's former setup shim. It generates a real, listener-free application entry for Elysia's dry run and keeps the boot entry separate. An Elysia instance called in memory through treaty(app) (with or without .use(furinSync)) compiles its own router. While the application evaluates for the AOT build, Furin records every instance passed to treaty() from @elysia/eden or @elysia/eden/treaty2, exports it from that entry, and Elysia compiles its manifest too; such an instance must be named (new Elysia({ name: "api" })), and the build fails otherwise. Runtime compiler stripping stays on auto: Furin completes the export surface of Kiana's no-WebSocket stub while preserving its throwing behavior, so applications without Sync can still remove unused WebSocket code. Applications whose dynamic handlers require the JIT fallback keep it automatically. Compiled applications with and without Sync are exercised end-to-end to cover both paths.

No custom Elysia runtime adapter is necessary for the supported targets. Bun uses Elysia's native Bun adapter, Vercel runs the same Bun-targeted bundle through app.handle(Request), static exports have no server runtime, and package builds let the host application own its adapter. A future non-Bun target should be added only with a dedicated Furin deployment adapter and Elysia Adapter v2 integration, not as an unimplemented CLI option.

Both Furin-owned entry points fail closed when the application uses asynchronous plugins, such as .use(import("./auth")). The Bun server serves no request until Elysia's app.modules settles, and it stops if app.modules rejects. The Vercel handler awaits app.modules during cold start, so a rejected plugin fails the Function's initialization. Elysia's app.handle() and app.fetch don't do this on their own. A request can run before a pending plugin is applied, and after a plugin rejects they keep serving the app without it. If you host the application in your own fetch handler, await app.modules before you serve requests:

ts
await app.modules;

export default { fetch: app.handle };

Adapters

The bun and vercel targets are deployment adapters. An adapter is a plain object, in the spirit of the Next.js adapter API, that targets in furin.config.ts accepts:

ts
import type { FurinAdapter } from "@teyik0/furin/config";

const adapter: FurinAdapter = {
  name: "my-platform",
  capabilities: { assets: "platform", publicCache: "platform", websocket: false },
  runtimeModule: new URL("./runtime.ts", import.meta.url).pathname,
  async onBuildComplete(output) {
    // Copy output.server.files and each app's clientDir, write platform config.
    return { buildId: output.buildId, generatedAt: new Date().toISOString() };
  },
};
FieldMeaning
capabilities.assetsWho serves /_client/* and public/: Furin from disk (filesystem), Furin from files embedded in an executable (embedded), or the platform (platform)
capabilities.publicCacheWho caches public SSG, ISR and PPR pages: Furin (framework, process memory or pageCache) or the platform (platform)
capabilities.websocketWhether requests reach Bun.serve(), so browser events can use a WebSocket; otherwise they stream over SSE
runtimeModuleTyped module bundled as the server entry. It imports the application from furin:app; its file name names the emitted entry
onBuildComplete(output)Receives data only: each app's prefix, build ID, client directory, routes (mode, revalidation, tags, PPR, declared query keys) and known prerender paths, plus the server bundle files

Furin owns the rest of the build: client bundles, the one ordered server plugin chain, Elysia AOT and the prerender pass. Capabilities are baked into the build, so the runtime never checks for a platform name: it picks the browser-event transport, skips asset serving and rejects furin({ pageCache }) from them.

Prerendering always runs inside the built server bundle, in a child process. That pass lists the SSG and ISR URLs known at build time and, when Furin caches public pages, renders the SSG documents into furin-prerender.json next to the bundle. The static export renders from a temporary bundle in the same way. The build never renders pages from source in its own process.

Bun Server Bundle

sh
furin build --target bun
bun .furin/build/bun/server.js

The target directory contains the server.js entry, the listener-free _furin-app.js application module and their shared chunk-*.js files, furin-prerender.json (the SSG documents the bundle rendered during the build), one client directory per Furin instance, and copied public/ assets. .furin/build/manifest.json records the exact paths and build IDs.

Deploy the whole .furin/build/bun directory. server.js resolves client assets beside itself; FURIN_CLIENT_DIR can override the root instance's directory when a platform mounts assets elsewhere.

Compiled Executables

sh
# Executable plus client/public directories
furin build --target bun --compile server

# Framework and public assets embedded in the executable
furin build --target bun --compile embed

Both commands write .furin/build/bun/server on macOS/Linux and .furin/build/bun/server.exe on Windows. The executable is compiled from the finished JavaScript bundle, so it carries the SSG documents that bundle prerendered; the intermediate JavaScript files are removed afterwards. In server mode, deploy the executable together with its asset directories. In embed mode, Furin removes the external client directories after embedding them.

Use embed for the simplest single-artifact rollout. Use disk mode when a CDN, container layer, or packaged child application should serve or replace assets independently.

Vercel

The Vercel adapter emits the Build Output API v3 directly. It uses the Vercel Bun runtime, serves browser and public/ assets from the CDN, and keeps SSR, SSG, ISR, API routes, streaming, and SPA navigation in one Furin application.

Application @elysia/static mounts are not supported by this target and fail the build rather than deploying a missing runtime directory. Place files under public/ to serve them through the CDN.

Server entry

Export the Elysia application as the default export. Guard .listen() with import.meta.main so the same entry runs locally without opening a TCP listener inside a Vercel Function:

ts
import { furin } from "@teyik0/furin";
import { Elysia } from "elysia";

const app = new Elysia().use(
  await furin({
    pagesDir: "./src/pages",
  })
);

export const port = Number(process.env.PORT ?? 3000);

if (import.meta.main) {
  app.listen(port);
  console.log(`Furin app running at http://localhost:${app.server?.port}`);
}

export default app;

The compiled Bun boot entry reads the exported numeric port when PORT is unset, so multiple workspace applications keep distinct local production defaults. Runtime PORT still takes precedence because the server module computes the export from the environment.

Build

json
{
  "scripts": {
    "build:vercel": "furin build --target vercel"
  }
}

The build writes a deployable artifact to .vercel/output:

text
.vercel/output/
├── config.json
├── static/
│   ├── _client/
│   └── ...public assets
└── functions/
    ├── __server.func/
    │   ├── index.js
    │   ├── handler.js
    │   ├── furin-vercel.json
    │   └── .vc-config.json
    ├── index-ssg.func -> __server.func
    └── index-ssg.prerender-config.json

Add vercel.json when Vercel should run this target automatically:

json
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "buildCommand": "bun run build:vercel",
  "bunVersion": "1.4.x",
  "framework": null,
  "installCommand": "bun install --ignore-scripts"
}

You can also build and deploy the prebuilt artifact yourself:

bash
bun run build:vercel
bunx vercel deploy --prebuilt

Rendering and cache behavior

Furin modeVercel output
ssrBun catch-all Function
ssgPrerender Function with expiration: false and a build-time HTML fallback when the URL is known
isrPrerender Function whose expiration matches revalidate, with a build-time fallback for request-independent URLs
SSG/ISR with requestLoader (PPR)Cached public HTML shell chained to a private streaming Function continuation

Fixed ISR routes are rendered during the build, like Next.js. Dynamic SSG and ISR URLs returned by staticParams() receive individual build-time fallbacks. Routes with a declared query schema remain on-demand so the fallback cannot ignore query-dependent input. For PPR routes, only the public shell is prerendered: requestLoader does not execute during this phase. revalidatePath() and revalidateTag() invalidate the corresponding Vercel-Cache-Tag, while waitUntil() keeps background ISR regeneration alive after the response is sent.

SPA navigation data requests (<page>?_furin=data) are routed to the Bun Function instead of the prerendered document, and successful SSG/ISR payloads without a requestLoader are cached by the CDN with the document's revalidation window. JSON-only results use a compact envelope; rich values, shared references, deferred data, and RSC keep the lossless CrossJSON/Route Frame transports. The document and all query-specific navigation payloads share one cache tag, so revalidatePath() and revalidateTag() purge them together.

Vercel owns the deployed SSG/ISR cache. Prerender Function invocations bypass Furin's process-local cache and return newly rendered HTML; the in-memory stale-while-revalidate implementation is used only in development and Bun deployments.

Every Prerender Function, PPR included, sets allowQuery to the keys of the route's TypeBox query schema (none without a schema), so the CDN keeps one entry per declared query instead of one per unique URL; Vercel drops every other param before invoking the function. Routes whose query schema cannot be enumerated (Standard Schema) keep Vercel's per-URL caching. A PPR shell records the declared query it rendered with, and requestLoader resumes with that query: on Vercel, a request loader sees only declared query params, while on Bun it receives the live request.

index.js and handler.js are typed runtime modules of the adapter (adapter/vercel/bootstrap.ts and adapter/vercel/handler.ts). The build only writes data for them: furin-vercel.json holds the Prerender Function aliases, cache-tag rules, PPR routes and the chain key.

PPR stores the public HTML shell, React's opaque postponed state, and the public loader snapshot as one coherent artifact. The build generates known PPR shells using the actual production bundle, so minification cannot change the component tree between prerender and resume.

Vercel serves the public shell from its native cache and chains it to a Function that runs requestLoader and resumes the suspended boundaries. Both parts form one HTTP response; the browser does not fetch a separate continuation. Resume requests are authenticated with a build-scoped chain key and checked against the build and mounted route. The state is not included in the browser's HTML.

The personalized response and navigation payload remain private, no-store. Public artifacts also use Runtime Cache for origin rendering and navigation, scoped by deployment, build, mount prefix, route mode, and query string. Path and tag invalidation expires those entries. On Bun deployments, the same artifact is kept in memory; stale ISR artifacts remain usable while a tracked regeneration produces their replacement.

Consume request-loader field promises inside a React Suspense boundary. Furin uses React's Web Streams renderer for applications containing PPR routes so full renders and resumptions share the same React contexts. Applications without PPR keep the native Bun renderer and omit the resumable renderer from their server bundle.

The isolated Flight codec is included alongside the Function handler, so RSC rendering does not compile code or read framework sources at runtime.

SSR payloads, request-specific loaders, deferred streams, redirects, not-found results, and errors are explicitly private, no-store.

Function region

Functions use Vercel's project default region (typically iad1). Place compute close to the application's data source by configuring the adapter in furin.config.ts:

ts
import { vercel } from "@teyik0/furin/adapters/vercel";
import { defineConfig } from "@teyik0/furin/config";

export default defineConfig({
  targets: ["bun", vercel({ regions: ["cdg1"] })],
});

furin build --target vercel then builds this configured adapter. The editor suggests Vercel's supported compute regions, such as cdg1, fra1, or iad1. An empty list, duplicates, and unsupported identifiers fail while the configuration loads.

Cold-start diagnostics

The generated Function uses a lightweight bootstrap in front of the application bundle. Dynamic responses expose:

text
Server-Timing: furin_module_init;dur=104.74, furin_server_init;dur=12.8, furin_handler_wait;dur=0.1, furin_handler;dur=2.4
MetricMeaning
furin_module_initParse and evaluate the complete server bundle
furin_server_initImport the user server entry and assemble the Elysia application
furin_handler_waitTime the request waited for module initialization
furin_handlerTime Furin spent creating the response after initialization

The first request handled by a Function instance also emits a structured vercel_cold_start event containing these timings, route/app counts, region, and raw server-bundle bytes.

Use --analyze to inspect which modules contribute to the bundles:

bash
bunx furin build --target vercel --analyze

This writes one client metafile per mounted app (for example, vercel-client.json for the root app and vercel-client-admin.json for /admin) plus the shared vercel-server.json under .furin/build/analysis.

The executable reference is examples/weather.

Static Export

furin.config.ts
import { defineConfig } from "@teyik0/furin/config";

export default defineConfig({
  static: {
    basePath: "/my-site",
    outDir: "dist",
    onSSR: "error",
  },
});
sh
furin build --target static
furin preview

The output contains:

text
dist/
├── index.html
├── __furin_data.ndjson
├── docs/
│   └── index.html
├── _client/
├── public assets...
└── 404.html

Each rendered path also receives a route-data file so SPA navigation works without the runtime ?_furin=data requests. Redirect loaders become portable HTML redirect pages.

Like the server targets, the export renders built code. Furin bundles the app into a temporary production bundle under .furin/build/static/, with the same plugin chain as a server bundle. It renders every page in a child process, then deletes the bundle. This has three consequences:

  • Pages, loaders and staticParams() run with NODE_ENV=production and React's production renderer.
  • import.meta.dir and import.meta.url point into the temporary bundle, which is deleted after the build. Read content files relative to process.cwd() instead: the export runs from the project root.
  • dist/ is replaced only after every page has rendered and every output path has been validated. A failed export leaves the previous dist/ in place.

Static export supports only SSG routes and rejects requestLoader(). Dynamic SSG routes require staticParams(). Keep onSSR: "error" unless another deployment deliberately owns the skipped SSR/ISR URLs.

Set basePath only when the host serves the site below a subpath. It updates asset URLs and client navigation; it is not the same setting as the runtime furin({ prefix }) used by a Bun server.

Packaged Apps

sh
furin build --target package --pagesDir ./src/admin --prefix /admin

This produces an Elysia plugin factory rather than a bootable server. See Multi-Instance & Packaged Apps for host composition and current executable limitations.

Environment Variables

Server secrets stay in process.env and are read only by server code. Do not expose secrets through the browser build.

Client-visible values must match the Bun full-stack allowlist used during development:

toml
[serve.static]
env = "FURIN_PUBLIC_*"

Treat every FURIN_PUBLIC_* value as public, because it may be compiled into browser code.

Caches And Replicas

SSG/ISR HTML caches are in-process unless furin({ pageCache }) receives a shared adapter. A CDN can cache their public responses, but on-demand invalidation must evict both the origin entry and the CDN entry. For multiple Bun replicas, create redisPageCache() once per process and use one stable namespace across replicas and releases. Do not include a replica ID or build ID in that namespace. See Shared Page Cache for the complete setup.

Vercel Build Output is the exception: omit pageCache and let the deployment adapter use Vercel's native prerender and Runtime Cache integration. A custom page cache is intended for self-hosted Bun replicas, and configuring one on the Vercel target fails fast instead of creating a second source of truth.

The Sync journal distributes browser invalidations but is not a shared HTML cache. A Redis Sync adapter and a Redis page-cache adapter may use the same server, but they are separate contracts and should use distinct namespaces.

For Sync, use file-backed SQLite only on one host. For replicas on separate hosts, PostgreSQL with its native LISTEN/NOTIFY notifier is the recommended default; Redis remains available as either the durable adapter or an independent notifier. Keep one stable namespace and run the required schema bootstrap before starting traffic. See Sync & Invalidations.

Production Checklist

Reverse proxy, compression, and streaming

Put a reverse proxy or load balancer in front of Bun. Let it terminate TLS and negotiate Brotli/gzip; Furin deliberately does not compress responses in the application process. Brotli normally saves more bytes than gzip for HTML, JS, and CSS, while gzip remains the compatibility fallback. Do not compress already-compressed images or add buffering to streaming SSR, PPR, SSE, and Sync WebSockets. Preserve Content-Encoding, Vary: Accept-Encoding, cache headers, Upgrade, and forwarded host/protocol headers. An Nginx-style starting point is:

nginx
# In the http block:
map $http_upgrade $connection_upgrade { default upgrade; '' close; }
upstream furin { server 127.0.0.1:3000; keepalive 32; }

server {
  listen 443 ssl;
  # Configure certificates and HTTP/2 for your environment.
  gzip on;
  gzip_vary on;
  gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
  # If the Brotli module is installed: brotli on; brotli_types text/html text/css text/javascript application/javascript application/json image/svg+xml;

  location / {
    proxy_pass http://furin;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_buffering off;
    proxy_connect_timeout 5s;
    proxy_read_timeout 120s;
    proxy_send_timeout 120s;
  }
}

Verify your actual proxy build supports Brotli before enabling it. Test both Accept-Encoding: br and Accept-Encoding: gzip; with a gzip-only proxy, gzip is a valid production contract. Keep streaming responses unbuffered and set idle/read timeouts above the longest expected interval between chunks. Bun also has its own default 10-second idleTimeout: it counts silence while a loader is still running, so the 120-second proxy timeout above cannot prevent Bun from resetting a slow request. Export idleTimeout (seconds) from src/server.ts for the generated Bun boot, or pass it to startProductionServer() in a custom boot; choose it for your workload and test a response with a delayed first byte through the public endpoint. Restrict direct Bun access to trusted networks and never expose .furin/build/private as static content. See Bun server timeouts.

Health and graceful shutdown

The generated Bun server exposes /_furin/health/live and /_furin/health/ready. Both return Cache-Control: no-store. Liveness means the process is running; readiness means it should receive application traffic. On SIGTERM or SIGINT, readiness becomes 503 immediately, while application requests continue to be served during the five-second load-balancer propagation window. Liveness remains 200 during the drain. After that window, Furin rejects new application requests, closes its browser-event WebSockets, stops accepting connections, waits for in-flight HTTP requests, ISR regeneration, deferred request-log emissions, and Sync cursor unsubscriptions, then calls an optional application onShutdown export. The 30-second deadline covers this entire drain; when it expires, Bun force-closes remaining sockets and Furin stops waiting for unfinished drains. Signal-triggered shutdown then exits the process with its existing exit code (or 0), or with 1 if cleanup throws. Calling shutdown() programmatically does not exit the process. Configure your orchestrator's termination grace period above 35 seconds with an operational margin. The proxy must remove a replica when readiness fails; use liveness only to detect a stuck process, not to decide traffic routing.

ts
// src/server.ts — optional, for clients owned by the application
export async function onShutdown() {
  await redis.quit();
  await database.end();
}

Furin closes its own Sync subscriptions but does not close application-owned Redis or PostgreSQL clients; close those in onShutdown after the framework drain. An application exporting a custom startServer() owns its listener and signal handling instead of the generated boot path. It can use startProductionServer() from @teyik0/furin/production-server to adopt the same lifecycle, or implement equivalent probes and draining itself. A direct bun src/server.ts development start also uses the application's own .listen() path.

Private server source maps and error tools

Server source maps are off by default. Enable them for Bun and Vercel builds:

ts
import { defineConfig } from "@teyik0/furin/config";

export default defineConfig({ serverSourceMaps: true });

Furin uses Bun's external mode, which does not add a public sourceMappingURL comment, and moves the maps to .furin/build/private/server-sourcemaps/{bun,vercel}. These maps are not included in .furin/build/bun or .vercel/output; upload them from CI before starting the new release. Keep that private directory restricted because maps can contain source content. For Sentry, stage the generated server .js files and their matching .map files together outside the deployable directory, then run bunx @sentry/cli sourcemaps upload --org <org> --project <project> --release <release> <staging-dir> with SENTRY_AUTH_TOKEN supplied by CI. Upload the same release identifier used by the server SDK and verify a test error is symbolicated. For another error tool, upload the same JS/map pairs using its supported artifact API; no credentials are embedded by Furin. Compiled executables can also emit private maps, but check your error tool's support for Bun executable stack frames before relying on remote symbolication.

Rolling deploys, state, and recovery

Deploy code and its matching client assets as one immutable release. Build and upload source maps, run compatible database migrations, and ensure shared Redis/PostgreSQL dependencies are reachable before adding the new replica to the load balancer. Wait for readiness, gradually shift traffic, then send SIGTERM to old replicas and leave the full drain window before SIGKILL. Roll back the code and matching assets together. Keep the page-cache namespace stable across replicas and releases; invalidate incompatible entries explicitly rather than encoding a replica ID in the namespace. CDN caches need their own purge/TTL policy for on-demand invalidation.

At the CDN, honor Furin's Cache-Control and include the full URL, including query string, and content encoding in the cache key. Do not cache personalized SSR, requestLoader results, errors, or Sync responses. Public SSG/ISR HTML and cacheable navigation data responses (?_furin=data on the page URL) may follow their headers; the query string keeps them in separate cache entries. On self-hosted Bun, revalidatePath() and revalidateTag() invalidate the origin cache, not an arbitrary external CDN; configure a separate CDN purge or bypass its HTML/data cache when immediate invalidation is required. Keep versioned client assets available while pages from the previous release can still reference them. Vercel's adapter handles its own CDN invalidation.

Keep secrets in the deployment secret store; never put them in FURIN_PUBLIC_*, client build flags, source maps, or logs. Back up the authoritative PostgreSQL/Redis data, including Sync journals and any durable page-cache state you require, with point-in-time or AOF/snapshot recovery appropriate to the service. Test restoration and schema compatibility before relying on backups. An in-memory cache is disposable and should not be treated as a backup.

For recovery, restore the application database and durable Sync journal to a consistent point, apply the required Sync schema migration, then start replicas against the restored store. Restore Redis page-cache state only when it is compatible with that application snapshot; otherwise let public pages regenerate from authoritative data. Rehearse the procedure in an isolated environment by reading a known Sync cursor and rendering an ISR page before admitting traffic. File-backed SQLite Sync requires a consistent snapshot of its database file on the single host.

  • Run type-checks and tests before the build.
  • Deploy the exact server and asset directories listed by the manifest.
  • Keep NODE_ENV=production behavior and server-only secrets consistent across replicas.
  • Route load-balancer traffic using /_furin/health/ready; monitor /_furin/health/live separately.
  • For multiple Bun replicas, verify Redis connectivity and keep the page-cache namespace stable across releases.
  • Decide whether CDN invalidation is required before enabling long shared-cache lifetimes.
  • Roll back code and matching assets together; the build ID protects browsers from mixed deployments, not the origin from an incomplete rollout.

Comments