E E-turo
← Back to the gallery View the Markdown source

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. main now 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 preselect main for 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.

  1. Copies the site. assets/, grades/, docs/, plus index.html, gallery.html, inventory.html, inventory.json and README.md. test/, tools/, node_modules/ and package*.json are excluded, and the copy list is opt-in so nothing new leaks in by accident.

  2. Refreshes the .html beside every .md by running tools/render-docs.mjs first, so a deploy can never ship a page whose source has moved on.

    Those .html files 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 correct lang (fil for the Key Stage 1 grades, en elsewhere), and wraps wide tables so they scroll inside their own box rather than the page.

    The .md stays the source of truth. Each rendered page links back to it, and npm test fails if any .html is stale.

  3. Rewrites any remaining .md link to .html in the copied HTML. The repository's own pages already point at the rendered pages; this catches README.md's links, which stay markdown so GitHub renders them natively.

  4. Content-hashes everything under assets/. assets/css/eturo.css ships as assets/css/eturo.1f8ba45c.css, and each of the 99 gallery thumbnails likewise. Every reference in every copied page is rewritten to match, whether it reads assets/…, ../../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 under grades/ is hashed, because those files are the deliverable: wall-chart.a3f1c204.png is a worse thing to hand a teacher than wall-chart.png.

    The point is the cache header. immutable for 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.js fails the build if any reference to an un-hashed asset survives into dist/.

  5. Emits _headers, _redirects, robots.txt and 404.html.

    _redirects gives every grade a short URL — /g4 and /g04 both 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 type contents.e-turo.app/g4 into 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 .html path. _redirects is only half of the routing. Workers static assets defaults to html_handling: "auto-trailing-slash", which serves gallery.html at /gallery and redirects /gallery.html to /gallery. A rule sending /gallery back to /gallery.html closes the circle, and since all 78 pages link to gallery.html, the site becomes unreachable — browsers report ERR_TOO_MANY_REDIRECTS. This shipped in 7d482cb and took the site down until 86ea8d6.

    The short URLs above are safe because they target directories, which html_handling leaves alone. /gallery and /inventory need no rule at all: the platform already serves them. test/deploy.test.js composes both layers and chases every path to a fixed point, so a rule that loops fails the build rather than the site.

  6. 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 called lesson-plan.html and worksheet.html with 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 an assets/ 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 — the docs/ tree, and the artifacts an audience slice leaves out — becomes an absolute link to contents.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, which tools/inventory.mjs generates from the same walk that produces every figure on the site, so a bundle cannot disagree with the page describing it. tools/zip.mjs is a dependency-free writer — the build runs on Cloudflare's builder, and a system zip binary 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.

  7. 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.js checks 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:

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


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.