stoneware

Documentation

Deploying

A checklist, one decision, and a walkthrough per platform — server or static, Vercel or Cloudflare.

A Stoneware app is a Bun HTTP server. Deploying it means running one file on a host that has Bun — there is no adapter layer and no per-platform build target.

Before you deploy: five minutes that save an evening

Every item below is here because it broke a real deploy, and each one fails in a way that looks like something else. Run them in order and the deploy is boring.

terminalsh
stoneware doctor      # tsconfig, Bun version, .gitignore, config
stoneware build       # or: stoneware export
stoneware preview     # export only — serves dist/ the way a host will
  1  STONEWARE_CSRF_SECRET set in the host's environment
     production refuses to start without one, and the error
     names the secret rather than the deploy

  2  stoneware doctor is clean
     catches the tsconfig JSX mistake that only shows up
     mid-render, as a TypeError blaming a correct template

  3  exporting? every dynamic route has staticPaths()
     without it the page is never written, and the link to it
     404s while the rest of the site looks perfect

  4  read the skipped list the export prints
     it is not a warning about the export - it is the list of
     pages that will not exist on the deployed site

  5  after deploying, open one page and check the network tab
     HTML 200 with the CSS and island JS 404 is its own failure,
     not a styling problem
the five checks

Item 1 is the most common crash by a wide margin. Item 3 is the most common silent failure: nothing errors, the build succeeds, and one section of the site simply is not there.

Which path: server or export

  Does any page differ per visitor?
  (a login, a cart, a <Form> that posts back)

        yes                              no
         │                                │
    stoneware build                 stoneware export
    needs a host that runs Bun      needs nothing at all
         │                                │
    VPS, Docker, Fly,               Cloudflare, Netlify,
    Railway, Render, Vercel         GitHub Pages, S3, any CDN
one question decides it

A blog, a docs site, a brochure site and most marketing sites are identical for everyone and export cleanly. If you are unsure, run stoneware export and read what it skips — the pages it cannot prerender are exactly the pages that need a server.

You can change your mind later. Both commands run the same routes through the same rendering pipeline, so moving between them is a change of deploy target rather than a rewrite.

The environment variable you must set

the one that is not optionalsh
STONEWARE_CSRF_SECRET=<32+ random characters>

# generate one
openssl rand -base64 32

A production server refuses to start without it. That is deliberate: with no fixed secret, tokens are invalidated by every restart and are not shared between processes, so forms would fail intermittently on any host that runs more than one instance — which is far harder to diagnose than refusing to boot.

  • On Vercel, scope it to All Environments. Production-only is the usual mistake and it leaves every preview deployment crashing on boot.
  • Set it in the host's dashboard, not in a committed file. Bun reads .env automatically in development, and .env is gitignored for exactly this reason.
  • Rotating it invalidates every form currently open in a browser. Those visitors get one rejected submission and a fresh token afterwards.
  PORT                   the port to bind. Most platforms set it.
  HOST                   defaults to 0.0.0.0 in production
  STONEWARE_TRUST_PROXY  "proto" behind a TLS-terminating proxy,
                         so canonical URLs say https://
other variables, all optional

What the host must provide

  • The Bun runtime. Not Node, not a V8 isolate — the framework is built on Bun.serve, Bun.CSRF and Bun.escapeHTML.
  • .stoneware/ from the build: the server bundle, the island manifest and the island chunks.
  • public/, if the app serves static assets.
  • routes/ and islands/, on disk, at request time — on 0.1.3 and earlier. Path matching reads the filenames on every request, and the island registry is rebuilt from the sources at boot.
  • .stoneware/islands.json reachable by whatever packages your function — on 0.1.4. Inlined into the bundle from 0.1.5, so it no longer has to travel as a file.
Since 0.1.4, routes/ and islands/ are not on that list. A build inlines every route and island into the bundle and writes a pattern table beside it, so the source tree becomes a build-time input rather than a runtime dependency. On 0.1.3 and earlier, both directories must be present at request time.

That matters wherever the machine that builds is not the machine that serves — a container image, a serverless function, a CI artifact. The build resolves its own project root from the bundle's location rather than recording the path it was built at, so the output runs wherever it is unpacked.

Starting the server

You do not write an entry point. stoneware build emits one, and stoneware start runs it — that is the whole deploy on any host that can run Bun.

deploysh
bun install
stoneware build      # writes .stoneware/
stoneware start      # serves .stoneware/server.js
Earlier versions of this page told you to hand-write a server.ts calling createApp(config, { dev: false }). Do not. With no root in the config, that resolves paths against process.cwd(), which is the project directory when you run it locally and something else entirely inside a container or a serverless function — so it starts, finds no routes/ and no island manifest, and crashes before the first request. The generated bundle derives its root from its own location instead.

If you genuinely need a custom entry point — extra middleware around the app, a second port, a health probe outside the router — pass root explicitly rather than letting it default.

server.ts — only if you need onetsx
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { createApp } from "stoneware";
import config from "./stoneware.config.ts";

// From this file's own location, never process.cwd(): the directory a build
// ran in is routinely not the directory it is served from.
const root = dirname(fileURLToPath(import.meta.url));

