[]previous and next page

Platforms

Platforms in the Deploy guide.

For agentsUpdated

Compare adapter capabilities, configuration and build output.

Choose a deployment target

Each platform has an adapter. The first table lists what each one emits; Support levels says how each one was checked.

Target Adapter Emits
Node.js node() from @shadow-js/dusk/node dist/node/server.mjs
Bun bun() from @shadow-js/dusk/bun dist/bun/server.mjs
Deno deno() from @shadow-js/dusk/deno dist/deno/server.mjs
Cloudflare Workers cloudflare() from @shadow-js/dusk/cloudflare dist/cloudflare/worker.mjs, wrangler.toml
Vercel vercel() from @shadow-js/dusk/vercel .vercel/output (Build Output API v3)
Netlify netlify() from @shadow-js/dusk/netlify dist/netlify (netlify.toml, functions or edge functions)
Tarout tarout() from @shadow-js/dusk/tarout dist/tarout/server.mjs
Static staticSite() from @shadow-js/dusk/static dist/static

Every adapter checks its configuration when it is created (a bad name, an outDir outside the project, a runtime it does not have) and throws a TypeError that names the option. The build also checks the server bundle against the platform: an edge runtime fails the build when the bundle imports a Node built-in it does not run ([ADAPTER_BUNDLE], with the file and the import), and a self-contained output fails when a package is left external. Cloudflare Workers runs the built-ins its nodejs_compat supports (crypto, async_hooks, buffer, stream, url, util, zlib, and the rest of the list the adapter carries); it refuses node:fs (its filesystem holds only the bundle and /tmp) and the import-only stubs (child_process, worker_threads, vm, and the like), each by name.

Adapter capabilities#

The table below is generated from the adapter factories when Dusk is built. It describes the emitted runtime bridges; the support-level table separately records where those bridges have been exercised. Tarout emits the Node layout, so these declarations do not establish Tarout proxy behavior. Async context requires the runtime's process.getBuiltinModule bridge; pass the event explicitly on the Vercel and Netlify edge targets. Workers require the adapter's emitted nodejs_compat setup.

Adapter WebSockets SSE Static-file middleware Streaming Async context Early Hints Static assets
Node.js yes yes yes yes yes yes adapter
Bun yes yes yes yes yes no adapter
Deno yes yes yes yes yes no adapter
Cloudflare Workers yes yes yes yes yes no platform
Vercel Node no yes no yes yes no adapter
Vercel Edge no yes no yes no no adapter
Netlify Functions no yes no yes yes no adapter
Netlify Edge no yes no yes no no adapter
Tarout Node layout yes yes yes yes yes yes adapter
Static export no no no no no no platform

Image optimization and ISR are opt-in handler features rather than declared adapter capabilities. Image optimization requires a supported image backend; the built-in optimizer loads sharp. ISR requires a suitable store for the deployment, with shared storage when multiple instances must share cache state. Static export has no request handler for either feature.

ISR store prerequisites. The default page cache is a bounded memory store of the process. On a single long-lived Node, Bun or Deno server that is enough. On a platform that runs many short-lived instances (Workers isolates, Vercel and Netlify functions), each instance has a memory store of its own, so a page is rendered once per instance and a purge reaches only the instance that calls it: give the handler a shared store instead, cache: { store: redisStore(client) } (Redis, or an Upstash REST client, which works from Workers) or cache: { kv } over a KVStore you adapt from the platform's key-value binding. The background refresh of a stale page goes to the platform's waitUntil when the adapter provides one (Workers, Vercel and Netlify do), so the instance stays alive until it ends. What a shared store guarantees across instances, and clock skew, are in Scaling the caches.

Support levels#

Choose by the runtime and the platform limits, not only by the output format.

Target Output and runtime Deployment limits
Node.js Standalone Node server Set the host allow-list and the static-file middleware policy.
Bun Standalone Bun server Native frame handling may reject a WebSocket payload before Dusk sees it.
Deno Standalone Deno server Native heartbeat and shutdown behavior apply; a real Deno deployment has not been established here.
Cloudflare Workers Worker plus static assets Deployed and checked on Cloudflare (pages, assets, WebSockets over WSS). CDN rules, bindings and placement depend on your Cloudflare project.
Vercel Build Output API v3 A production app has run through the adapter. Public files are served before function code; function duration limits apply to streams.
Netlify Function or edge function plus static assets Deployed and checked on Netlify (function target). Static-file middleware cannot gate CDN-served files.
Tarout Node layout Deployed and checked on Tarout. Its health check calls GET / on 127.0.0.1: allow that host (see the Tarout section).
Static hosts Prerendered HTML and browser assets No server functions, API routes, request middleware or file gate.

