Tech · Note
Hosting on Cloudflare Pages
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 (
mainhere). Pushes to any other branch make a preview deployment at its own*.pages.devaddress, 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.recan 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. _headersand_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
- Push the site to GitHub. Private is fine. Make sure
npm run buildworks from a clean clone. - 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.
- Pick the repo and set:
- Production branch:
main - Framework preset: None
- Build command:
npm ci && npm run build - Build output directory:
dist
- Production branch:
- Save and Deploy. The first build runs straight away. Check the log, then open the
*.pages.devaddress. - 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. - If you need a specific Node version, add a
NODE_VERSIONenvironment 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, butcontent/*,site/*,taxonomy.yml,site.yml,redirects.txtand 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.