// dev: false reads the island manifest that `stoneware build` wrote, rather
// than rebuilding chunks (and changing their hashed filenames) on every start.
const app = await createApp({ ...config, root }, { dev: false });

Bun.serve({
  port: Number(Bun.env.PORT ?? 3000),
  // Bun.serve defaults to localhost, which is loopback-only. Every container
  // runtime reaches the process through a proxy on another interface, so
  // binding localhost makes the service unreachable and health checks fail.
  hostname: Bun.env.HOST ?? "0.0.0.0",
  fetch: (request) => app.fetch(request),
});

Behind a proxy that terminates TLS

Render, Railway, Fly, Heroku, Vercel and nginx all terminate TLS and forward a plain HTTP request. Without being told, the app sees http:// on a site served over https://, and every absolute URL it builds — canonical links, og:image, sitemap entries — points at the insecure origin.

stoneware.config.tstsx
export default defineConfig({
  trustProxy: "proto",   // or true, or STONEWARE_TRUST_PROXY in the environment
});
  • "proto" trusts X-Forwarded-Proto only. Safe on any host, and enough to fix http/https confusion, which is the case that actually bites.
  • true also trusts X-Forwarded-Host. A forged host poisons every absolute URL the app emits, so use it only when something you control sets that header.
  • Off by default, because these headers are trivially forged by anyone who can reach the app directly.
This is a real bug this site shipped: every page declared <link rel="canonical" href="http://..."> while being served over https, which tells Google the insecure copy is the authoritative one.

Which platforms work

                      runs Bun?   ships whole dir?
  VPS / Docker            yes           yes        works as-is
  Fly.io                  yes           yes        works as-is
  Railway / Render        yes           yes        works as-is

  Vercel                  yes           no         build --target vercel

  Netlify / Cloudflare     no           -          wrong runtime
  GitHub Pages, any CDN    no           -          no runtime at all
                                                   -> stoneware export
the runtime decides, not the framework

Anywhere you can run bun server.ts against the project directory, nothing extra is required — the directory is simply there. The second column is what a bundling platform makes hard, and it is the column 0.1.4 removed: once the build is relocatable, only the runtime question is left. Cloudflare Workers run V8 isolates and Netlify Functions run Node, so neither can host a Stoneware server — for those, prerender the site instead.

Static export

stoneware export writes the whole site to a directory of plain files, which removes the runtime requirement entirely. Every page is fetched through the ordinary request pipeline rather than a second rendering path, so the HTML on disk is byte-identical to what the server would have sent.

terminalsh
stoneware export --out dist
dist/
├── index.html                 <- routes/index.tsx
├── 404.html                   <- routes/_404.tsx, if you have one
├── docs/
│   ├── index.html             <- routes/docs/index.tsx
│   └── routing/index.html     <- routes/docs/[slug].tsx
├── _stoneware/                island chunks + the hashed stylesheet
└── mark.svg                   everything from public/
dist/

A page is written as <path>/index.html rather than <path>.html, so a static host serves it at the URL the dev server used — no trailing-slash redirect and no per-host rewrite rules to write.

The 404 page is the exception, because it has no URL of its own. It is produced by requesting a path that cannot match and written to 404.html, which is the filename Cloudflare Pages, Netlify and GitHub Pages each serve for a miss.

A route with [params] cannot be enumerated on its own. Export it by having the module say which pages exist; without staticPaths the route is skipped and named in the summary rather than guessed at.

routes/docs/[slug].tsxtsx
export function staticPaths() {
  return DOCS.map((page) => ({ slug: page.slug }));
}
Two things are never written: server actions, which have no GET, and any page that renders a CSRF token. A prerendered token would be frozen into the file and handed to every visitor, and one token for everyone is no protection at all. Both are reported at the end of the run, so the omission is visible rather than silent.

That last rule is also the boundary of the technique. A form backed by a server action needs a running server; export covers the pages around it, not the action itself. A fully static site is one with no mutating requests.

Vercel

Vercel runs Bun as a first-class function runtime. Its Bun framework preset detects a single Bun.serve() call in a root server.ts and routes every request through it, so no /api directory and no routing configuration are needed. The preset requires a bun.lock file to be present.

stoneware build --target vercel writes both pieces the preset looks for: a root server.js that imports the built bundle, and a vercel.json if the project has none. An existing vercel.json is never rewritten — it is hand-maintained configuration that may carry regions, headers or redirects — so anything missing from it is reported instead.

On 0.1.3 and earlier you could write these two files by hand and the deploy still 404'd: the bundle recorded its build path and rescanned routes/, so the function started and matched nothing. The target and the fix shipped together in 0.1.4, which is why upgrading is the answer rather than more configuration.
terminalsh
stoneware build --target vercel
vercel.jsontxt
{
  "$schema": "https://openapi.vercel.sh/vercel.json",
  "framework": "bun",
  "bunVersion": "1.x",
  "buildCommand": "bun node_modules/stoneware/bin/stoneware.mjs build --target vercel"
}
server.js — generated, do not edittsx
import "./.stoneware/server.js";

