Cloudflare Worker + Static Assets Adapter
The Cloudflare adapter turns a StatikAPI project into a Cloudflare deployment with two clearly separated surfaces:
- Public routes are emitted as Cloudflare Static Assets under
/public/... - Private routes are stored in a private R2 bucket and served through the Worker
It also provides:
- a public manifest served as a static asset
- a private manifest served by the Worker
- authenticated route-path POST webhooks for private rebuilds
- a local preview UI and Cloudflare-specific
devflow
This page documents the current shipped behavior in this repo.
1) What the adapter supports today
Public routes
- Public is the default.
- A route without
config.cloudflare.public = falseis treated as public. - Public routes are emitted under
/public/.... - Public routes are generated during
statikapi-cf buildinto your configured Static Assets directory. - Cloudflare should serve those assets directly, without forcing the Worker to run first.
Private routes
- Set
config.cloudflare.public = falseto make a route private. - Private routes keep their original route paths.
- Private output is stored in a private R2 bucket.
- Private reads require the configured auth header.
Manifests
- Public manifest:
/public/_manifestwhenSTATIK_USE_INDEX_JSON = "false"/public/_manifest/index.jsonwhenSTATIK_USE_INDEX_JSON = "true"
- Private manifest:
GET /_manifest- requires the private auth header
Manifest entries include route metadata from the source route:
srcRouterecords the original route patternwebhookAvailableistruewhen that route can be refreshed by webhook POSTswebhookRouterepeats the source route that webhook POSTs target
Webhook rebuilds
POST /- rebuild all webhook-enabled private outputs
POST /users/1- rebuild the matching private route
POSTto a public route path- rejected on purpose
Important limitation in this version
Public routes are not webhook-refreshable in this version.
That means:
POST /and route-pathPOSTrequests refresh private Worker-managed outputs only- public Static Assets change only after a new build + deploy
If a route must be updated immediately by webhook in production, it should stay private instead of public.
2) Scaffold a project
Use the dedicated template:
pnpm dlx create-statikapi my-worker --template cloudflare-adapter
# or
npx create-statikapi my-worker --template cloudflare-adapter
# or
yarn create statikapi my-worker --template cloudflare-adapter
The template creates:
wrangler.tomlstatikapi.config.js.dev.varssrc-api/- package-manager-specific
dev,build, anddeployscripts
The scaffold asks for:
- project name
- package manager
- Static Assets directory for public output
- private R2 bucket binding and bucket name
- KV binding and namespace id
STATIK_BUILD_TOKENSTATIK_SRCSTATIK_USE_INDEX_JSON- private auth header name and value
- Cloudflare account id
- Cloudflare API token placeholder
- optional runtime limit values
After scaffolding:
cd my-worker
pnpm install
pnpm dev
The default dev flow:
- builds the Worker bundle
- builds public Static Assets
- starts local
wrangler dev - starts the preview UI
- tries to open
/_ui/automatically
Local vs deployed variables
The scaffold now writes a real .dev.vars file directly.
Use .dev.vars for:
- local preview/private-route auth during
pnpm dev - local
wranglercommands that needCLOUDFLARE_ACCOUNT_IDorCLOUDFLARE_API_TOKEN
In the current scaffold contract:
.dev.varsstays local to your machinewrangler.tomlstill carries non-secret runtime config such as route-shape settings, bindings, and usage limits- the auth/build values below are no longer scaffolded in
wrangler.toml:STATIK_BUILD_TOKENSTATIK_PRIVATE_AUTH_HEADER_NAMESTATIK_PRIVATE_AUTH_HEADER_VALUE
So if a value must affect the deployed Worker runtime, make sure it is set in the deployed Worker configuration, not just in local .dev.vars.
3) Route behavior and route config
Route modules can export route-level Cloudflare config:
export const config = {
cloudflare: {
public: false,
webhook: true,
},
};
config.cloudflare.public
- omitted or
true- route is public
- output is emitted under
/public/...
false- route is private
- output stays on the original route path
- route is served by the Worker
config.cloudflare.webhook
- omitted
- follows the project default
true- allow webhook rebuilds for that route
false- reject targeted webhook rebuilds for that route and skip it during full rebuilds
config.listIndex
The Cloudflare adapter also supports config.listIndex for dynamic and catch-all routes, just like the regular CLI path.
Example:
export const config = {
listIndex: {
enabled: true,
pick: ['id', 'title'],
},
};
That will generate a parent collection route in addition to the per-item outputs:
- item routes:
/public/posts/1/public/posts/2
- collection route:
/public/posts
The exact file shape depends on STATIK_USE_INDEX_JSON.
4) File and URL shape
The adapter supports two public-output shapes via STATIK_USE_INDEX_JSON.
STATIK_USE_INDEX_JSON = "false"
Public assets look like:
- route surface:
/public/public/posts/1/public/docs/guide/public/_manifest
- backing asset files may use hidden extensionless
indexfiles so parent and child routes can coexist:public/indexpublic/posts/1/indexpublic/docs/guide/indexpublic/_manifest/index
STATIK_USE_INDEX_JSON = "true"
Public assets look like:
/public/index.json/public/posts/1/index.json/public/docs/guide/index.json/public/_manifest/index.json
Private routes always keep the route path itself:
/account/posts/1/_manifest
For false mode, private stored keys are extensionless too, for example:
accountposts/1
The preview UI and snippet generator should follow the actual project shape.
5) wrangler.toml shape
The scaffolded wrangler.toml is built around one Worker + one Static Assets directory + one private R2 bucket + one KV namespace.
Typical structure:
[assets]
directory = "./public"
binding = "ASSETS"
[[r2_buckets]]
binding = "STATIK_PRIVATE_BUCKET"
bucket_name = "my-statikapi-private"
[[kv_namespaces]]
binding = "STATIK_MANIFEST"
id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
[vars]
STATIK_SRC = "src-api"
STATIK_USE_INDEX_JSON = "false"
STATIK_PRIVATE_BUCKET_BINDING = "STATIK_PRIVATE_BUCKET"
STATIK_WORKER_REQUEST_LIMIT = "0"
STATIK_R2_CLASS_A_LIMIT = "0"
STATIK_R2_CLASS_B_LIMIT = "0"
Important notes:
[assets].directoryis the public output target- do not configure
run_worker_first = truefor this adapter contract - public asset requests should be served by Static Assets directly
- the Worker still handles private routes, manifests, and rebuild webhooks
Cloudflare references:
- Workers Static Assets overview
- Static Assets configuration and bindings
- Wrangler configuration reference
6) Local development
Recommended command
pnpm dev
This runs statikapi-cf dev.
The local flow is:
- Build
dist/worker.mjs - Generate public assets into the configured assets directory
- Start local
wrangler dev - Start the preview UI, usually on
http://127.0.0.1:8788/_ui/ - Watch
src-api/andstatikapi.config.js - Rebuild on change
- Refresh private outputs locally after rebuild so private route content also changes during
dev
Preview UI behavior
The preview UI for Cloudflare projects:
- shows both public and private routes
- groups them separately
- injects the private auth header from
.dev.varswhen previewing private routes - uses the Worker origin in the route details snippets
.dev.vars
Local preview of private routes depends on:
STATIK_PRIVATE_AUTH_HEADER_NAME=x-statik-private-auth
STATIK_PRIVATE_AUTH_HEADER_VALUE=dev-private-token
STATIK_BUILD_TOKEN=dev-token-123
If those values are missing, private preview and local private rebuilds will fail.
7) Build, preview, and deploy commands
Build
pnpm build
Build should produce both:
- Worker bundle:
dist/worker.mjs
- public assets:
- inside your configured Static Assets directory
Preview only
If you are already running the Worker separately:
pnpm exec statikapi-cf preview --worker http://127.0.0.1:8787 --port 8788
Deploy
pnpm deploy
or:
wrangler deploy
In the scaffolded project, pnpm deploy is the safer default because it runs the StatikAPI build first.
Deploying publishes:
- Worker code
- Worker bindings/config
- public Static Assets
It does not magically refresh public assets later from runtime webhooks.
8) Pricing, free limits, and what starts charging
This adapter uses four Cloudflare product surfaces:
- Workers
- for private route reads,
POSTrebuilds, and the private manifest
- for private route reads,
- Static Assets
- for public routes under
/public/...
- for public routes under
- Workers KV
- for manifest/control-plane metadata
- R2
- for private route payloads
What is free on Cloudflare's free tiers
As of May 2026, the most relevant limits for this adapter are:
- Workers Free
100,000requests per day10 msCPU time per invocation50external subrequests per invocation20,000Static Asset files per Worker version
- Static Assets
- static-asset requests are free and unlimited
- no additional storage charge for Static Assets
- Workers KV Free
100,000key reads per day1,000writes per day1,000deletes per day1,000list requests per day1 GBstored data
- R2 Standard Free
10 GB-monthstorage per month1 millionClass A operations per month10 millionClass B operations per month- egress is free
Cloudflare references:
What that means for this adapter in practice
If your project is mostly public:
- public asset traffic is the cheapest path
- static-asset requests do not consume Worker request quota when they are served directly by Static Assets
If your project is private-heavy:
- every private read goes through the Worker
- those requests count against Workers usage
- each private read also causes R2 read-class work
If you rebuild private routes often:
- the Worker rebuild endpoint uses Worker requests
- KV updates count against KV usage
- R2 writes/lists count against R2 Class A operations
When charges start
Workers
- on the free plan, once you exceed the free daily limits, requests stop being free and you need the paid Workers plan
- on the paid Workers Standard plan, pricing currently includes:
10 millionrequests per month30 millionCPU milliseconds per month- then overages are billed per additional million
- Cloudflare currently documents the Workers Paid plan as having a
$5/monthminimum
Static Assets
- static-asset requests are free and unlimited
- Cloudflare currently documents no extra storage charge for Static Assets themselves
Workers KV
- on the free plan, exceeding a daily KV limit causes further operations of that type to fail until reset
- on the paid plan, overages are billed by operation/storage type
R2
- beyond the monthly free tier, R2 bills separately for:
- GB-month stored
- Class A operations
- Class B operations
- Cloudflare rounds usage up to the next billable unit
Rough free-tier guidance
These are rules of thumb, not guarantees:
- if your API is mostly public and traffic is low to moderate, the free plan can stretch surprisingly far because Static Asset requests are free
- if you have many authenticated/private reads, the Worker free request limit becomes the first likely bottleneck
- if you rebuild private data frequently or store lots of private payloads, R2 and KV become the next costs to watch
You should expect to move to the paid Workers plan sooner if:
- private endpoints are used regularly in production
- private rebuild webhooks run frequently
- your Worker does noticeable compute per request
Concrete examples
Example A: Mostly public API
Imagine:
- almost all traffic hits
/public/... - private routes are rarely used
- rebuilds happen a few times per day
This is the most free-tier-friendly shape for this adapter because:
- public Static Asset requests are free and unlimited
- Worker usage stays relatively low
- R2/KV activity stays low
In this shape, your first paid pressure point is often deployment/build frequency or private-admin usage, not public traffic itself.
Example B: Private dashboard/API usage
Imagine:
- authenticated clients frequently read private routes
- each private request goes through the Worker
- each private read also hits private storage
In this shape, your first pressure point is usually:
- Workers request usage
and then:
- R2 read operations
This is the shape where you should expect to move to a paid Workers plan sooner.
Example C: Rebuild-heavy private content
Imagine:
- a CMS or backend triggers
POST /orPOST /routeoften - each rebuild updates multiple private outputs
In this shape, the cost pressure is usually:
- Worker rebuild requests
- KV manifest/control-plane writes
- R2 Class A operations
That means frequent rebuild automation can become the main source of metered usage even before read traffic gets large.
What happens when you exceed limits
- Workers Free
- requests can fail once the account exceeds the free request quota
- KV Free
- further operations of the exceeded type fail until reset
- R2
- overage is billed rather than simply blocked, according to the current pricing model
Check the latest Cloudflare docs before launch, because these limits and prices can change.
9) Webhook rebuild contract
Full rebuild
curl -X POST "https://api.example.com/" \
-H "Authorization: Bearer YOUR_STATIK_BUILD_TOKEN"
Effect:
- rebuild webhook-enabled private outputs
- update the private manifest
- leave already-deployed public Static Assets unchanged
Targeted private rebuild
curl -X POST "https://api.example.com/account" \
-H "Authorization: Bearer YOUR_STATIK_BUILD_TOKEN"
Effect:
- rebuild only that private route, if webhook is enabled for it
Public-route POSTs are rejected
Example:
curl -X POST "https://api.example.com/public/posts/1" \
-H "Authorization: Bearer YOUR_STATIK_BUILD_TOKEN"
That should be rejected because public routes are Static Assets in this model.
STATIK_BUILD_TOKEN is not a Cloudflare API token
STATIK_BUILD_TOKEN is your app’s own shared secret for authorizing rebuild webhooks.
It protects:
POST /POST <private-route-path>
It should be a long random secret and should be treated like any other deploy-time secret.
10) Private auth header contract
Private reads require the configured auth header.
Example:
curl "https://api.example.com/account" \
-H "x-statik-private-auth: YOUR_PRIVATE_VALUE"
The same applies to:
GET /_manifest- all private route reads
Public routes do not need that header.
11) Cloudflare setup checklist
A) Account ID
You need your Cloudflare Account ID for Worker deploy wiring and token scoping.
Official doc:
Practical dashboard path:
- Open Workers & Pages
- Look for Account details
- Copy the Account ID
B) Private R2 bucket
You need the bucket name, not a random label.
Official docs:
You can create it in the dashboard or with Wrangler:
wrangler r2 bucket create my-statikapi-private
C) KV namespace
You need the namespace id for wrangler.toml.
Official docs:
In Cloudflare:
- Go to Workers & Pages
- Open KV
- Create or select a namespace
- Copy the namespace id
D) Static Assets directory
This is the folder Wrangler uploads as public Static Assets.
Official docs:
Common choices:
publicdist- another generated folder
For this adapter, the scaffold defaults to public.
12) Create the Cloudflare API token correctly
Use a custom API token for deployment. Do not use the global API key.
Official docs:
Recommended minimum permissions
For this adapter, the practical account-scoped token is:
Workers Scripts EditWorkers KV Storage EditWorkers R2 Storage Edit
Optional:
Workers Tail Read- only if you want
wrangler tail
- only if you want
Cloudflare’s docs may show the same capability as Edit in some places and Write in others. For deployment purposes, use the write-capable version of those permissions.
Recommended token creation flow
- Open Cloudflare Dashboard
- Go to My Profile -> API Tokens
- or Manage Account -> API Tokens if you use account-owned tokens
- Select Create Token
- Choose Create Custom Token
- Give it a name like:
statikapi-cloudflare-deploy
- Add these Account permissions:
Workers Scripts: EditWorkers KV Storage: EditWorkers R2 Storage: Edit
- Scope the token to the specific Cloudflare account you will deploy into
- Create the token and copy it immediately
Where this token goes
Keep the Cloudflare deploy token in your local environment or local .dev.vars for deploy commands, and use CI/project secrets in automated deploy flows.
Keep it separate from:
STATIK_BUILD_TOKENSTATIK_PRIVATE_AUTH_HEADER_VALUE
Those are application/runtime secrets, not Cloudflare deploy credentials.
Git-connected deployments
Cloudflare can also deploy a Worker from a connected Git repository through its build system. That path is useful if you want deployments to run on pushes instead of from your terminal or self-managed CI.
For this repo, keep the same separation of responsibilities:
- source code and
wrangler.tomllive in git - deploy credentials live in Cloudflare build/project secrets or deployment settings
- runtime secrets live in the deployed Worker configuration
STATIK_DEPLOY_ORIGINpoints at the final deployed Worker or custom domain if you want to make manual post-deploy private-output seeding easier
Recommended Git-connected setup:
- Connect the repository in the Cloudflare dashboard.
- Choose the branch you want to deploy.
- Set the deployment command to
pnpm deployif you want this wrapper's build-first behavior. - Make sure the deployment environment has the Cloudflare deploy credential and any build-time values the wrapper needs.
- Set the deployed Worker secrets separately for:
STATIK_BUILD_TOKENSTATIK_PRIVATE_AUTH_HEADER_NAMESTATIK_PRIVATE_AUTH_HEADER_VALUE
If you prefer a simpler remote pipeline, you can use wrangler deploy as the deployment command and keep the StatikAPI build outside the Cloudflare build step. That works too, but then you must document the manual build and post-deploy seed steps yourself.
13) Deploy to Cloudflare and add a custom domain
Deploy the scaffolded project
After filling in wrangler.toml, preparing .dev.vars, and setting the deployed Worker auth/build vars, the normal deployment flow is:
pnpm deploy
or, if you prefer Wrangler directly:
pnpm build
wrangler deploy
What this deploy does:
- uploads the Worker bundle
- uploads the configured Static Assets directory
- applies the Worker bindings and non-secret variables from
wrangler.toml
What pnpm deploy adds on top of raw wrangler deploy:
- runs the StatikAPI build first so changed public Static Assets are fresh at deploy time
- loads local deploy credentials from
.dev.varsfor the wrapper command - if you need to seed private outputs after deploy, send a manual
POST /to the deployed Worker pnpm deploystill succeeds even when you skip that manual seed step
Before deploying, confirm:
- the private R2 bucket already exists
- the KV namespace already exists
- the
bindingnames inwrangler.tomlmatch the names referenced by the scaffold - the deployed Worker has:
STATIK_BUILD_TOKENSTATIK_PRIVATE_AUTH_HEADER_NAMESTATIK_PRIVATE_AUTH_HEADER_VALUE
- your deploy token has the required permissions
You can set those deployed Worker values either:
- in the Cloudflare dashboard under Worker settings/secrets
- or with Wrangler:
wrangler secret put STATIK_BUILD_TOKEN
wrangler secret put STATIK_PRIVATE_AUTH_HEADER_NAME
wrangler secret put STATIK_PRIVATE_AUTH_HEADER_VALUE
If you want the deploy wrapper to try seeding private outputs automatically after a deploy, also set:
STATIK_DEPLOY_ORIGIN
in .dev.vars to the deployed Worker or custom-domain origin.
Short production checklist
Before your first production deploy:
- Create the private R2 bucket.
- Create the KV namespace.
- Confirm your Cloudflare API token can:
- deploy Workers
- access KV
- access R2
- Confirm
wrangler.tomlcontains the real:- account id
- bucket name
- KV namespace id
- assets directory
- Decide whether your production hostname will be:
workers.devtemporarily- or a Custom Domain such as
api.example.com
- Confirm which routes must stay private because they need webhook-refreshable behavior.
- Optionally set
STATIK_DEPLOY_ORIGIN=https://your-app.example.comin.dev.varsif you want a saved worker origin for manual seeding. - Run:
pnpm deploy
If you need to seed private outputs after deploy, use:
curl -X POST "YOUR_WORKER_URL/" \
-H "Authorization: Bearer YOUR_STATIK_BUILD_TOKEN"
Make sure the deployed Worker has the same secrets as your local .dev.vars by setting STATIK_BUILD_TOKEN, STATIK_PRIVATE_AUTH_HEADER_NAME, and STATIK_PRIVATE_AUTH_HEADER_VALUE in Wrangler or the Cloudflare dashboard.
Add a custom domain
For production, Cloudflare recommends using a Custom Domain or route instead of relying only on the workers.dev hostname.
Official docs:
Cloudflare's current Custom Domain flow:
- Make sure the domain is on an active Cloudflare zone you control.
- Deploy the Worker first.
- In Cloudflare Dashboard:
- go to Workers & Pages
- open your Worker
- go to Settings -> Domains & Routes
- choose Add -> Custom Domain
- Enter the hostname, for example:
api.example.com
- Confirm the setup.
Cloudflare will create the necessary DNS record and certificate handling for that hostname.
Wrangler-based custom domain example
Cloudflare also documents configuring custom domains in Wrangler:
[[routes]]
pattern = "api.example.com"
custom_domain = true
Then deploy again:
wrangler deploy
Important caveats
- you cannot create a Custom Domain on a hostname with an existing conflicting CNAME
- a Custom Domain points the whole hostname at the Worker
- this adapter expects public assets and private Worker endpoints to live under the same deployed hostname
That means a hostname like api.example.com works well for this adapter:
- public assets:
https://api.example.com/public/...
- private routes:
https://api.example.com/account
- manifests:
https://api.example.com/public/_manifest...https://api.example.com/_manifest
14) Deployment model and tradeoffs
This Cloudflare integration is designed around a deliberate tradeoff:
- public
- cheaper to serve
- cache-friendly
- deployed as Static Assets
- not runtime-refreshable by webhook in this version
- private
- Worker-managed
- authenticated
- runtime-refreshable by webhook
That means you should decide route visibility partly based on freshness needs:
- if the route must update immediately from a webhook, keep it private
- if it can wait for a build + deploy, keep it public
15) Troubleshooting
assets binding missing
Usually means your wrangler.toml is missing:
[assets]
directory = "./public"
binding = "ASSETS"
GET /_manifest returns 403
Expected unless you send the private auth header.
Public route did not change after POST /
Expected in this version.
You must:
- run a new build
- deploy again
Private route did not change during local dev
Make sure:
.dev.varsexistsSTATIK_BUILD_TOKENis presentSTATIK_PRIVATE_AUTH_HEADER_NAMEandSTATIK_PRIVATE_AUTH_HEADER_VALUEare present
Preview UI looks stale after UI-source changes
Run:
pnpm ui:build
That now refreshes both:
packages/cli/uipackages/adapter-cloudflare/ui
so the regular CLI preview and Cloudflare preview stay in sync.