Deploy on git push
After Go live, deploys should happen without you: a push to main builds and
publishes, and an approval in /admin/ does the same. Two routes exist, and you should pick exactly
one — running both is how a build from an old commit overwrites a newer deploy.
| Workers Builds | GitHub Actions | |
|---|---|---|
| Where it runs | Cloudflare’s builder, connected to your repository | your repository’s Actions |
| Tokens you manage | none — Cloudflare mints its own | three repository secrets |
| What an approval triggers | a Deploy Hook (providers.rebuild: 'deploy-hook') |
a repository_dispatch event (providers.rebuild: 'github-dispatch') |
| Choose it when | you want the least setup | you already deploy everything from Actions, or you want tests to run before every deploy |
-
Connect the repository.
Cloudflare dashboard → Workers & Pages → your Worker → Settings → Build → Connect, and fill in:
Field Value Repository / branch your repository, mainRoot directory the directory holding package.jsonandwrangler.jsonc—/for a flat copyBuild command pnpm buildDeploy command pnpm exec wrangler deploy— the pinned wrangler, not whatevernpxfetches todayAPI token leave empty; Cloudflare generates one -
Add the build variables.
Under Variables and secrets on the same page. For a site that still builds from Markdown,
PNPM_VERSION=10.12.4is enough. Once the site reads the database, addLISTINGS_SOURCE=d1,CLOUDFLARE_ACCOUNT_ID,D1_DATABASE_ID, andD1_READ_TOKENas a secret — see Environment and secrets. -
Push to
main.Cloudflare builds and deploys. Other branches get a preview version and never touch production.
Versions are pinned by files already in the repository: .nvmrc, packageManager in
package.json, and wrangler in devDependencies. A build takes about a minute.
Free-plan quota: 3,000 build minutes a month, 20 minutes per build, and one concurrent build per account. The concurrency limit is account-wide, so a second connected project makes builds queue — including the build an approval triggers.
A deploy.yml ships with the starter and is off until you set a repository variable. It shows
as skipped otherwise, so the file costs nothing and a repository cannot end up with two things
deploying the same Worker.
-
Create three Cloudflare tokens and store them as repository secrets.
Secret Scope Can it… CLOUDFLARE_API_TOKENEdit Cloudflare Workers deploy code to your domain CLOUDFLARE_D1_WRITE_TOKEND1 Edit only change the schema D1_READ_TOKEND1 Read only neither — and it is the only one the build itself gets Three is the point: a leaked build log cannot deploy, and a leaked deploy token cannot drop a table.
-
Switch the workflow on.
Terminal gh variable set USE_GITHUB_DEPLOY --body true -
Push to
main.The workflow runs
pnpm check,pnpm testand the build before it deploys, so a failing test never reaches production.
If you set providers.rebuild: 'github-dispatch', this workflow is also what receives the rebuild
event when you approve a listing — without it, approving publishes nothing.
Verify
Section titled “Verify”| Where | You should see | |
|---|---|---|
| The build ran | Workers Builds → Deployments, or the Actions run | a green build, about a minute long |
| It built from the right source | the build log | [zerodirs-files] listings: N from ./src/content/listings or [zerodirs-d1] D1: N approved listings — whichever you intended |
| It is live | https://<your domain>/ |
the change you pushed |
Custom domains
Section titled “Custom domains”-
Add the domain to your Cloudflare account as a zone.
-
Workers & Pages → your Worker → Settings → Domains & Routes → Add → Custom domain. Cloudflare issues the certificate and creates the DNS record.
pnpm cf:setupdoes this step for you whensite.urlis on a zone the account owns. -
Set
site.urlto that domain and deploy again. Canonicals, the sitemap and the feeds are built from it, so until the next build they still point at the old host.
The <name>.workers.dev URL keeps working. Turn it off in the same panel if you would rather it did
not — and do turn it off if you put the admin routes behind Cloudflare Access, because
*.workers.dev bypasses a zone-level Access policy. See Security.
Every dynamic URL ends in a slash
Section titled “Every dynamic URL ends in a slash”trailingSlash: 'always' is enforced before route matching, so a request to a URL without one earns
a 301 (GET) or 308 (everything else). That is invisible for a browser and fatal for a webhook: Stripe
records the redirect as a failed delivery. Register the endpoint with the slash.
https://example.com/api/stripe/webhook/ ← correcthttps://example.com/api/stripe/webhook ← 308, recorded as a delivery failureThe same applies to the Deploy Hook URL, the checkout redirect, the status-page links and every
admin route. Nothing in the starter constructs a URL by hand: src/lib/paths.ts is the only place
one is built, and a unit test asserts every dynamic path it produces ends in /.
Rollback
Section titled “Rollback”Every deploy is a version. Workers & Pages → your Worker → Deployments → pick the previous one → Rollback. Nothing local is needed.
This is also why the build is strict rather than forgiving. An unknown category, an orphan page under
seo.strictLinks, or a file count over 90% of the plan limit all stop the build — and the version
already serving keeps serving. A build that fails is a deploy that did not happen, which is the
outcome you want.