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

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. 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 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. Emits _headers, robots.txt and 404.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:

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.