Environment variables reach the handler in one of two ways, chosen by the adapter. Where the platform sets process.env (Node, Bun, Deno, Vercel, Netlify functions, Tarout), createHandler validates the schema when the module loads, as on the Node server. Where the platform passes bindings to each request (Cloudflare Workers, Netlify edge functions), the schema is validated on the first request, and a missing variable is a 500 that names the variable and not its value. The request's client address comes from the header the platform sets and overwrites (cf-connecting-ip on Cloudflare, x-real-ip on Vercel, x-nf-client-connection-ip on Netlify), or from the socket on Bun, Deno and Node. waitUntil(promise) from @shadow-js/dusk/server hands background work to the platform (Workers' ctx.waitUntil); elsewhere the work continues and a failure is logged.

Compression is the one place the runtimes differ. Bun and Deno gzip buffered text responses (those with a Content-Length) when the client accepts gzip. Streamed responses are never gzipped by the runtime: the gzip stream holds every chunk until the end, which turned a streamed page's first byte from 5 ms into 1235 ms in the measurement that set this rule. Put a proxy or the platform's CDN in front for compression of streamed pages.

Node.js#

vite.config.tsts
import { node } from "@shadow-js/dusk/node";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [dusk({ adapter: node() })] });
bash
npx dusk build
node dist/node/server.mjs      # PORT (default 3000), HOST (default 0.0.0.0)

The emitted server.mjs is the Node server of the first section, with PORT and HOST read from the environment. It imports @shadow-js/dusk at run time, so the production install must include it (npm ci --omit=dev).

Bun#

vite.config.tsts
import { bun } from "@shadow-js/dusk/bun";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [dusk({ adapter: bun() })] });
bash
npx dusk build
bun dist/bun/server.mjs        # PORT, HOST; runs on Bun.serve

serve(handler, options) from @shadow-js/dusk/bun is the same runtime for your own entry. It serves the client folder first (exact paths, validators, 304, no dot files, no traversal), gzips buffered text, and on SIGTERM or SIGINT waits for requests in flight (up to gracefulShutdownMs, default 10 s) and exits 0. WebSocket routes use Bun.serve upgrades after the shared origin, protocol and connection guards. Bun exposes control frames to Dusk's ping/pong and rate checks. Its native frame ceiling can reject messages before Dusk sees them. The adapter's output is checked on a real Bun process: a static file, the file gate (a header on the file, and a refused file), an API route, a page, a missing path, WebSocket echo, origin rejection, message-size limits, control frames and close code 1001 on SIGTERM. Only Bun 1.4.2 was exercised.

Deno#

vite.config.tsts
import { deno } from "@shadow-js/dusk/deno";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [dusk({ adapter: deno() })] });
bash
npx dusk build
deno run --allow-read --allow-env --allow-net dist/deno/server.mjs

The Deno runtime uses Deno.serve and the same static, compression and shutdown rules as Bun. Deno's server has no forced stop: a shutdown waits for requests in flight, and gracefulShutdownMs only bounds how long the process waits before it exits. WebSocket routes use Deno.upgradeWebSocket after the shared upgrade guards. Deno owns heartbeat framing and its native idle timeout, so wsPongTimeoutMs cannot be configured there. Shutdown asks upgraded connections to close with code 1001 and waits for their close handshakes until the deadline.

Cloudflare Workers#

vite.config.tsts
import { cloudflare } from "@shadow-js/dusk/cloudflare";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [dusk({ adapter: cloudflare({ name: "my-app" }) })] });
bash
npx dusk build
npx wrangler deploy -c dist/cloudflare/wrangler.toml

The adapter writes dist/cloudflare/worker.mjs, a { fetch(request, env, ctx) } module over the server bundle, and a wrangler.toml with nodejs_compat (so getRequestEvent() works) and an [assets] table that serves dist/client (binding = "ASSETS"). The client folder also gets a _headers file with the immutable rule for the assets folder and vendored fonts. env is the Worker's bindings: defineEnv validates them on the first request, and a missing variable is a 500 that names it. The client address is cf-connecting-ip.

