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 platform Worker 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
jiayang 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.
Deployed version 1 (6f1d0c9a2b47).
https://handbook--acme.jiayang-apps.cloud

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 go up named by their contents, 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

public is never picked after a build. Builders copy it into their own output, and on a fresh checkout it’s often the only one there, so serving it would publish your sources instead of your site. With nothing to build, public counts, and after it the app’s directory itself.

A build that writes somewhere else stops the deploy rather than serving the project:

error: `npm run build` ran, but none of dist, build, out, _site, .output/public, .vitepress/dist, docs/.vitepress/dist is there to deploy. Name the directory it writes as build.output in jiayang.json.

Name it in jiayang.json:

{ "version": 1, "build": { "output": "site" } }

jiayang deploy --no-build serves what’s already built. With nothing built yet, it says what to run:

error: nothing here is built yet: run `npm run build`, or if it writes somewhere other than dist, build, out, _site, .output/public, .vitepress/dist, docs/.vitepress/dist, name that directory as build.output in jiayang.json.

These never go up:

  • 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;
  • whatever .assetsignore names.

.assetsignore sits in the served directory, 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

A file named like a credential stops the deploy, because everything in the directory is served to everyone the app is shared with. A dotfile counts, though it wouldn’t be served:

error: .env looks like a credential, and everything here is served to everyone who can open the app. Take it out of the directory you're deploying.

A key inside a file stops it too, and 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. Take it out, or keep it in `jiayang secret set`.

Neither check looks at symlinks, at what .assetsignore names, or inside a directory that’s left out: one whose name starts with a dot (except .well-known), node_modules, _worker.js, _worker.js.map or _routes.json.

  • Paths. A page is found with or without .html and a trailing slash: /about reaches about.html or about/index.html. A request that names the file, like /about.html, is redirected to /about with a 307.
  • Paths with no file. A request for a path that has no file gets your index.html, so a front-end router works. A site with a 404.html at its top gets that page instead, with a 404 status, unless its package.json depends on a router that runs in the browser (React Router, Vue Router, TanStack Router, Angular’s router and the like) or the 404.html is a copy of index.html. The deploy says when it does.
  • Content types. Each file is served with the type its extension implies. An extension the platform doesn’t know is served as application/octet-stream, never guessed as HTML.
  • Caching. Every response comes back Cache-Control: private. A public, s-maxage or proxy-revalidate directive is removed, and CDN-Cache-Control and similar headers are dropped, so no shared cache keeps a page meant for signed-in people. A browser asks for every file again on each page load, unless you let it keep hashed files.

Both are read from the top of the served directory as configuration, never served as files, and checked when you deploy. They apply to the site’s files: a redirect is followed even where a file is at that path, and neither touches what a Worker’s own code answers.

_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-Jiayang-* or CF-* header:

error: _headers sets Set-Cookie, which the platform decides. Take that line out.

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 in place rather than redirecting. Write its target the way the page is reached, without .html: Cloudflare sends a request for /about.html on to /about with a 307, and a 200 to /about.html gets that 307 too, so the browser ends up at /about after all. The same goes for /index.html, which is sent to /.

A 200 only works for a path on the same app:

error: _redirects serves https://example.com/team in place, which only works for a path on this app. Use a redirect status, or fetch it in the app's own code.

The rule single-page apps are often given, /* /index.html 200, isn’t needed: a path with no file already gets your index.html. Cloudflare refuses that rule as a loop, so the platform leaves it out when it sends your rules and turns on that fallback instead, even when the site has code of its own. With not_found_handling set to anything else in jiayang.json, 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 the same way, since Cloudflare sends /index.html on to its directory and the rule matches again:

error: _redirects has a rule Cloudflare refuses as a loop: /app/* /app/index.html 200. /app/index.html is sent on to its directory, which the rule matches again. Point it at the directory itself, or take it out.

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. It holds up to 2,000 rules without a * or :name and 100 with, counting every rule after the first one with either, on lines of up to 1,000 characters. A refused rule stops the deploy before it gets a version number.

By default, files come back with max-age=0, must-revalidate, so a browser asks about each one again on every page load. The answer 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, and each one 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 keep those for a year. Say so 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 person’s own browser keeps a copy.

Built with Hashed files
Vite, VitePress, Docusaurus /assets/*
Astro /_astro/*
Create React App /static/*

_headers has to end up in the directory that’s served. A builder copies its public directory there as it is (Docusaurus copies static), so that’s where it goes.

Rules that match the same path add up. A /* rule that also sets Cache-Control is joined to this one, and a no-cache from it still makes the browser ask every time. Keep Cache-Control off wider rules.

Leave HTML, and any file without a hash in its name, as it is. Those keep their names from one deploy to the next, so the browser has to ask to see a new one. A file marked this way without a hash stays as it was in a browser that has it, for up to a year.

Taking someone’s access away refuses their next request, but a file their browser already holds stays there. Keep 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 any version uploaded counts toward the stored totals until the app is deleted.

A file over 25 MiB is refused before anything uploads:

error: video/tour.mp4 is over 25 MiB, which is more than a site file can be

Limits lists the rest of each plan’s limits.

A site has no code running on a request, so its build is the only thing that reads the app’s environment variables. The build runs with them, and a front end bakes in the ones it exposes, such as VITE_*. Changing one takes effect on the next jiayang deploy.