Skip to content

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.

Detection makes a directory a static site when either:

  • it has an index.html and no package.json, or
  • its package.json depends on a site builder (@docusaurus/core, vitepress, @11ty/eleventy, react-scripts, astro, vite or parcel) 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.

A plain directory of HTML goes up as it is:

Terminal window
forculus deploy acme/handbook ./handbook --create
a static site, served by Workers
because it has index.html and nothing to build
Deploying 14 files (212 KiB) from ./handbook
Uploaded 14 files.
Version 1 (6f1d0c9a2b47) is live.
https://handbook--acme.forculuslabs-apps.tech

A 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 build
Installing with `npm ci` ...
Building with `npm run build` ...
Deploying 9 files (148 KiB) from ./dist

The 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.

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.

These are never uploaded:

  • files and directories whose names start with a dot, except .well-known,
  • node_modules,
  • symlinks,
  • _worker.js, _worker.js.map and _routes.json, as files or directories,
  • forculus.json and a wrangler config (wrangler.jsonc, wrangler.json or wrangler.toml) at the top of the served directory, which are the deploy’s settings,
  • whatever .assetsignore names.

.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.

*.map
drafts
notes.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.

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.html is a copy of index.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.

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: nosniff

It 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 200

A 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.

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, immutable

A 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.

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.

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.