Deploying
Push to the self-hosted GitLab; CI builds, checks, and deploys to Cloudflare Workers static assets. There is nothing to run by hand.
The rule that shapes the pipeline
Production requires a passing gate. A preview does not.
A preview exists to look at work in progress, and a preview you cannot see because the check failed is a preview that is not doing its job. Production is the other way round: a failing check means no deployment exists to look at, and that is the intended pressure.
So check is allow_failure: true, which lets the preview job run regardless, and the production job re-asserts the gate itself before it deploys. GitLab's needs does not inherit allow_failure, so the production job runs cms check again rather than trusting the earlier one.
Setting a site up
Copy the template out of the engine and set two variables:
cp node_modules/@fivepaths/ai-cms/scaffold/.gitlab-ci.yml .
| Variable | Where | Needs |
|---|---|---|
| `CLOUDFLARE_API_TOKEN` | masked, protected | Workers Scripts:Edit, plus Zone:Edit on the zone if the site uses a custom domain |
| `CLOUDFLARE_ACCOUNT_ID` | masked, protected | The account the Worker belongs to |
Then a wrangler.jsonc beside it:
{
"name": "<site>",
"account_id": "42ba03104fef1c753538c263deb58c8e",
"compatibility_date": "2026-08-01",
"assets": { "directory": "./public", "not_found_handling": "404-page" },
"routes": [{ "pattern": "<hostname>", "custom_domain": true }],
"workers_dev": false,
"observability": { "enabled": true }
}
Pin account_id. More than one Cloudflare account exists on these machines, and an unpinned deploy has landed in the wrong one before. Both existing FivePaths repos pin it for that reason.
Why Workers static assets rather than Pages
It is what fivepaths-cdn and the live gtfs-media Worker already use, and Cloudflare has been directing new work there. Pages' built-in Git integration also connects only to github.com and gitlab.com, so a self-hosted GitLab could not drive it anyway: the pipeline would upload directly either way.
Cache headers, and one trap
Every generated asset is content-addressed: images, fonts, and both stylesheets. A change produces a new filename, so the old one can be cached forever, and the build writes a _headers saying so.
/assets/img/* Cache-Control: public, max-age=31536000, immutable /assets/fonts/* Cache-Control: public, max-age=31536000, immutable /assets/*.css Cache-Control: public, max-age=31536000, immutable
There is no /* catch-all, deliberately. Workers static assets applies every matching rule and concatenates the results, so a catch-all alongside a specific rule puts two Cache-Control values on one response and the first wins. Adding /* max-age=0 alongside the rules above served every image at max-age=0. Anything unlisted keeps the platform default, which revalidates, and that is right for HTML.
Stylesheets are hashed for a reason worth recording: they were not, and a CSS fix could not reach a browser that had already cached the file. The symptom was that the fix appeared not to work.
Caches
Two, and the second is the one that matters:
node_modules, keyed onpackage-lock.json..cms/media/and.cms/fonts/, keyed on.cms/media.lock.json. Image variants are content-addressed, so an unchanged image is never re-encoded. Adding an image changes the lock file and nothing else does.
At more than a few sites, move the media cache to an R2 bucket keyed by content hash so it is shared across runners and branches rather than per-runner. The paths are already content-addressed, so that is a change to the cache backend and not to the build.
Locally
env -u CLOUDFLARE_API_TOKEN -u CF_API_TOKEN npx wrangler deploy
The env vars are unset deliberately. A user API token generally lacks the zone permissions a custom domain needs, while the OAuth login has them, and wrangler prefers the token when both are present.
What is deployed now
gtfs-media.fivepaths.com serves the gtfs.media content migration: the same copy rebuilt through the engine, so the two can be compared side by side.
It is not gtfs.media. The live site is a separate Worker serving the hand-authored HTML, and nothing in this repo routes the apex domain.
The preview currently fails the gate, which is exactly the case the rule above exists for. The failures are migration artifacts: prose fallbacks that lost the button and table treatment their components would have supplied. cms check lists them.