Hosting
Where a web app (ts_kind=web-app, TypeScript kinds) and a
static site (site) go live. A web app always deploys to a Cloudflare Worker. A site
deploys to GitHub Pages, or to a Worker with site_host=cloudflare. Either way, each pull
request gets a preview on its own Worker.
Static site
Answer site (under customise) to give any project a static page beside its code: a playground, a demo or a report. GitHub Pages serves it from main, or a Cloudflare Worker with site_host=cloudflare, and each pull request gets a preview. The page's readers are the project's users, so it stays small: the tool in the first screen, details in disclosures.
The two choices differ only in what is in site/ and how just site builds it:
site=plain:site/index.html,site/style.cssandsite/main.mjs, a working tool page. Plain HTML, CSS and an ES module, no build step or framework.just sitecopiessite/to_site/. For a page that needs a build, replace the recipe's body with yours, such asuv run site/build.py --out _site, and add the setup it needs (uv, Rust, Node) beforejust sitein thebuildjob ofsite.yml.site=astro: a small Astro project.package.jsonpins astro at the version incopier.yml,src/layouts/Base.astrohas the same header and footer, andsrc/pages/holdsindex.astroand a Markdown page,about.md. Runjust site-synconce and commit thesite/pnpm-lock.yamlit writes.just siteinstalls from that lockfile and runsastro buildintosite/dist/. Thebuildjob sets up Node and pnpm fromsite/package.json, so the repo needs no TypeScript stack. Pages serves the site athttps://<user>.github.io/<repo>/, sositeandbaseinastro.config.mjsmatch it. On a Worker (site_host=cloudflare) the site is served from/:siteis the Worker's address and there is nobase.
just site-serve serves either one at http://localhost:4321/, under /<repo>/ for an Astro site on Pages, as Pages does.
The starter files are yours: an update leaves them alone. An Astro site's package.json stays the template's, and Dependabot or Renovate bumps it. Both choices also render:
.github/workflows/site.yml. Thebuildjob runsjust siteon every pull request and push tomainand uploads the result, which is gitignored. Thedeployjob publishes that build to GitHub Pages on a push tomain. It is the only job withpages: write. On a Worker, it deploys to the Worker instead.- The
previewjob insite.yml, withwrangler.site.jsonc,.github/scripts/deploy-worker.sh,preview-retire.ymlandpreview-retire.sh: the web app's own preview pieces (Pull request previews).
A GitHub Pages site is public even when the repo is private, and a private repo needs a paid GitHub plan for Pages. Until Pages is on, the deploy job skips with a notice, and just repo-settings warns and goes on where GitHub refuses Pages. The one-time setup is in Cloudflare setup.
The plain tool page
site=plain starts from a small working tool that formats JSON. It shows the layout and the states, and you replace the job with your own. Top to bottom, the page has:
- A header with the project name,
project_descriptionas the one sentence that names the job, and a Docs link to the README. Without a description, a TODO marks the spot. - The workspace: an input with an Open a file button, and beside it the result with one action, Copy result. They sit side by side from 48rem and stack on a phone. The result updates 250 ms after the visitor stops typing, and a label shows its state: Sample, Empty, Needs a fix or Ready. An error shows under the input, and Copy result stays off until the input is valid, so nobody copies a stale result.
- Two disclosures: Settings, with an indent choice, and Use it elsewhere, a TODO for command-line or CI use of the same job.
- A footer with one privacy line, then the licence, the source and the issue tracker. A private repo's page has no links into the repo.
To make it your tool, change convert() at the top of main.mjs, the labels in index.html and the colours at the top of style.css. convert() returns the result text, or throws an error whose message the page shows under the input.
The privacy line says that what the visitor types never leaves the page. The page's Content-Security-Policy has no connect-src, so the browser blocks any request the script makes, and the line stays true. A page that calls an API, such as a worker route, adds that origin to connect-src and changes the line to say what it sends.
For accessibility, the page has a skip link, labelled controls, a visible focus ring, targets at least 44 px tall, and screen reader announcements for a state change or the copy outcome. It follows the system's dark mode and reduced-motion setting, and keeps the primary action visible in forced colours.
Hosting on a Worker
A web app, and a site with site_host=cloudflare, deploy to a Worker on every push to main:
- A web app's
productionjob ints.ymlruns after the checks pass. It deploys the build the checks ran on to the Worker<slug>, with the pinned wrangler, athttps://<slug>.<subdomain>.workers.dev. - A site's
deployjob replaces Pages with the Worker<slug>-site, from the samewrangler.site.jsoncthe previews use. It deploys the build job's artifact, one run at a time. A repo with a web app keepssite_urlfor the app, and the site stays at its workers.dev address.
Both jobs run in the production GitHub environment, which just repo-settings limits to main. A preview job that holds the same Cloudflare token can still deploy over production (see Cloudflare setup). Without CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID, the job skips with a notice. Static-asset requests are free and unlimited on the Workers Free plan.
The starter astro.config.mjs is the project's, so switching an existing Astro site from Pages leaves it alone: set site to the new address and delete base.
Custom domains
site_url is the public address of what deploys to a Worker. Give a host alone, without a path: a Worker serves a whole host. When the host is not *.workers.dev, it is a custom domain: the production job runs wrangler deploy --domain <host>, which attaches it to the Worker and creates its DNS record. The domain needs a zone in the same Cloudflare account, without a CNAME record for that host, and the production token needs Zone > Workers Routes: Edit for that zone. The domain is not in the wrangler config, because previews deploy that config under their own names and must not take it. just repo-about sets it as the repo's homepage.
Worker script
worker adds a Worker script in front of the static assets, for server code such as an API. main in the wrangler config points at it, the ASSETS binding lets it hand a request to the files, and run_worker_first: ["/api/*"] sends only /api/* to it first. Every other request is served from the files without running the script, so it stays free; each request the script handles counts against the Workers plan. The starter has one route, /api/health, answers 404 for the rest of /api/, and passes anything else to the files.
- A web app gets
worker/index.ts, with its ownworker/tsconfig.jsonthat swaps the DOM types for@cloudflare/workers-types.pnpm run typecheckchecks both, and Vitest runsworker/index.test.tsin Node against a stubASSETS.pnpm devruns Vite alone;pnpm exec wrangler devserves the last build and the script together. - A site gets
site/worker/index.mjs, plain JavaScript with no TypeScript stack needed.just siterunssite/worker/index.test.mjswithnode --testfirst, and a plain site's build leavesworker/out. wrangler bundles the script on every preview and production deploy, so a script that does not bundle fails the pull request's preview.
A plain site on a Worker also gets site/_headers, with the same security headers as a web app's public/_headers (a Content-Security-Policy with frame-ancestors 'none', X-Content-Type-Options: nosniff and HSTS). The page's meta tag cannot set frame-ancestors, so keep the header's policy in step with it; a site on Pages cannot send headers.
The script and its test are the project's after the first render. A binding beyond ASSETS (KV, D1, a secret) also reaches every preview: read decision 0001 first.
Moving an app off Cloudflare Pages
An app made before this template deployed from CI was deployed by Cloudflare Pages. Check first that the account has no Worker named <slug>: the first deploy overwrites it. Expect a short gap while the domain moves from Pages to the Worker. To move the app:
- Delete any Pages
public/_redirectsrule with status200!: Workers refuses it with error100324. Merge thecopier update, then runjust repo-settingsand add both secrets to the newproductionenvironment. - The next push to
maindeploys the Worker. Check the app at its workers.dev address. - Remove the custom domain from the Pages project. With
site_urlset to it, the next production deploy attaches it to the Worker. - Delete the Pages project, now that the Worker serves production.
Without a custom domain, the app's address changes: set the new one with copier update --data site_url=https://<slug>.<subdomain>.workers.dev.
Pull request previews
Each pull request that is open, not a draft, not opened by a bot such as Renovate and not from chore/copier-update gets its own Cloudflare Worker:
- A web app's
previewjob ints.ymldeployswrangler.jsoncas<slug>-pr-<number>, athttps://<slug>-pr-<number>.<subdomain>.workers.dev. - A site's
previewjob insite.ymldeploys the build job's artifact, not a fresh build, as<slug>-site-pr-<number>. The-sitein the name keeps it apart from a web app's preview in a repo that has both. For a site on Pages, the preview serves the build under/<repo>/as Pages does, with a redirect from/. For a site on a Worker, it serves the build from/, as production does.
A bot's push does not start a preview, so after one the preview keeps the previous build until a person pushes.
The job summary and the pull request's deployment show the link. Previews are public, and they send X-Robots-Tag: noindex. With a custom domain, production sends it only on the duplicate workers.dev origin.
When a pull request closes or turns draft, preview-retire.yml deletes its Worker through the Cloudflare API. A daily run lists the account's preview Workers and deletes those whose pull request is not open and ready, in case an event was missed. When it cannot read a pull request, it keeps that Worker and fails. To retire previews by hand, run that workflow with a list of pull request numbers, or with the list empty to run the sweep.
The daily sweep finds preview Workers by name alone. In one Cloudflare account, a repo foo with a site and a repo foo-site with a web app would share the names foo-site-pr-<number>: rename one of them if that happens.
Fork pull requests and repos without CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID skip the job with a notice. A site on Pages still deploys.
Cloudflare setup
One-time setup for previews and Worker hosting:
- Run
just repo-settings. For a web app or a site on a Worker, it creates thepreviewandproductionenvironments in GitHub (or create them under Settings > Environments, withproductionlimited tomain). For a site on Pages, it creates thepreviewenvironment and sets GitHub Pages to deploy from Actions (Settings > Pages > Source: GitHub Actions). - Create a custom Cloudflare API token with the permission Account > Workers Scripts: Edit. Add it to each of those environments as the secret
CLOUDFLARE_API_TOKEN, and add your account ID asCLOUDFLARE_ACCOUNT_ID.
The preview job runs the pull request's code with the preview environment's Cloudflare token. If both environments hold one account-wide token, anyone who can push a branch can deploy over the production Worker. To keep them apart, give previews a token from a second Cloudflare account (why).
The first deploy creates each Worker, so there is nothing to set up in Cloudflare except the token. The account needs a workers.dev subdomain. A web app's wrangler is an exact devDependency in package.json (why), and the dependency bot bumps it.
Social card
A web app, a site or a docs site renders docs/og-card.html, a 1200x630 poster with the project name, project_description and the site's address, and og:image, og:image:width, og:image:height, og:image:alt and twitter:card tags in the page head that point at it. A docs site sets them from docs-overrides/main.html on every page, with og:title, og:description and og:url too, reading og_image from zensical.toml. The alt text is the name and the description. The file is yours: an update adds it when it is missing and leaves an edited one alone.
A web app's index.html and a site's page are yours after the first render too, so an update does not add the tags to them. Add them to the page head by hand, with your own address and alt text:
<meta property="og:image" content="https://example.org/og.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Project name: what it does" />
<meta name="twitter:card" content="summary_large_image" />
A docs site needs the settings in Documentation site added to its zensical.toml, custom_dir and og_image among them.
Run just og-card to render it to a PNG, and commit the PNG: public/og.png for a web app, site/og.png for site=plain and site/public/og.png for site=astro and docs/assets/og.png for a docs site. A web app renders with the Playwright in its devDependencies (pnpm exec playwright install chromium once). A site or a docs site runs pnpm dlx playwright@<pinned version>, so it needs no Node package of its own, but pnpm and Node must be installed, even in a Python-only project. The recipe also writes the PNG's first 8 hex of SHA-256 into the page's og:image URL (og_image in zensical.toml for a docs site) (og.png?v=<hash>), because Facebook, Slack, Discord and iMessage cache a preview by image URL and would keep the old card after a redraw. Commit the page with the PNG. The head tags point at a file that does not exist until you run it.
Replace the card's colours and font with the project's own, then check it before you commit:
- View it at 300 px wide. The name and the one-line job must still read.
- Crop it to the centre 630 px square. Small square thumbnails (LinkedIn posts, X
summary, WhatsApp) cut it that way, so the name and any mark must survive there. The job line and the address only need to stay 60 px clear of every edge, since no thumbnail is big enough to read them.
Keep the text at 36 px or more at 1200 px wide, one idea on the card and no label above the name. Never use a screenshot of the app: it is illegible at preview size. Keep the job line and the address on the card even though og:description and the alt text say the same, because X shows only the image, LinkedIn hides the description, and a shared screenshot carries no preview text. One real result or short example may go under the job line to prove it. A repo with several of them has one card source, docs/og-card.html, and just og-card renders the same image into each place. The card prints the web app's address, else the site's, else the docs site's. Every page head that names a card gets it: the app, the site and each docs page. The docs site's copy is in docs/assets/, so the build publishes the card source (og-card.html) beside it.