Deploying to Cloudflare
This is a static site with one build step and one very small Worker. There is no database, and
no request leaves the visitor's browser once a page has loaded. Every page, artifact and
thumbnail is served by Cloudflare's asset layer with no Worker invocation at all; the Worker
runs on /downloads/* and nowhere else, which is why a bug in it cannot take the site down.
Live at https://contents.e-turo.app.
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",
"main": "./worker/index.js",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"run_worker_first": ["/downloads/*"],
"not_found_handling": "404-page"
},
"analytics_engine_datasets": [
{ "binding": "DOWNLOADS", "dataset": "eturo_downloads" }
],
"observability": { "enabled": true }
}
Every line 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.
main and run_worker_first. There is a Worker now, and it does almost nothing — see
The Worker below. run_worker_first names the only paths it runs on. Without it,
Workers serves a matching asset before ever invoking the script, and the downloads would never
be counted; with it set to true instead of a path list, every request to the site would run
JavaScript, which is exactly what this project does not want.
binding: "ASSETS" is how the Worker hands a request back to the asset server. Responses
that come back through it still carry the headers from dist/_headers, so caching and CSP stay
described in one place — tools/build.mjs — rather than half in the build and half in a script.
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.html,inventory.jsonandREADME.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.Content-hashes everything under
assets/.assets/css/eturo.cssships asassets/css/eturo.1f8ba45c.css, and each of the 99 gallery thumbnails likewise. Every reference in every copied page is rewritten to match, whether it readsassets/…,../../assets/…or/assets/….Only
dist/is hashed — the committed sources keep their plain names, so a clone still opens in a browser with no build step. And nothing undergrades/is hashed, because those files are the deliverable:wall-chart.a3f1c204.pngis a worse thing to hand a teacher thanwall-chart.png.The point is the cache header.
immutablefor a year is only safe when the name changes with the bytes; without hashing, a corrected wall chart would sit in a teacher's browser for twelve months.test/deploy.test.jsfails the build if any reference to an un-hashed asset survives intodist/.Emits
_headers,_redirects,robots.txtand404.html._redirectsgives every grade a short URL —/g4and/g04both reach/grades/g04-math/, and/g4/*reaches into it. The wall charts get printed and pinned to a classroom wall, and a teacher standing in front of one can typecontents.e-turo.app/g4into a phone. Nobody types/grades/g04-math/. Cloudflare allows 2,000 static redirects and 100 with a splat; this uses 19 and 10.Never point a redirect at a
.htmlpath._redirectsis only half of the routing. Workers static assets defaults tohtml_handling: "auto-trailing-slash", which servesgallery.htmlat/galleryand redirects/gallery.htmlto/gallery. A rule sending/galleryback to/gallery.htmlcloses the circle, and since all 78 pages link togallery.html, the site becomes unreachable — browsers reportERR_TOO_MANY_REDIRECTS. This shipped in7d482cband took the site down until86ea8d6.The short URLs above are safe because they target directories, which
html_handlingleaves alone./galleryand/inventoryneed no rule at all: the platform already serves them.test/deploy.test.jscomposes both layers and chases every path to a fixed point, so a rule that loops fails the build rather than the site.Builds the download bundles into
dist/downloads/— twenty-four zips: each grade whole, each grade documents-only, one per audience, and one of everything. They are built here rather than committed, because a zip of files already in the repository is derived and would go stale the first time anyone edited a worksheet.Three rules, each of which an externally supplied bundle set got wrong. No archive contains another — theirs nested all twelve inside the master, so "download everything" delivered every file twice, and 424 KB of its 929 KB was archives inside an archive. Every entry is prefixed with its grade folder, so unzipping into Downloads/ does not scatter loose files. Each bundle carries its own
index.html, because a folder of files calledlesson-plan.htmlandworksheet.htmlwith no starting point is a puzzle.A fourth rule now: every link in a bundle either resolves inside it or points at the website. A bundle used to ship 1,312 dead links. Every generated document page carries
../../assets/css/eturo.css, and no bundle contained anassets/directory, so every downloaded syllabus, lesson plan and assessment rendered unstyled — against a syllabus that promises a teacher can "copy to a phone or USB stick and run offline at home". Each bundle now carries the stylesheet at its root, the../../is rewritten to../, and the content hash is stripped back off because it means nothing offline. The brand and gallery links, which pointed at a site root that is not in the archive, now point at the bundle's own listing. Whatever is genuinely not in the bundle — thedocs/tree, and the artifacts an audience slice leaves out — becomes an absolute link tocontents.e-turo.app, decided from the bundle's real contents rather than guessed at from the text of the link. 5,288 local links, 0 broken.The audience split is read from
inventory.json, whichtools/inventory.mjsgenerates from the same walk that produces every figure on the site, so a bundle cannot disagree with the page describing it.tools/zip.mjsis a dependency-free writer — the build runs on Cloudflare's builder, and a systemzipbinary that happens to exist locally is not a thing to make the deploy depend on. Timestamps are fixed, so the same input produces the same bytes.Writes
dist/downloads.json, the manifest naming each bundle's grade, audience, file count and size. The Worker reads it to label a download;test/deploy.test.jschecks it against the zips themselves.
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.
…except on four paths. /, /index.html, /gallery.html and /inventory.html get a
second rule allowing the Cloudflare Web Analytics beacon. Those four are a website; the 159
artifacts are documents, and a document should behave the same whether it was opened over
HTTPS or from a USB stick. A document that phones home does not.
A later rule does not replace an earlier one — it appends. Both CSPs are sent, the browser
enforces their intersection, and the strict policy wins. So the override silently did nothing
from 7d482cb until 86ea8d6, which added ! Content-Security-Policy to drop the inherited
header before setting the new one. The ! lines in _headers are load-bearing, not tidiness.
The split is still fail-safe. Losing an unset costs the portal its analytics; it cannot loosen
an artifact, because nothing unsets the strict policy on the paths that carry it. The failure
mode is "no analytics", never "a loosened artifact" — that part was right. What was wrong was
the assumption that the override worked at all, which reading _headers cannot tell you: both
rules look correct in isolation and the bug only exists in the response.
The rest is ordinary: nosniff, a strict-origin referrer policy, no geolocation or camera, and
base-uri/form-action/object-src locked off.
Caching
| Path | Policy | Why |
|---|---|---|
| everything | max-age=300, stale-while-revalidate=86400 |
second visit paints from cache and refreshes behind the page; an edit appears within five minutes |
/assets/* |
max-age=31536000, immutable |
content-hashed names, so the bytes cannot change under a cached URL |
/downloads/* |
max-age=3600, stale-while-revalidate=86400 |
rebuilt each deploy, but the names do not change |
/docs/research/source/* |
max-age=604800 |
the DepEd curriculum guide, 1.7 MB, unchanged since it was published |
Each of the last three rules opens with ! Cache-Control for the reason above. Without it the
catch-all's value is prepended and the header arrives as
max-age=300, …, max-age=31536000, immutable; a browser reads the first max-age it finds, so
/assets/* was cached for five minutes rather than a year and the content-hashing bought
nothing. test/deploy.test.js now fails on any rule that sets a header an earlier matching rule
already set without unsetting it first.
The old policy was max-age=0, must-revalidate on everything and a day on assets/. For a
teacher on prepaid mobile data reopening the same worksheet, that is a round trip per file, per
visit, on a link where latency is the whole cost.
Limits
Workers static assets allow 20,000 files on the free plan (100,000 on paid) and 25 MiB per
file on both. The current build is well inside them, and test/deploy.test.js fails if either
is breached:
422 files · 28.2 MiB total · largest 5.8 MiB (all-grades.zip)
_headers allows 100 rules; the build emits 13. _redirects allows 2,000 static and 100 with a
splat; the build emits 21 and 10.
The Worker
worker/index.js, about forty lines. It exists to answer one question the static site cannot:
which grades do teachers actually download? Nothing else in this repository can tell us, and
the answer should decide which learning area gets built second.
On a request to /downloads/*.zip it looks the bundle up in downloads.json, writes one
Analytics Engine data point, and returns env.ASSETS.fetch(request). That is the whole script.
What it deliberately does not do
An earlier design had the Worker assemble the zips on demand, so dist/ would not have to carry
15 MB of them. That is the wrong trade. Deflating all-grades.zip — six megabytes — costs on the
order of a hundred milliseconds of CPU, and the Workers free plan allows ten milliseconds per
request. The deploy is 30 MB against a limit of 25 MiB per file and 20,000 files; saving half of
it was never worth putting a CPU-bound code path in front of a site that currently cannot fail.
The zips are built once at deploy time, where CPU is free and the output is deterministic.
Analytics Engine
blobs: [bundle, kind, grade, audience, country]
doubles: [bytes, files]
indexes: [bundle]
Write-only from the Worker, queried later over the SQL API. No read path, no state, no cookies, and nothing for a visitor's browser to run — which is why it needs no CSP change and no consent banner, unlike the page beacon below.
Create the dataset by deploying: the binding in wrangler.jsonc is enough. If your account is
on a plan without Analytics Engine, delete the analytics_engine_datasets block — the Worker
checks for the binding and downloads work unchanged without it.
Three things that need a dashboard step
The repository ships all three ready. Each needs one action in Cloudflare that no config file can perform.
1. Email Routing — the feedback address
This is the one that matters most. The project's own open questions are "no artifact has been used with real learners" and "the Filipino translations need a Filipino-speaking teacher's review". Neither is answerable from inside this repository, and until now there was no way for a teacher who used one of these to say anything at all.
Every document artifact now carries a line in its footer, in its own language, linking to a mailto. Email Routing takes unlimited inbound mail free on both plans, so the address costs nothing to run.
In the dashboard, under e-turo.app → Email → Email Routing, add two custom addresses:
| Address | Goes to |
|---|---|
puna@e-turo.app |
your inbox — the Filipino artifacts link this one |
feedback@e-turo.app |
the same inbox — the English artifacts link this one |
Two addresses, not one, because the local part sits inside a sentence and this project does not mix languages in an artifact. Routing them to the same place also means the inbox tells you which language the writer chose.
Both are set in data/site.json. Change them there and re-run
npm run feedback; leave a language out and its artifacts simply carry no line.
A mailto rather than a form, deliberately: no JavaScript, no Turnstile, no consent banner, no
server, it does not violate connect-src 'none' because it is not a network call, and — the part
that matters for this audience — it still works when the file was copied to a USB stick and
opened with no connection. The mail client queues it until the teacher next has signal.
2. Web Analytics — is anyone arriving?
Free, cookieless, and needs no consent banner. Turn it on under Web Analytics → Add a site
for contents.e-turo.app with automatic setup; Cloudflare injects the beacon at the edge.
The CSP already allows it on the four portal paths. Without that allowance, dashboard-enabled RUM
would be silently blocked by connect-src 'none' and you would spend an afternoon wondering why
the graph is empty.
3. Custom domain
Your project → Domains & Routes → Add. Cloudflare issues the certificate. contents.e-turo.app
is already what data/site.json points at, and it is where the absolute links
inside the download bundles go.
Before you deploy
npm ci
npm run generate # every generated file, in the one order that works (see below)
npm run test:deploy # builds, then verifies the output
npm run preview # serves dist/ at http://localhost:8788
npm run generate runs six tools and the order is load-bearing, because each edits files the
next one measures or photographs:
render-docs → cards → hubs → feedback → thumbnails → inventory
feedback changes every footer, so it must run before thumbnails photographs the artifacts;
inventory reads file sizes off disk, so it must run last. Run them out of order and
npm test will tell you — every one of them has a --check mode and CI runs all six.
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
- 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.
What you lose. Because Pages ignores wrangler.jsonc, it ignores main too: worker/index.js
never runs and no download is ever counted. _headers and _redirects still apply, so the
caching, the CSP split and the short URLs all work — only the Analytics Engine half of the
picture goes missing. Web Analytics, being a dashboard beacon rather than a Worker, is unaffected.