Deploying to Cloudflare
This is a static site with one build step. Nothing runs on a server, there is no database, and no request ever leaves the visitor's browser once a page has loaded.
It deploys as a Worker with static assets — that is what the Cloudflare project was
created as, and npx wrangler deploy is its deploy command. Pages still works, and is
described further down; Cloudflare's current guidance points
new static projects at Workers.
The config that matters
wrangler.jsonc at the repository root — not linked here, because it is not copied into
dist/ and the link would 404 on the deployed site:
{
"name": "e-turo",
"compatibility_date": "2026-08-17",
"assets": {
"directory": "./dist",
"not_found_handling": "404-page"
}
}
Three lines of substance, and each is load-bearing.
directory: "./dist" is the whole deploy. Without this file, wrangler deploy runs its
non-interactive setup, guesses "directory": ".", and uploads the repository:
Detected Project Settings:
- Output Directory: .
✨ Read 2226 files from the assets directory /opt/buildhome/repo
✘ [ERROR] Asset too large.
We found a file /opt/buildhome/repo/node_modules/workerd/bin/workerd with a size of 144 MiB.
That is a real failure this repository hit. Note what it is not: the build ran fine
immediately before, emitting 50 files and 3.3 MiB. The 144 MiB file is workerd, which
npx wrangler had installed into node_modules seven seconds earlier — the deploy tool's
own install is what broke the deploy. The error names node_modules, so it reads like a
dependency problem, and nothing you change in tools/build.mjs can fix it.
not_found_handling: "404-page". The default is none, which returns a bodyless 404 and
never looks at dist/404.html. The build goes to some trouble to make that page styled and
resolvable from any depth; this is the line that makes Cloudflare serve it.
No main. An assets-only Worker is a supported shape. There is no script because there is
nothing to run.
There is no .assetsignore. Pages excludes node_modules, .git and .DS_Store
automatically and Workers does not, which is what an .assetsignore is normally for — but
that only matters when the asset directory is a working tree. dist/ contains exactly what
tools/build.mjs put there.
Build settings
In the Cloudflare dashboard, under Workers & Pages → e-turo → Settings → Build:
| Setting | Value |
|---|---|
| Build command | npm run build |
| Deploy command | npx wrangler deploy |
| Root directory | / |
| Node version | 24 — the current build image; pin it with a NODE_VERSION variable if that changes |
Leave the build output directory alone. It is wrangler.jsonc that decides what gets
uploaded now, not the dashboard field.
Production branch — point it at
main.mainnow holds the full site: it started as an empty root commit, but #1 merged everything into it. Note that the repository's default branch is still the feature branch, so Cloudflare will not preselectmainfor you — set it explicitly, or move the default branch first.
Deploying from a terminal instead
npm ci
npm run deploy # build, then npx wrangler deploy
What the build does
tools/build.mjs assembles dist/ from the repository. It is deliberately small — no bundler,
no minifier, no framework.
Copies the site.
assets/,grades/,docs/, plusindex.html,gallery.html,inventory.htmlandREADME.md.test/,tools/,node_modules/andpackage*.jsonare excluded, and the copy list is opt-in so nothing new leaks in by accident.Refreshes the
.htmlbeside every.mdby runningtools/render-docs.mjsfirst, so a deploy can never ship a page whose source has moved on.Those
.htmlfiles are committed to the repository, not generated only at deploy time. That means a clone opens in a browser with no build step — the same promise the artifacts make — and the gallery can preview the documents live rather than mocking up their contents. Served raw, a syllabus with 800+ table rows is either a download prompt or a wall of pipe characters; rendered, it uses the same design system as the rest of the site, carries the correctlang(filfor the Key Stage 1 grades,enelsewhere), and wraps wide tables so they scroll inside their own box rather than the page.The
.mdstays the source of truth. Each rendered page links back to it, andnpm testfails if any.htmlis stale.Rewrites any remaining
.mdlink to.htmlin the copied HTML. The repository's own pages already point at the rendered pages; this catchesREADME.md's links, which stay markdown so GitHub renders them natively.Emits
_headers,robots.txtand404.html.
Everything else ships exactly as it is in the repository. The artifacts are already plain files that run from a double-click, and the build does not touch them.
Headers
Generated into dist/_headers, which Workers static assets and Pages both honour. Two choices
worth explaining, because both look wrong at a glance:
X-Frame-Options: SAMEORIGIN, not DENY. gallery.html previews the
artifacts in same-origin iframes — the thumbnails are the real files running, not screenshots.
DENY would blank every one of them. The deploy test asserts this specifically, because it is
exactly the kind of hardening someone tightens later without realising what it breaks.
connect-src 'none'. Not defensive dressing. These pages genuinely make no network calls at
run time — the browser tests load each one with every request intercepted and failed. The CSP
states, at the edge, the promise the tests already verify.
The rest is ordinary: nosniff, a strict-origin referrer policy, no geolocation or camera, and
base-uri/form-action/object-src locked off. HTML revalidates on every request so updates
land immediately; assets/ caches for a day and the committed curriculum guide for a week.
Limits
Cloudflare allows 20,000 files and 25 MiB per file — the same numbers for Workers static
assets and for Pages. The current build is well inside both, and test/deploy.test.js fails if
either is ever breached:
238 files · 10.9 MiB total · largest 1.7 MiB (the DepEd curriculum guide PDF)
_headers allows 100 rules; the build emits 8.
Before you deploy
npm ci
npm run docs # re-render the committed .html from the .md
npm run inventory # re-count what ships, for the index, the gallery and the portal
npm run test:deploy # builds, then verifies the output
npm run preview # serves dist/ at http://localhost:8788
The portal's index of artifacts, the by-subject listing on inventory.html and the gallery's
file sizes are committed like the rendered .html is, and for the same reason — a clone opens
in a browser with no build step. All three come from tools/inventory.mjs; npm test runs it
with --check and fails if any of them has drifted from what is on disk.
test/deploy.test.js is not a smoke test. It checks the build output the way Cloudflare will
serve it:
- every local
hrefandsrcresolves to a file that exists — over 1 500 of them - no
.mdlink survives in any HTML page, apart from the deliberate "view the source" links - rendered pages declare the right language, have an
<h1>, and contain no unrendered markdown - the language invariants — that every Filipino/English pair links both ways, that each Key Stage 1 grade's five teacher documents plus its grade index have an English copy, that the artifacts shown to the children have none, and that the English lesson plans still carry their spoken script in Filipino. These encode a curriculum rule (MTB-MLE), so they are asserted rather than trusted — and counted per key stage, so adding a grade cannot quietly opt out
- the file-count and file-size limits, and the
_headersrule limits wrangler.jsoncpoints at./distand setsnot_found_handling— the failure above, caught before it reaches Cloudflare- then it serves
dist/over HTTP and drives it in Chromium, becausefile://andhttp://are different environments: same-origin rules, iframe access and fetch all change. It confirms the portal renders, the gallery's live previews load, syllabi render with their tables in both languages, the language switcher round-trips, a missing URL returns a styled 404, and nothing overflows at 320 px. Every grade's game and deck are driven separately, intest/browser-all.test.js. - no request reaches an off-origin host
That last set found three real bugs the first time it ran: a 20 px horizontal overflow on the
portal at 320 px, caused by a minmax(20rem, …) grid in the shared stylesheet that was wider
than the space available; a 404 page whose relative stylesheet path 404'd in turn when served
from a nested URL; and every .md link still pointing at markdown.
The one thing it cannot check is the dashboard. Build command, deploy command and production branch live in Cloudflare's settings, not in this repository.
After deploying
- Custom domain — your project → Domains & Routes → Add. Cloudflare issues the certificate.
- Preview deployments are created for non-production branches automatically. They are public URLs; if that matters, put Cloudflare Access in front of them.
- The site works offline once loaded. That is not a service worker — every artifact is a
single self-contained file. A teacher can also just download the repository and open
index.html, which is the intended fallback for schools with no connection at all.
Deploying to Pages instead
Pages serves this build unchanged — _headers, 404.html and all. Create the project with
Workers & Pages → Create → Pages → Connect to Git, then:
| Setting | Value |
|---|---|
| Framework preset | None |
| Build command | npm run build |
| Build output directory | dist |
| Root directory | / |
Or from a terminal:
npm ci && npm run build
npx wrangler pages deploy dist --project-name=e-turo
wrangler.jsonc is ignored by Pages, so leaving it in place costs nothing. Note that Pages
excludes node_modules from uploads on its own — the failure documented above is specific to
Workers, and is the reason this repository ships a config at all.