bla.ckmo.re

Tech · Note

Hosting on Cloudflare Pages

27 Oct 2026

Part of the how this site is built series. The site is a folder of static files, so hosting it should be boring. Cloudflare Pages makes it so: it watches the GitHub repo, runs the build on every push to main, and serves the result from Cloudflare’s network.

Why Cloudflare Pages

  • Free for a site like this: builds, bandwidth and custom domains.
  • Builds from a private repo. The repo also holds plans, scripts and the admin code, so I want it private. GitHub Pages needs a paid plan to publish from a private repo.
  • No server to look after, and HTTPS certificates are handled for you.
  • Same account as the admin. The phone admin runs on Cloudflare Workers, so everything sits under one login. Only the hosting and the admin are Cloudflare-specific; the content and the generator would move anywhere.

Key concepts

These are the terms that confused me while setting it up.

  • Pages project: one site. It is linked to one Git repo and has a build command and an output folder. Each project gets a free address, <project>.pages.dev. Mine got a short suffix (knowledgebase-<xyz>.pages.dev) because the plain name was already taken.
  • Production branch: the branch whose builds go live (main here). Pushes to any other branch make a preview deployment at its own *.pages.dev address, which is handy for trying things out.
  • Zone: a domain whose DNS Cloudflare runs, after you point the domain’s nameservers at Cloudflare. If your DNS is somewhere else, the domain is not a zone in your account.
  • Custom domain with external DNS: a subdomain such as bla.ckmo.re can still be served by Pages without being a zone. You add a CNAME record at your DNS provider pointing at <project>.pages.dev, and Cloudflare issues the certificate. This only works for subdomains; a bare domain (ckmo.re) needs to be a zone.
  • _headers and _redirects: plain text files in the output folder that Pages reads to set response headers and redirects. No server config needed.

My settings

Read straight from the project:

Setting Value
Source GitHub, jackblackmore/knowledgebase (private)
Production branch main
Build command npm ci && npm run build
Output directory dist
Root directory repo root
Build image v3 (Node 22, which matches "engines": { "node": ">=22" })
Environment variables none
Preview deployments all branches
Build watch paths everything (*)
Custom domain bla.ckmo.re, by CNAME from my DNS provider

npm ci installs exactly what is in package-lock.json, which is what you want on a build server. The build validates content first, so a bad tag or a missing cover fails the deploy and the previous version stays live.

Setting it up

  1. Push the site to GitHub. Private is fine. Make sure npm run build works from a clean clone.
  2. In the Cloudflare dashboard, go to Workers & Pages → Create → Pages → Connect to Git. Install the Cloudflare GitHub app when asked, and give it access to just that repo.
  3. Pick the repo and set:
    • Production branch: main
    • Framework preset: None
    • Build command: npm ci && npm run build
    • Build output directory: dist
  4. Save and Deploy. The first build runs straight away. Check the log, then open the *.pages.dev address.
  5. Custom domain: in the project, go to Custom domains → Set up a custom domain and enter the subdomain. If the domain is not on Cloudflare, it shows you a CNAME to add. Add it at your DNS provider (bla → <project>.pages.dev), then wait for it to verify. The certificate is issued once it does.
  6. If you need a specific Node version, add a NODE_VERSION environment variable (e.g. 22) under Settings → Variables and Secrets. I didn’t need to.

From then on, a push to main is a deploy. The Deployments tab shows each build with its commit message, and any deployment can be rolled back to with one click.

Cache headers

The generator writes dist/_headers:

/assets/*
  Cache-Control: public, max-age=31536000, immutable
/pagefind/fragment/*
  Cache-Control: public, max-age=31536000, immutable
/pagefind/index/*
  Cache-Control: public, max-age=31536000, immutable
/*.webp
  Cache-Control: public, max-age=86400
  • CSS and JS are linked with a content hash (style.css?v=…), and Pagefind names its files by content hash, so browsers can cache them for a year. A change gets a new URL.
  • Images are not hashed, so they get a day. A regenerated cover shows up by tomorrow at the latest.
  • HTML gets no header at all, so Pages’ default applies and edits show up straight away.

Rules that match the same path are merged, so it is easiest to keep them from overlapping.

Redirects from the old site

The old WordPress posts lived at /<slug>/, and the new ones are at /<section>/<slug>/. redirects.txt in the repo holds one line per old URL:

/egg-custard/ /food/egg-custard/

The build turns it into dist/_redirects (301 by default). It also fails if a target page does not exist, or if a source is a real page that the redirect would hide, since Pages applies redirects before serving files. Pages allows up to 2,000 plain redirects like these.

Things I’d tighten later

  • Build watch paths are set to everything, so a change in admin/ (which Pages doesn’t serve) still triggers a site build. Harmless, but content/*, site/*, taxonomy.yml, site.yml, redirects.txt and the package files would be enough.
  • Preview deployments run for every branch. That is fine while I’m the only one pushing. Set it to “None” if branch builds ever get noisy.

Related