It also copies the built island chunks and the stylesheet into public/_stoneware/, and that copy is what makes the deploy work at all. Vercel builds the function by tracing imports, and the server locates those assets through a path it works out at runtime — which tracing cannot follow, so they were left behind. public/ is a platform convention rather than a Stoneware one, so it ships regardless, and the assets are served from the CDN instead of through the function.

  before                          now
  ──────────────────────────      ──────────────────────────
  GET /             200           GET /             200
  GET /_stoneware/                GET /_stoneware/
      styles.css    404               styles.css    200
      Counter.js    404               Counter.js    200
what that fixed, from 0.1.7

Add public/_stoneware/ to .gitignore — it is build output, emptied and rewritten on every build, and new projects get the rule already. On 0.1.6 and earlier the symptom is a site that renders perfectly and arrives unstyled with dead islands, which reads as a CSS bug rather than a missing file. Upgrading is the fix.

A side-effect import, deliberately. The bundle calls Bun.serve() as it evaluates, and that call is exactly what the preset detects. Exporting a handler instead would leave the server unstarted and every request unrouted.

framework: "bun" is the line that matters. Left as "Other", Vercel treats the project as a static build: server.ts is never detected, no function is created, and every path returns 404: NOT_FOUND — even though the build log reports success. Setting it in vercel.json overrides Project Settings, so it is version-controlled rather than a dashboard click someone has to remember.
  • buildCommand invokes the CLI through Bun directly, which sidesteps shim and shebang resolution in the build image.
  • Set STONEWARE_CSRF_SECRET as a project environment variable.
  • If the app lives in a subdirectory of a larger repo, set Root Directory to it — Vercel still clones the whole repository and only changes directory into it.
Do not add a functions block for the preset. Those patterns only match Serverless Functions inside an api/ directory, so the build fails with "The pattern server.ts doesn't match any Serverless Functions inside the api directory." There is no includeFiles equivalent for the framework preset.

On 0.1.5 the function carries what it needs. If one still crashes, the remaining suspect is .stoneware/ itself. A serverless filesystem is read-only outside /tmp, so a missing island manifest used to make the server fall back to rebuilding chunks, and that write failed in a way that looked unrelated to the cause. It now fails immediately with a message naming the directory instead.

On 0.1.3 and earlier the usual failure is different and quieter: the bundle records the absolute path it was built at and rescans routes/ on every request, so a function that starts perfectly well answers 404 for every path. Fixed in 0.1.4 rather than worked around, so upgrading is the answer rather than the /api model.

Cloudflare, Netlify, GitHub Pages and any CDN

None of these run Bun, so none can host a Stoneware server — they host the output of stoneware export instead. That is not a downgrade for a content site: there is no cold start, no runtime to crash, and the whole thing is served from the edge.

Cloudflare Workers, with static assetssh
stoneware export          # writes dist/
wrangler deploy
wrangler.tomltxt
name = "my-site"
compatibility_date = "2026-01-01"

[assets]
directory = "dist"
not_found_handling = "404-page"

not_found_handling = "404-page" is what makes your routes/_404.tsx the page a visitor sees on a bad URL. Without it Cloudflare serves its own. The export writes dist/404.html precisely because that is the filename every static host looks for.

  Cloudflare Pages   wrangler pages deploy dist
                     reads _headers, so the CSP arrives intact

  Netlify            netlify deploy --prod --dir dist
                     reads _headers too

  GitHub Pages       push dist/ to the pages branch
                     no header support - the meta-tag CSP is
                     what you get, and 404.html still works

  S3 + CloudFront    aws s3 sync dist s3://bucket --delete
                     set the error document to 404.html
the other three, same directory

The failure to expect on a static host

A page that 404s while the rest of the site works is almost never the host. It is a page the export never wrote, and the export said so at the time.

the line that predicted ittxt
[stoneware] exported 10 page(s) in 13088ms
  skipped  /divisions/[division] (no staticPaths export)
  skipped  /products/[sku] (no staticPaths export)

A dynamic route matches infinitely many URLs and prerendering writes a finite number of files, so the route has to say which ones. Without staticPaths() nothing is written, the links to those pages still appear on the pages that were written, and every one of them 404s on a site that otherwise looks finished. See static export for how to write it.

The export now names every link that points at a page it did not write, and stoneware export --strict exits 1 when any route was skipped or any link dangles. In CI, use --strict and the build fails instead of the site.

When a serverless deploy crashes

A bundled function that starts without its files fails at startup, and the platform reports a generic 500. Check the function log rather than the page: the cause is a missing directory nearly every time, and the log names it.

Guarding for it explicitly turns an opaque crash into a readable one, which is worth the six lines on any host that bundles.

server.ts — optional preflighttsx
import { existsSync } from "node:fs";
import { resolve } from "node:path";

if (!existsSync(resolve(process.cwd(), ".stoneware/islands.json"))) {
  console.error(
    `[stoneware] missing .stoneware/ in ${process.cwd()} — ` +
      "the build output did not reach the runtime",
  );
}

Something wrong in the framework itself rather than the page? Open an issue on GitHub.