Workers and frameworks
A Worker app is JavaScript that runs on Cloudflare Workers for each request that passes our sign-in. It can be a single file, a TypeScript project that wrangler builds, or a framework app built with its Cloudflare adapter.
A Worker with nothing to build
Section titled “A Worker with nothing to build”A directory with an index.js and no package.json is a Worker, uploaded as it is:
export default { async fetch(request, env) { return new Response(`Hello from ${new URL(request.url).pathname}`); },};forculus deploy acme/hello . --createa Worker because it has index.jsVersion 1 (0c4e9f3a7d21) is live.https://hello--acme.forculuslabs-apps.techThe entry is the file package.json names as module or main, when it has a default export, or else the first of index.js, index.mjs, src/index.js, src/index.mjs, dist/index.js, dist/index.mjs, worker.js, worker.mjs, src/worker.js and src/worker.mjs. It must be an ES module. Name another with --main or worker.main in forculus.json.
Every module in the directory is uploaded with it: .js and .mjs as ES modules, .wasm as WebAssembly, and .txt, .html, .css, .md, .svg and .json as text. Dotfiles, node_modules and symlinks are left out. worker.include limits the upload to the directories you list.
Nothing is bundled in this case. If the entry imports a package by name, like import { requireUser } from "@forculus/sdk", or imports a file that does, it’s built with wrangler like a TypeScript entry, so the package is bundled into the Worker:
a Worker because it has index.js, builds with wrangler, since index.js imports @forculus/sdkThe package must be installed first. If it isn’t, the deploy stops before the build:
error: index.js imports @forculus/sdk, which isn't installed. The deploy bundles it into the Worker, so run `npm install` or your package manager's install, then deploy again.TypeScript and wrangler builds
Section titled “TypeScript and wrangler builds”A project with a wrangler config (wrangler.jsonc, wrangler.json or wrangler.toml), or with a TypeScript entry (src/index.ts, index.ts, src/worker.ts or worker.ts), is built with wrangler:
{ "name": "hello", "main": "src/index.ts", "compatibility_date": "2026-09-01", "compatibility_flags": ["nodejs_compat"]}forculus deploy runs wrangler deploy --dry-run --outdir into a scratch directory, which builds the Worker without deploying it, then uploads what wrangler wrote. It uses the project’s own wrangler if node_modules has version 4 or later, and otherwise runs npx --yes wrangler@4.134.0. To use another wrangler, set FORCULUS_WRANGLER to its path.
If a TypeScript entry, or a JavaScript entry that imports a package, has no wrangler config, forculus writes a temporary one for the build. A project can be a single src/index.ts.
After the build, forculus reads the config the build used. If a build tool generates its own config and points .wrangler/deploy/config.json at it, forculus reads that one and checks it the same way as yours.
forculus deploy installs dependencies only for a project with a build script in package.json, or a forculus.json that sets build.command, build.install or build.output. For a TypeScript Worker with none of these, run npm install (or your package manager’s install) before you deploy.
From a build, .js and .mjs are uploaded as ES modules, .wasm as WebAssembly, .txt, .html, .css, .json, .md and .svg as text, and .bin and .dat as data. Source maps (.map) aren’t uploaded. A Worker can’t load CommonJS, so a build that writes .cjs stops the deploy:
error: The build wrote chunks/legacy.cjs, but a Worker can't load CommonJS. Build to ES modules.So does a failed build:
error: The build failed. Fix it the way you normally would, then deploy again.Compatibility date and flags
Section titled “Compatibility date and flags”The compatibility date comes from --compatibility-date, then forculus.json, then the wrangler config, and is 2026-09-01 if none of them sets one. It must be a valid date, and not in the future. forculus deploy --dry-run shows the date a flag or forculus.json sets, and forculus --json versions has the one each version runs with, as compatibility_date. A version’s sha256 names its code and files, not its date, so two versions that differ only in their date share one.
Five compatibility flags are allowed: nodejs_compat, nodejs_compat_v2, nodejs_als, global_fetch_strictly_public and no_nodejs_compat_v2, which Nitro writes into the config it generates for Cloudflare. Any other is refused before the build:
error: wrangler.jsonc asks for the compatibility flag streams_enable_constructors, which we don't allow. You can use nodejs_compat, nodejs_compat_v2, nodejs_als, global_fetch_strictly_public, no_nodejs_compat_v2.From compatibility date 2026-08-04, Cloudflare turns on nodejs_compat and nodejs_compat_v2 by default, so you only need those two for a Worker that pins an earlier date. nodejs_als is never on by default.
Bindings and variables
Section titled “Bindings and variables”Your Worker gets these on env:
| Name | What it is |
|---|---|
DB |
The app’s database, a D1 database. |
FILES |
The app’s own files, an R2 bucket. |
ASSETS |
The files a framework build serves, when it serves any. |
FORCULUS_APP_ID |
The app’s id, which the identity token’s audience has to match. |
FORCULUS_IDENTITY_ISSUER |
Who signs the identity token. |
FORCULUS_JWKS_URL |
Where the keys to check it are published. |
| Your variables | Each environment variable you set, as a string. |
The SDK reads the three FORCULUS_ values to check who is calling. Every request carries a signed identity token in X-Forculus-Identity, and requireUser(request, env) from @forculus/sdk verifies it. Node.js has an example in a Worker.
A Worker’s outbound fetch goes through our egress proxy, which adds your secrets to requests for the hosts they’re for.
The Cache API
Section titled “The Cache API”Cloudflare turns caches.default off for Workers run the way we run them (Workers for Platforms). SvelteKit’s Cloudflare adapter reads it when its Worker loads, and Astro’s image endpoint reads it when it answers, so both would fail on every request.
To prevent that, forculus deploy adds a small module, __forculus_platform.js, that runs before your code. Where caches.default is off, it puts an empty cache in its place: match finds nothing, and put and delete do nothing. Your Worker’s own module is loaded through __forculus_entry.js, which runs __forculus_platform.js first and re-exports everything yours exports. Both names are reserved, so a Worker with its own module named either is refused.
caches.open("name") is unchanged, and each app’s caches are its own. OpenNext’s regional cache and Hono’s cache middleware use it.
Allowed wrangler config
Section titled “Allowed wrangler config”Each app gets its D1 database, bound as DB, and its files, bound as FILES. Neither needs anything in the config. On Team and Business, an app whose forculus.json asks gets key-value storage and a queue, bound as KV and QUEUE. A config that asks for anything else is refused before the build, with every problem listed at once:
error: wrangler.jsonc asks for things we don't offer: vars (API_BASE): set plain values with `forculus env set` instead forculus env set <app> API_BASE=<value> ai (AI): we don't offer Workers AI| Refused | Why |
|---|---|
vars |
Plain values belong in forculus env set. |
r2_buckets |
The app’s own files come bound as FILES. |
ai, vectorize, hyperdrive, browser, send_email, workflows |
We don’t provide these. |
services |
A service binding would reach another Worker directly. |
durable_objects |
Its fetches would skip the egress proxy. |
dispatch_namespaces |
Only we route requests to apps. |
mtls_certificates |
Certificates belong in forculus secret set. |
containers |
A container app is deployed from a Dockerfile. |
unsafe |
Unsafe bindings can be anything Cloudflare adds in future. |
images |
We don’t provide Cloudflare Images. |
routes, route, triggers, crons, zone_id, workers_dev_preview |
An app answers only at its own URL, through the edge. |
A kv_namespaces binding named KV, and a queues producer named QUEUE, are allowed too, so the config you run wrangler dev with deploys as it is. We bind the app’s own there when its forculus.json asks, and the deploy says so. Any other name is refused, and so is a queue consumer: we send the queue’s messages to the path in forculus.json.
A D1 binding is allowed if it’s named DB. We bind the app’s own database there, whatever database_id says. Any other name is refused:
error: wrangler.jsonc asks for things we don't offer: d1_databases (MAIN_DB): each app gets one database, bound as DBThese keys are allowed: name, main, compatibility_date, compatibility_flags, account_id, workers_dev, preview_urls, minify, keep_vars, keep_names, no_bundle, find_additional_modules, base_dir, preserve_file_names, rules, define, alias, tsconfig, build, dev, assets, d1_databases, kv_namespaces, queues and $schema.
These are also allowed but have no effect here. The deploy warns you when one is turned on:
| Key | Why it does nothing |
|---|---|
upload_source_maps |
Source maps aren’t uploaded, so stack traces point into the built code. |
observability |
We keep every app’s logs. Read them with forculus logs. See Logs. |
placement |
We decide where an app runs. |
limits |
The plan sets an app’s limits. |
wrangler.jsonc sets upload_source_maps, which does nothing here: source maps aren't uploaded, so stack traces point into the built code.Any other key in a config you wrote is refused, since it could ask for something we don’t provide:
error: wrangler.jsonc has tail_consumers, which we don't know how to deploy. Remove it, or write to support@forculuslabs.tech about what the app needs.When the config asks for anything in the table too, these keys are listed with the rest, each as we don't know how to deploy it.
In a config generated by a build tool, only the bindings it actually asks for are checked, because such configs list every binding type, empty. An images binding there isn’t refused, since the app may never use it. The deploy says that the app can’t transform images as it runs.
Frameworks
Section titled “Frameworks”A framework app is a Worker if it depends on the framework’s Cloudflare adapter. Without one, it’s a container or a static site, depending on whether it renders on a server:
| Framework | Worker with | Otherwise |
|---|---|---|
| Next.js | @opennextjs/cloudflare |
container |
| Astro | @astrojs/cloudflare |
static site |
| SvelteKit | @sveltejs/adapter-cloudflare or @sveltejs/adapter-cloudflare-workers |
container with @sveltejs/adapter-node, static site with @sveltejs/adapter-static, refused with any other |
| Nuxt | nitro-cloudflare-dev |
container |
| React Router | @react-router/cloudflare |
container, with @react-router/node |
| Remix | @remix-run/cloudflare |
|
| Vite | @cloudflare/vite-plugin |
static site |
| Hono | hono or @hono/vite-build |
An adapter’s setup guide has you write a wrangler config naming the Worker it builds and the directory of files it serves. Keep that config in the project, because forculus deploy builds with wrangler only when there’s a wrangler config or a TypeScript entry. For an Astro project with its wrangler config, forculus detect prints:
a Worker because it has wrangler.jsonc, builds with `npm run build`, builds with wrangler, from wrangler.jsonc install: pnpm install --frozen-lockfile build: npm run buildSwapping one adapter for another changes the project’s kind, but an app keeps the kind it was created with. A Next.js app deployed as a container stays a container, so deploy the OpenNext version to a new app.
Files a framework serves
Section titled “Files a framework serves”A framework build produces a Worker and a directory of files, named by assets.directory in the config the build used. The files are uploaded like a static site’s, with the same limits and checks. The directory must be inside the app.
_worker.js, _worker.js.map and _routes.json are not served, as files or as directories. Astro and SvelteKit write the Worker inside the directory they serve, so this keeps your server code from being served.
assets.not_found_handling (single-page-application, 404-page or none) and assets.run_worker_first are passed on. With neither set, a path with no file goes to your Worker.
A _headers file in that directory works as it does for a site. It applies to the files only, not to responses from your Worker. Use it to let browsers keep hashed files, so a return visit doesn’t request each one again.
Next.js
Section titled “Next.js”@opennextjs/cloudflare runs the Next.js build itself, so forculus deploy runs ./node_modules/.bin/opennextjs-cloudflare build instead of your build script. Keep "build": "next build" as it is.
a Worker because it has wrangler.jsonc, @opennextjs/cloudflare builds what gets deployed, after the framework's own build, builds with `./node_modules/.bin/opennextjs-cloudflare build`, builds with wrangler, from wrangler.jsonc install: npm ci build: ./node_modules/.bin/opennextjs-cloudflare buildThe config and open-next.config.ts that npx @opennextjs/cloudflare migrate writes ask for three things we don’t offer, so the deploy stops before the build and lists them:
error: wrangler.jsonc asks for things we don't offer: r2_buckets (NEXT_INC_CACHE_R2_BUCKET): where the platform has storage on, an app's own files come bound as FILES, with nothing to set up services (WORKER_SELF_REFERENCE): a service binding would reach another Worker directly images (IMAGES): we don't offer Cloudflare ImagesOpenNext's own setup asks for these. Take services, r2_buckets and images out of the config, and the incrementalCache line out of open-next.config.ts.Before the first deploy:
- In
wrangler.jsonc, removeservices,r2_bucketsandimages. - In
open-next.config.ts, remove theincrementalCacheline and its import, so it readsexport default defineCloudflareConfig({});.
Without the IMAGES binding, OpenNext doesn’t optimise images. Set images: { unoptimized: true } in next.config.ts, so <Image> serves each one as it is.
Without OpenNext, a Next.js app is a container started with npm start.
With output: "server", Astro’s CSRF check (security.checkOrigin) refuses any non-GET request whose Origin header doesn’t match the site. A browser always sends Origin, but a script, a webhook or forculus curl doesn’t. Every request has passed our sign-in before your app sees it, so an app with endpoints for scripts can turn the check off:
import cloudflare from "@astrojs/cloudflare";import { defineConfig } from "astro/config";
export default defineConfig({ output: "server", adapter: cloudflare(), security: { checkOrigin: false },});From @astrojs/cloudflare 14, the adapter keeps sessions in a key-value binding called SESSION, and transforms images with a Cloudflare Images binding called IMAGES. We bind an app’s key-value storage as KV, and we don’t offer Cloudflare Images. So name the sessions’ binding KV, and have images made as the site builds:
export default defineConfig({ output: "server", adapter: cloudflare({ sessionKVBindingName: "KV", imageService: "compile" }), security: { checkOrigin: false },});If the app uses Astro’s sessions, also ask for key-value storage in forculus.json, which takes Team or Business:
{ "version": 1, "kv": true }Without the sessionKVBindingName line, the deploy stops after the build and says what to set:
error: wrangler.json asks for things we don't offer: kv_namespaces (SESSION): an app gets one namespace of its own, bound as KV, when forculus.json says "kv": trueAstro's adapter keeps its sessions in a binding called SESSION. Set sessionKVBindingName: "KV" in the adapter's options. If the app uses sessions, also set "kv": true in forculus.json.An Astro site without the adapter deploys as a static site.
SvelteKit
Section titled “SvelteKit”A project made with sv create has @sveltejs/adapter-auto, which builds only for hosts it recognises. The deploy stops before building and says which adapter to add. For a Worker:
npx sv add sveltekit-adapter="adapter:cloudflare+cfTarget:workers" && npm run genThis installs @sveltejs/adapter-cloudflare, writes the wrangler.jsonc the build needs, and changes the build script to check the Worker’s types first. npm run gen writes those types.
For a container instead, add the Node adapter with npx sv add sveltekit-adapter="adapter:node". If package.json has no start script, the container starts with node build, where that adapter writes its server. @sveltejs/adapter-static makes a static site.
A Vite project with @cloudflare/vite-plugin builds with npm run build, and the plugin’s output is uploaded. Without the plugin, a Vite project is a static site.
Hono is a Worker. A Hono project with only a src/index.ts needs no wrangler config:
a Worker (Hono) because package.json depends on hono, builds with wrangler, from src/index.tsIf there’s no build script and nothing under build in forculus.json, dependencies aren’t installed for you, so run npm install before the first deploy.
Limits
Section titled “Limits”| Code, all modules together | 32 MiB |
| Modules | 1,000 |
| CPU time per request | set by the plan: see Limits |
| Subrequests per request | set by the plan: see Limits |
| Files a framework serves | the static site limits |
The two modules forculus deploy adds, __forculus_platform.js and __forculus_entry.js, don’t count toward the size or the number. Each of them can be up to 64 KiB instead. A Worker with more modules of its own is refused before anything is uploaded:
error: The Worker has 1001 modules, and it can have up to 1000. Deploy only what the app needs.A live key in the code stops the deploy. Put keys in secrets instead, so your code doesn’t hold them:
error: src/config.js has what looks like a Stripe key in it. Remove it and use `forculus secret set`, which keeps it out of the app entirely.A live Stripe key is one of the kinds always refused. Other keys, and packages with published vulnerabilities, warn or refuse as the workspace chooses. See Deploy scans.
Next steps
Section titled “Next steps”- Environment variables for configuration.
- Secrets and outbound calls for API keys.
- Local development to run the app behind a local copy of our front door.