Deploy
A preset shapes .output/ for one platform. Choose it with --preset, or preset in the config, or the SILGI_PRESET variable. Without any of them, Silgi picks vercel when VERCEL is set, netlify for NETLIFY, cloudflare for WORKERS_CI (set by Workers Builds), and otherwise bun. Cloudflare Pages (CF_PAGES) does not select cloudflare: its output is a Worker with an assets binding, which Pages does not deploy.
| Preset | Output | Runs on |
|---|---|---|
bun | .output/server/index.mjs and .output/public/ | Bun |
node | the same layout | Node 24 (LTS) or newer |
static | .output/public/, every route prerendered | any static host |
cloudflare | a Workers module, .output/wrangler.json | Cloudflare Workers |
vercel | .vercel/output/ (Build Output API v3) | Vercel |
netlify | .netlify/v1/functions/server/ and dist/ | Netlify |
Files under /_silgi/ are sent with Cache-Control: public, max-age=31536000, immutable. Files from public/ get max-age=0, must-revalidate and an ETag. On the bun and node presets the build writes .output/server/public-assets.json with the size and a SHA-256 based strong ETag of every file, so an unchanged file answers 304 after a new deploy and on every instance. With build.compressPublic: true the build also writes .br, .zst and .gz copies of text, JSON, SVG, font and WASM files of 1 KiB or more (an object such as { brotli: true } turns on only the codings set to true), and the presets serve them by Accept-Encoding, br first. A file with a precompressed copy is always answered with Vary: Accept-Encoding.
Bun
The default. bun .output/server/index.mjs starts the server itself and listens on PORT (default 3000) and HOST; importing the entry starts nothing. Started with node, the entry prints one line and exits with code 1: build with --preset node to run on Node. It is the only preset that runs WebSockets and the sqlite storage driver, and the only one that runs the bun-sql and bun-sqlite connections of @silgi/db. The redis storage driver also runs on node and cloudflare, and s3 on every preset.
silgi build
PORT=8080 HOST=0.0.0.0 bun .output/server/index.mjs
silgi preview runs the same command.
Shutdown
The bun and node servers drain on the first SIGTERM or SIGINT: they stop accepting connections and give requests in flight up to 10 seconds, then close the connections that are left, run the close server hook (also for up to 10 seconds) and exit with code 0. A second signal exits with code 1 at once.
Other details of these two servers: the node server sets keepAliveTimeout to 65 seconds, above the idle timeout of common load balancers. Its requestTimeout, headersTimeout (66 seconds by default) and maxConnections come from the node config key; a HEAD request is answered with the headers only, even on a streaming route, and a client that leaves mid-upload is not logged as an error. The bun server sets no idle timeout on a text/event-stream response. Files in public/ answer a Range request with 206 and Accept-Ranges: bytes.
Node
Needs Node 24 (LTS) or newer. The entry uses node:http and serves public/ itself. node .output/server/index.mjs listens on PORT (default 3000) and HOST, and drains as described under Bun.
silgi build --preset node
PORT=8080 HOST=0.0.0.0 node .output/server/index.mjs
Static
silgi generate builds with the static preset and prerenders every route it reaches by following links from /. A route with params that nothing links to is skipped, with a warning; list such paths in prerender.routes. A redirect answer is written as an HTML redirect page; any other non-2xx answer fails the build, unless prerender.failOnError is false. The message names the page that linked the failing path (/products/404: 404 (linked from /products/2)); leave such a path out with prerender.ignore, or set prerender.failOnError: false to get the same text as a warning. A page whose route middleware redirects is skipped with a warning instead of being written as a redirect page, because the build has no session and the redirect would apply to every visitor; give the route ssr: false to ship it as a client-rendered page, whose middleware runs in the browser. Links to files in public/ are not followed, prerender: false route rules and prerender.ignore leave paths out, and prerender.crawl: false turns link following off. Your own public/_redirects, _headers, 404.html and pages are kept; Silgi's lines are added after yours.
silgi generate
import { defineConfig } from "silgi";
export default defineConfig({
framework: "react",
prerender: { routes: ["/blog/hello", "/blog/world"], crawl: true, failOnError: true },
});
The output has 200.html, the single-page fallback, and 404.html. Point the host's fallback at them: unknown paths get 404.html, and a route that is not prerendered can use 200.html. silgi preview serves the files and 404.html for unknown paths only: it applies neither 200.html, _headers nor _redirects, which are for the host. The server is only used while building, so storage, sessions and server routes work during the build and nowhere after it. Server routes that a prerendered page links to are written as files.
The build also writes _headers and _redirects into .output/public/ from your headers, noindex and redirect route rules, for hosts that read them (Netlify and Cloudflare do; others ignore them). It writes no single-page fallback rule.
A static output serves files only, so after prerender the build warns once and lists what it drops:
[silgi] The static preset serves files only; these are dropped:
server route /api/hello (server/hello.ts)
server middleware server/index.ts (middleware[0])
server.openapi at /_silgi/openapi.json
route rule "/api/**" proxy
route rule "/blog/**" swr
The lines are handler routes whose path was not written as a file, the entries of the middleware list of server/index.ts, the GraphQL and OpenAPI paths when they are on, and route rules with proxy, cors, swr or cache. When nothing is dropped there is no warning.
Cloudflare
The build writes a Worker and .output/wrangler.json, with the assets binding for public/. Cloudflare serves a matching asset before the Worker runs. The Worker has the nodejs_compat flag. Every storage driver runs there except fs, sqlite, netlify-blobs, vercel-blob and vercel-runtime-cache: memory, redis, s3, cloudflare-kv, cloudflare-r2, cloudflare-cache, azure-blob, planetscale, github, http, overlay and null, and the db driver over a d1 connection. scheduledTasks become triggers.crons and the Worker's scheduled handler, and tasks.queue adds a queue producer and consumer; see Tasks. Deploy with Wrangler. The build ends by printing the command: wrangler deploy --config .output/wrangler.json. Run it from the project root, or set it as the deploy command in Workers Builds. A bare wrangler deploy from the app root works too, through the file described under Deploy from the app root.
Your own Worker settings go in a wrangler.json or wrangler.jsonc in the app root: kv_namespaces, vars, d1_databases, routes and the rest. The build merges that file into .output/wrangler.json. Never edit .output/wrangler.json: every build rewrites it. The preset owns main, no_bundle, assets.directory and assets.binding, and sets them over your file. compatibility_flags is the union of your flags and nodejs_compat; name is yours, or the project's name when you give none.
{
"name": "my-app",
"kv_namespaces": [{ "binding": "CACHE", "id": "<namespace id>" }],
"vars": { "ENVIRONMENT": "production" }
}
Compatibility date
Your own compatibility_date is kept when it is 2026-09-01 or later, Silgi's minimum, so an app can ask for a newer runtime behaviour. An older date is raised to 2026-09-01 and the build warns: compatibility_date 2020-01-01 is older than Silgi's minimum 2026-09-01; using 2026-09-01. Without one the build writes 2026-09-01. A value that is not a YYYY-MM-DD date fails the build.
Environments
Set cloudflare.env to the name of an entry of env in your wrangler.json. The build copies that block's keys over the top level of .output/wrangler.json and drops env, so vars, bindings and the compatibility date of that environment are the ones deployed. A name that is not in the file fails the build and lists the names that are (cloudflare.env "qa" is not in wrangler.json (envs: production, staging)). Without cloudflare.env, env is copied as written.
{
"name": "my-app",
"compatibility_date": "2026-10-01",
"vars": { "ENVIRONMENT": "production" },
"env": {
"staging": {
"vars": { "ENVIRONMENT": "staging" },
"kv_namespaces": [{ "binding": "CACHE", "id": "<staging namespace id>" }]
}
}
}
Bound resources
A D1 database of @silgi/db, the R2 bucket of @silgi/blob and the namespaces and buckets of cloudflare-kv and cloudflare-r2 storage mounts are bindings. Silgi writes their d1_databases, r2_buckets and kv_namespaces entries into .output/wrangler.json when it knows the id: cloudflare.bindings.<NAME>.id in the config, else the environment variable SILGI_CF_<NAME>_ID (the name upper-cased, each character outside A-Z0-9_ turned into _, so my-kv reads SILGI_CF_MY_KV_ID; an R2 id is the bucket name). A D1 entry also gets the migrations_dir of your migrations. An entry in your own wrangler.json for the same binding is kept as written and no id is read. A name that another kind of binding, a vars key or a durable object already uses fails the build for D1 and blob.
With no id and no entry of your own, a D1 or blob binding fails the build (db.connections.default uses the d1 binding "DB", which the app's wrangler config does not declare; add a d1_databases entry with binding "DB"), and a storage mount warns once per binding: storage mount "cache" uses the cloudflare-kv binding "CACHE", which has no id and is not in the app's wrangler config; set cloudflare.bindings.CACHE.id or env SILGI_CF_CACHE_ID, or add a kv_namespaces entry with binding "CACHE".
Deploy from the app root
Every cloudflare build writes .wrangler/deploy/config.json in the app root. It holds { "configPath": "../../.output/wrangler.json" } (the path from that file to the built config), and Wrangler reads such a file when it finds no config of its own. So a plain wrangler deploy from the app root deploys the built Worker, without --config. Do not edit the file: every cloudflare build rewrites it.
silgi build --preset cloudflare
bunx wrangler deploy
The build writes _headers and _redirects into .output/public/ from your route rules. Cloudflare reads at most 100 _headers rules and 2,000 static plus 100 dynamic _redirects rules (a source with *, :splat or :pN), and a _headers line is at most 2,000 characters, a _redirects line at most 1,000. Past a limit it ignores the rest without a message, so the build warns, for example [silgi] _redirects has 2101 static rules; Cloudflare reads the first 2000 and ignores the rest. The same check runs for the static preset; Netlify documents no such limit.
silgi build --preset cloudflare
bunx wrangler deploy --config .output/wrangler.json
Vercel
The build writes .vercel/output/: static/ for the files, one Node function for everything else, and config.json with routes. Immutable headers, headers and noindex rules become routes of config.json; the function applies every rule itself too. redirect rules are answered by the function, not by config.json, because a route there cannot keep the request query: /old/x?a=1 under "/old/**": { redirect: "/new/**" } goes to /new/x?a=1. Deploy the prebuilt output.
vercel.functions in the config is merged into the function's .vc-config.json, for example vercel: { functions: { maxDuration: 800, regions: ["fra1"] } }. runtime, handler, launcherType, shouldAddHelpers and supportsResponseStreaming stay Silgi's. A value that is not an object fails when the config loads. getRequestIP(event) returns the first x-real-ip value, which Vercel sets to the client address.
silgi build --preset vercel
vercel deploy --prebuilt
Netlify
The build writes dist/, the folder to publish, with _headers and _redirects, and a self-contained function in .netlify/v1/functions/server/: server.mjs and app/, a copy of .output/server/, so the function needs nothing else from the project. Files win over the function. Set the publish directory to dist. Silgi writes no netlify.toml, and removes the .netlify/functions-internal/server.mjs an older build left.
netlify.functions in the config is merged into the function's config, for example netlify: { functions: { region: "fra", memory: "2gb" } }. path and preferStatic stay Silgi's. Netlify takes only literal values there, so the build writes the merged object into server.mjs as JSON. A value that is not an object fails when the config loads. Synchronous execution stops at 60 seconds on Netlify, whatever the config says.
getRequestIP(event) returns Netlify's context.ip.
silgi build --preset netlify
netlify dev --dir dist
Tasks, queues and request limits per preset
Schedules, queues and body limits differ by platform; each is described where you use it.
| Preset | scheduledTasks | tasks.queue | tasks.runOnStart | Request body limit |
|---|---|---|---|---|
bun, node | in process, UTC | no (in-process enqueue, not durable) | yes | server.maxRequestBodySize, 128 MiB |
cloudflare | triggers.crons | Cloudflare Queues | no | 100 MB (Free, Pro) to 5 GB (Enterprise) |
vercel | crons, needs CRON_SECRET | Vercel Queues (beta) | no | 4.5 MB |
netlify | scheduled functions | no | no | 6 MB |
static | no | no | in the build | not a server |
See Tasks for the weekday rule of cron expressions, and Blob for uploads under these limits. Files a browser uploads to storage directly (direct: true, presigned URLs) do not pass the function.
Route rules
routeRules keys are path patterns: /about, /api/* for one segment, /blog/** for /blog and everything below. Rules from several matching patterns merge, and the most specific one wins.
import { defineConfig } from "silgi";
export default defineConfig({
framework: "react",
preset: "node",
routeRules: {
"/": { prerender: true },
"/admin/**": { ssr: false, noindex: true },
"/blog/**": { swr: 600, headers: { "x-section": "blog" } },
"/old-blog/**": { redirect: { to: "/blog/**", status: 301 } },
"/api/**": { cors: true },
"/legacy/**": { proxy: "https://legacy.example.com/**" },
},
});
| Rule | Effect |
|---|---|
prerender | render the route at build time |
ssr: false | send the single-page shell for the route |
swr, cache | cache the render; see Storage and cache. A cached answer also gets a Cache-Control: public, s-maxage=… header for the CDN, see CDN headers |
headers | add response headers |
redirect | Location with status 307, or the status you give; ** in to is what ** matched |
cors | see Sessions and security |
proxy | forward the request with fetch; ** works as in redirect; see Proxy |
noindex | X-Robots-Tag: noindex |
Set cache, swr, prerender, ssr, cors or noindex to false to turn a broader rule off; redirect, proxy and headers do not take false. Rules run after static files and before server middleware, in the order redirect, proxy, middleware. On every server preset Silgi applies the rules itself. Vercel gets headers rules in config.json, and Netlify, Cloudflare and static get headers and redirect rules in _headers and _redirects; these apply to static files. Each path appears once in those files: for a path with several redirects the most specific wins, and the headers of several rules for one path are merged, the more specific value replacing a header of the same name. proxy, cors, swr and cache always run in the function.
Proxy
A proxy rule sends the request to the target with fetch and streams the upstream answer back. The details:
- The upstream sees its own
host, not the visitor's. - The request's query is added to the target unless the target has a
?of its own. - Hop-by-hop headers (
connection,keep-alive,te,upgradeand the others, and every header named inconnection) are dropped in both directions.accept-encodingis dropped from the request, andcontent-encodingandcontent-lengthfrom the answer, becausefetchhas already decoded the body. - A redirect from the upstream is passed to the visitor as it is, with its
LocationandSet-Cookieheaders; it is not followed. - An upstream that cannot be reached answers 502, as JSON or as the error page, with the rule's CORS headers.
- The
staticbuild dropsproxyrules and warns about it; every server preset runs them.