The [assets] table uses the Wrangler 4 syntax, and Wrangler 3 refuses it. The output is checked under workerd with wrangler dev --local, which never logs in and never deploys, and the Worker is bundled with wrangler deploy --dry-run. Workers Pages (functions/ folders) is not supported by this adapter.

With runMiddlewareForStaticFiles: true in the handler options, run_worker_first is true (every request runs the Worker) and the Worker asks the handler's gate about each file the assets binding has: a refusal is sent, and a file is sent with the headers and cookies the middleware set. A file the binding does not have goes to the handler, and a POST is never gated.

Prerendered pages that a rule of the app applies to are listed in run_worker_first in wrangler.toml, so the Worker answers them and the assets binding does not serve their files. The headers of the other prerendered pages are in the assets _headers file. The adapter is marked edge, so the build refuses the Node built-ins Workers does not run, although nodejs_compat is on: an OG route that reads its wasm with node:fs fails the build here, because the Workers filesystem holds only /bundle and /tmp, not the node_modules the route would read from. Import the wasm as a module instead. The adapter was also deployed to a Cloudflare account with wrangler deploy and requested over HTTPS and WSS: the page, a public file behind the file gate, an API route, a missing path, and the WebSocket echo, with its size limit and origin refusal, all answered as they do under workerd.

Vercel#

vite.config.tsts
import { dusk } from "@shadow-js/dusk/vite";
import { vercel } from "@shadow-js/dusk/vercel";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [dusk({ adapter: vercel() })] });
bash
npx dusk build
vercel deploy --prebuilt

Vercel's project setting "Automatically expose System Environment Variables" (on by default) copies each VERCEL_* variable under the public prefix of every framework it supports, for example VITE_VERCEL_ENV and NEXT_PUBLIC_VERCEL_URL. Those copies are public by contract, so the build's secret scan does not count their values as leaks; a name that looks like a secret, such as VITE_VERCEL_API_TOKEN, is still scanned.

The adapter writes .vercel/output in the Build Output API v3 format. Vercel reads headers from routes only, so the immutable Cache-Control rule for the assets folder and fonts/google/ is a route too (step 3), and config.json has no top-level headers key. The routes, in this order:

  1. For each prerendered page that a rule of the app can apply to, a route to the function. A rule applies when a redirect or a rewrite matches the path, a header rule has a condition (a host, cookie, header or query), a route redirects it, or a route or _middleware module runs for it. The entry's global middleware applies to every page, and so does an entry option the build cannot read (see which pages the handler answers). The handler renders that page per request, so the host rewrites and the middleware run, and the prerendered file is not served.
  2. For each other prerendered page with headers, a route that only adds them and continues to the file. No function runs for that page.
  3. For the assets folder and fonts/google/, a route that adds Cache-Control: public, max-age=31536000, immutable and continues to the file.
  4. { "handle": "filesystem" }. Then, for those folders, a missing file is a 404 with Cache-Control: no-store, so the year-long rule never sticks to a fall-through response.
  5. The catch-all { "src": "/(.*)", "dest": "/render" }.

static/ holds the client build without the Vite manifest. functions/render.func/ holds .vc-config.json, package.json ("type": "module"), index.mjs and a copy of the server bundle. The packages the server loads by name at run time (satori for ImageResponse, @resvg/resvg-wasm for its rasterizer, sharp) are copied, with the packages they need, into the function's own node_modules, so an OG image runs in the function. Build on Linux: sharp copies the native binary of the machine that builds it.

A prerendered page's file carries the app's security headers: the secure defaults, security.headers, the Content-Security- Policy without its {nonce} sources, the header rules that match its path, and HSTS over https. Its inline scripts and styles are allowed by their SHA-256 hashes, which are fixed when the page is prerendered, so the page needs no nonce. What a file cannot carry is per request: the nonce of a dynamic page, x-request-id, cookies and the handler's cache-control default.

The generated .vercel/output tree follows Build Output API v3. Deploy the folder with vercel deploy --prebuilt.

