Static sites
A static site is a directory of files, served as they are to the people you share the app with. None of your code runs on a request. A Worker of ours serves the files, behind the same sign-in as every other app.
What counts as a static site
Section titled “What counts as a static site”Detection makes a directory a static site when either:
- it has an
index.htmland no package.json, or - its package.json depends on a site builder (
@docusaurus/core,vitepress,@11ty/eleventy,react-scripts,astro,viteorparcel) and on no Node server.
A builder with a Cloudflare adapter, such as Astro with @astrojs/cloudflare, is a Worker instead, because pages render on a request.
Deploy one
Section titled “Deploy one”A plain directory of HTML goes up as it is:
forculus deploy acme/handbook ./handbook --createa static site, served by Workers because it has index.html and nothing to buildDeploying 14 files (212 KiB) from ./handbookUploaded 14 files.Version 1 (6f1d0c9a2b47) is live.https://handbook--acme.forculuslabs-apps.techA site with a builder is built first, on your machine:
a static site, served by Workers (Vite) because package.json depends on vite, builds with `npm run build`, deploys what the build writes install: npm ci build: npm run buildInstalling with `npm ci` ...Building with `npm run build` ...Deploying 9 files (148 KiB) from ./distThe build tool’s own output follows each step, on stderr. Files are uploaded by content hash, so a redeploy sends only the files that changed.
Which directory is served
Section titled “Which directory is served”After the build, the first of these that exists is served:
dist, build, out, _site, .output/public, .vitepress/dist, docs/.vitepress/dist
After a build, public is not served. Builders copy it into their own output, and on a fresh checkout it’s often the only one of these directories there, so serving it would publish your sources instead of your site. When there’s nothing to build, public is served if it exists, and otherwise the app’s directory itself.
If the build writes somewhere else, the deploy stops instead of serving the project directory:
error: `npm run build` ran, but none of dist, build, out, _site, .output/public, .vitepress/dist, docs/.vitepress/dist exists to deploy. Set build.output in forculus.json to the directory it writes.Name it in forculus.json:
{ "version": 1, "build": { "output": "site" } }forculus deploy --no-build serves what’s already built. If nothing is built yet, it tells you what to run:
error: Nothing here is built yet. Run `npm run build`. If it writes somewhere other than dist, build, out, _site, .output/public, .vitepress/dist, docs/.vitepress/dist, set build.output in forculus.json to that directory.Files that aren’t uploaded
Section titled “Files that aren’t uploaded”These are never uploaded:
- files and directories whose names start with a dot, except
.well-known, node_modules,- symlinks,
_worker.js,_worker.js.mapand_routes.json, as files or directories,forculus.jsonand a wrangler config (wrangler.jsonc,wrangler.jsonorwrangler.toml) at the top of the served directory, which are the deploy’s settings,- whatever
.assetsignorenames.
.assetsignore goes in the served directory, and it’s the same file wrangler reads. Each line is a name, a path, or a pattern with a leading or trailing *. Blank lines and lines starting with # are skipped.
*.mapdraftsnotes.txt_headers and _redirects at the top are read as rules even when .assetsignore names them, as wrangler reads them. SvelteKit’s adapter lists both in the .assetsignore it writes.
A file named like a credential stops the deploy, because everything in the directory is served to everyone the app is shared with. This includes dotfiles, even though they aren’t served:
error: .env looks like a credential, and everything here is served to everyone who can open the app. Remove it from the directory you're deploying.So does a key inside a file. The error names what it found:
error: assets/config.js has what looks like a Stripe key in it, and everything here is served to everyone who can open the app. Remove it, or store it with `forculus secret set`.We read every file again on our side when it’s uploaded, for that and the other kinds of key a deploy scan looks for.
TypeScript source stops the deploy too. No browser runs a .ts, .tsx, .mts or .cts file, so serving one would only show your code to everyone who can open the app. Build the site, deploy the app as a Worker, or name the file in .assetsignore:
error: src/worker.ts is TypeScript source, and a site serves every file as it is to everyone who can open the app. Build the site and deploy what the build writes, deploy it as a Worker with --kind=worker, or leave the file out with a line in .assetsignore.A .ts file that’s a video segment (an MPEG transport stream, as HLS uses) isn’t source, and is served as video/mp2t.
A page beside a Worker’s entry, such as index.html and a worker.js with export default { fetch }, could be either, so forculus deploy asks which you meant (see how a directory is deployed). A site deployed by a CLI older than that, or by your own client through the API, has its usual entry places read when it’s deployed: index.js, worker.js, the same in src/ and dist/, their .mjs forms, and the file a top-level package.json names as main or module. One that reads as a Worker’s code stops the deploy:
error: /worker.js reads as a Worker's code, and a site serves every file as it is to everyone who can open the app. Deploy it as a Worker with --kind=worker, or leave the file out of the site with a line in .assetsignore. The latest CLI asks which you meant.None of these checks looks at symlinks, at files .assetsignore names, or inside a directory that isn’t uploaded: one whose name starts with a dot (except .well-known), node_modules, _worker.js, _worker.js.map or _routes.json.
How files are served
Section titled “How files are served”A page is found with or without .html and a trailing slash. about.html is served at /about. about/index.html is served at /about/, and /about is redirected there with a 307. A request that names the file, like /about.html or /about/index.html, is redirected to the page’s own path with a 307.
A request for a path that has no file gets your index.html, so a front-end router works. If the site has a 404.html at its top, that page is served instead, with a 404 status. There are two exceptions, where index.html is still served:
- package.json depends on a router that runs in the browser, like React Router, Vue Router, TanStack Router or Angular’s router
- the
404.htmlis a copy ofindex.html
The deploy tells you when it serves 404.html.
On a public path, a missing file answers 404 with an empty body instead, never your index.html or 404.html. Only real files under the open pattern are served there.
Each file is served with the content type its extension implies. A file with an extension we don’t know is served as application/octet-stream, not as HTML.
Every response has Cache-Control: private. We remove public, s-maxage and proxy-revalidate directives, and drop CDN-Cache-Control and similar headers, so no shared cache keeps a page meant for signed-in people. A browser requests every file again on each page load, unless you let it keep hashed files.
_headers and _redirects
Section titled “_headers and _redirects”Both files are read from the top of the served directory as configuration. They aren’t served, and they’re checked when you deploy. They apply to the site’s files only: a redirect applies even where a file exists at that path, and neither affects responses from a Worker’s own code.
_headers sets response headers by path, in Cloudflare’s format:
/assets/* X-Content-Type-Options: nosniffIt may not set Set-Cookie, Service-Worker-Allowed, Content-Length, Transfer-Encoding, Connection, Upgrade, or any X-Forculus-* or CF-* header:
error: _headers sets Set-Cookie, which we set ourselves. Remove that line.It holds up to 100 rules, with lines of up to 2,000 characters.
_redirects has one rule per line, from to [status], with 302 when the status is left out:
/old-handbook /handbook 301/team /about 200A 200 serves another path’s content without redirecting. Write its target the way the page is reached: /about for about.html, and /about/, with the slash, for about/index.html. A target written any other way gets the same 307 a browser would, so the browser ends up at the page’s own path and the URL changes: /about.html goes to /about, /index.html to /, and /about to /about/ when the page is about/index.html.
A 200 only works for a path on the same app:
error: _redirects serves https://example.com/team with a 200, which only works for a path on this app. Use a redirect status, or fetch it in the app's own code.Single-page apps often include the rule /* /index.html 200. You don’t need it here, because a path with no file already gets your index.html. Cloudflare refuses that rule as a loop, so we drop it when we send your rules, and turn on the index.html fallback instead, even when the site has code of its own. If the app’s not_found_handling is something else, set by a framework’s assets.not_found_handling or by a site’s own 404.html, the deploy stops and says the two disagree.
Any other rule that sends a path to an index.html or /index under the path it matched is refused for the same reason. Cloudflare redirects /index.html to its directory, which the rule matches again:
error: _redirects has a rule that loops: /app/* /app/index.html 200. Cloudflare sends /app/index.html on to its directory, which the rule matches again. Point it at the directory itself, or remove it.A rule’s target is a path or an https:// URL. Each path is matched by one rule only, and a trailing # note is a comment. The file can hold up to 2,000 rules without a * or :name, and 100 with one. Once one rule has either, every rule after it counts toward the 100. Lines can be up to 1,000 characters. A refused rule stops the deploy before it gets a version number.
Let browsers keep hashed files
Section titled “Let browsers keep hashed files”By default, files are served with max-age=0, must-revalidate, so a browser checks each one again on every page load. The response is a 304 with no body, but each of those requests goes through the edge, which checks access and writes an audit entry before it answers. Each one also counts as a request.
Most builders put a hash of a file’s contents in its name, like index-4f9a1c2e.js, so a changed file gets a new name. A browser can safely keep those for a year. Set that in _headers:
/assets/* Cache-Control: max-age=31536000, immutableA page that loads 20 hashed files then makes one request on a return visit instead of 21. The edge adds private, as it does to every response, so only the visitor’s own browser keeps a copy.
| Built with | Hashed files |
|---|---|
| Vite, VitePress, Docusaurus | /assets/* |
| Astro | /_astro/* |
| Create React App | /static/* |
_headers must end up in the served directory. Builders copy their public directory there unchanged (Docusaurus copies static), so put the file in public.
Rules that match the same path are combined. If a /* rule also sets Cache-Control, its value is joined to this one, and a no-cache from it still makes the browser check every time. Don’t set Cache-Control in broader rules.
Don’t set this for HTML or any file without a hash in its name. Those keep their names from one deploy to the next, so the browser has to check for a new version. A file without a hash that you mark this way stays unchanged for up to a year in any browser that has it.
Removing someone’s access refuses their next request, but files already in their browser’s cache stay there. Use the long max-age for code, styles and images, not for files that hold data.
Limits
Section titled “Limits”| Files in one deploy | 20,000 |
| One file | 25 MiB |
| One version, all files | 256 MiB |
| Files stored for one app, across versions | 1 GiB |
| Storage for a workspace, shared with its apps’ databases | 1 GB on Free, 10 GB on Team, 100 GB on Business |
Every file uploaded for the app counts toward the stored totals until the app is deleted, even one that no version went live with.
A file over 25 MiB is refused before anything is uploaded:
error: video/tour.mp4 is over 25 MiB, the most a site file can be.So is a site whose files come to more than 256 MiB:
error: The files in ./dist come to 264 MiB, and a version can hold 256 MiB. Leave out what the site doesn't need with a line in .assetsignore.Limits lists the rest of each plan’s limits.
Values a build bakes in
Section titled “Values a build bakes in”A site runs no code on a request, so only its build reads the app’s environment variables. The build runs with them, and a front end bakes in the ones it exposes, such as VITE_*. A change takes effect on the next forculus deploy.
Next steps
Section titled “Next steps”- Share an app to let people in.
- Versions and rollback to put an earlier site back.