The adapter output was also deployed to a Vercel production project with vercel deploy --prebuilt --prod and requested: the page, a prerendered page, /_dusk/health, a missing path, the security headers and the Content-Security-Policy with its nonce. A production app is deployed on Vercel through this adapter (Git build with framework: null, npm run build, no dashboard output directory). The build and the deployment, pages and API routes, the OG PNG, redirects and the security headers on the landing page were checked on the live site; the route order, the headers, the function folder and the OG PNG were also checked from a standalone copy. Not checked live: how Vercel applies a header route with continue to a file, and that a host condition of a rule reaches the function when one deployment serves several hosts. On a first preview check that / on each host shows the right site, that the landing page carries Content-Security-Policy and X-Frame-Options, and that /opengraph-image answers a PNG. The Node.js function exports a Web-standard { fetch } object; check the first deployment's logs for that export.

Netlify#

vite.config.tsts
import { netlify } from "@shadow-js/dusk/netlify";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    dusk({
      adapter: netlify({
        target: "functions",
        redirects: [{ from: "/old", to: "/posts/1", status: 301 }],
      }),
    }),
  ],
});
bash
npx dusk build
cd dist/netlify && npx netlify deploy --prod   # netlify.toml is in this folder

The output folder has netlify.toml (the publish folder is client/), client/_headers (hashed assets, under the build's assets folder, and vendored fonts are immutable), client/_redirects (from the redirects option), and either functions/render.mjs (a Netlify Function, v2, on path = "/*") or edge-functions/render.mjs with target: "edge" (a Deno Edge Function). Static files win over the function, as on Netlify. checkNetlifyOutput checks the files; the function runs in-process. The output was run under netlify dev, and the function target was deployed to a Netlify site and requested: the page, a prerendered page, /_dusk/health, a missing path, the security headers and the refusal of a foreign host. Set APP_URL to the site's address (netlify env:set APP_URL https://<site>.netlify.app) before the deploy. A team with login protection answers every request with a 401 login page until the protection is turned off for the site. From a script, deploy with netlify deploy --prod --no-build --dir client --functions functions from dist/netlify, so the CLI asks no question.

Prerendered pages that a rule of the app applies to are rewritten to the function with a forced rewrite (200!) at the end of _redirects, after your redirects, and the headers of the other prerendered pages are in _headers.

Tarout#

vite.config.tsts
import { dusk } from "@shadow-js/dusk/vite";
import { tarout } from "@shadow-js/dusk/tarout";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [dusk({ adapter: tarout() })] });

Tarout's documentation describes a Node app with a start script that listens on process.env.PORT; it is deployed from the project folder, and Tarout builds it. The adapter writes the Node layout, dist/tarout/server.mjs, which meets that contract. Set the project's start script to:

package.jsonjson
{
  "scripts": {
    "build": "dusk build",
    "start": "node dist/tarout/server.mjs"
  }
}

Deployed to a Tarout account (a Dusk app made with --adapter tarout, uploaded with tarout deploy), this is what it does, and what an app has to allow for:

  • Tarout sets PORT (3000) and runs npm start. The Node layout listens on PORT and, with no HOST, on 0.0.0.0, which is what the platform's router needs.
  • The health check is a GET / from inside the container, to 127.0.0.1:<PORT>, and it wants a 200. Its Host header is 127.0.0.1:<PORT>, so an app that answers only for its public host (as the generated src/entry-server.ts does, from APP_URL) fails it, and the deploy ends with "New container is unhealthy". An app made with --adapter tarout also allows 127.0.0.1; add "127.0.0.1" to security.allowedHosts in an existing one. No other loopback name is needed.
  • Set APP_URL to the address Tarout gives the app (tarout env <app> set APP_URL=https://<name>.tarout.app --no-secret, then deploy again); the app answers only for that host. A request for any other host is refused (403 at the platform, 400 by the handler).
  • The check runs wget or curl inside the container. If the builder's image has neither, the deploy is reported unhealthy although the app started: build from a Dockerfile (tarout apps build <app> --type dockerfile --dockerfile Dockerfile) on an image that has them, for example node:22-bookworm-slim with apt-get install -y wget curl.

The page, a prerendered page, /_dusk/health (always 200, whatever the host), a missing path (404), the security headers and the refusal of a foreign host were checked on the deployed app. Tarout has no step that Dusk needs configured: the adapter writes no tarout.json, and the Node layout serves dist/client itself.

Static hosts#

vite.config.tsts
import { staticSite } from "@shadow-js/dusk/static";
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";

export default defineConfig({ plugins: [dusk({ adapter: staticSite() })] });

staticSite() copies the client build to dist/static. Pages that are prerendered (see Prerender at build time) are there as index.html files; a page that needs the server is not. Upload the folder to any host that serves files.