Skip to content

Claude Code Production EngineeringStep 5 of 8

Deploy a Claude Code Website With GitHub Actions and Cloudflare: Complete CI/CD Workflow

Continuous deployment is usually sold on speed, which is the least interesting thing about it. The reason to build one is that it is the only place a check runs whether or not anyone remembered.

Claude Code Guides: Deploy With GitHub Actions and Cloudflare. Three stacked job cards on the left connected by lines to a vertical gate; two connections are stopped at the gate by a crossed circle and one passes straight through.

What a pipeline is actually for

Continuous deployment is usually sold on speed. That is the least interesting thing about it.

The reason to build one, especially when an agent is writing a meaningful share of the code, is that it is the only place a check runs whether or not anyone remembered. A rule in a project context file is a request. A step in a workflow is a gate. When Claude Code produces forty files in an afternoon, the difference between those two things is the difference between a site you can ship and one you hope is fine.

The second reason is that it makes deploys boring, and boring deploys are what let you ship small changes often, which is what makes rollback cheap. A pipeline that only runs on a monthly release is a pipeline that has taught everyone to fear releases.

This guide builds the whole thing: repository, branches, validation, preview, production, smoke tests, rollback. It uses GitHub Actions and Cloudflare, and the shape transfers to other hosts with different deploy steps.

The lifecycle, end to end

Every stage below either produces evidence or is a gate. Stages that do neither should not exist.

Stage What it proves Where it runs
Local work with Claude Code Nothing yet — this is where mistakes are made cheaply Your machine
Branch and commit The change is isolated and revertible Your machine
Pull request Someone will read it before it ships GitHub
Validation workflow Lint, types, tests, structural checks pass Actions
Build The thing compiles from a clean checkout, not just on your laptop Actions
Preview deploy The built artefact renders on a real URL Cloudflare
Merge A human approved it GitHub
Production deploy The same artefact, same pipeline Cloudflare
Smoke test The live site is actually serving what you shipped Actions, post-deploy
Monitoring You find out about the failure before a customer does Ongoing
Rollback The last stage is reversible On demand

Repository preparation

Four things before any workflow exists, because a pipeline built on a repository that is not ready produces confusing failures.

A lockfile, committed. CI must install from it with the frozen path — npm ci, not npm install. Without this, the build that passes in CI is not the build you tested.

Node version pinned in a file the tooling reads. An .nvmrc or the engines field, and the workflow reads the same source. A version mismatch between local and CI is a whole afternoon.

Scripts that CI can call. If the only way to run your tests is a command you remember, CI cannot run them. Every gate needs an npm run entry.

A .gitignore that covers .env, build output and editor files. Then verify it works rather than assuming: git check-ignore -v .env should print the rule that matches.

Prepare this repository for CI. Do not add a workflow yet.

1. Confirm a lockfile is committed and the install script uses the
   frozen/locked path. Show me the file and the script.
2. Confirm the Node version is pinned in a file, and say which.
3. List every quality command that exists — lint, typecheck, test,
   build — and for each, run it and show the output. If one does not
   exist, say so rather than inventing a script name.
4. Run `git check-ignore -v .env` and show the result.
5. Search the tracked tree for credential patterns and report anything
   found, redacted.

Report only. Change nothing.

Branch strategy

For a website, trunk-based with short-lived branches is almost always right. Long-lived branches accumulate merge risk, and a marketing site rarely needs a develop branch, a release branch and a hotfix ceremony.

Branch Rule
main Always deployable. Protected. No direct pushes, including from an agent.
Feature branches Short-lived, one concern, merged by pull request.
Tags Optional, but a tag per production deploy makes rollback a checkout rather than an archaeology exercise.

Protect main in the repository settings: require a pull request, require the validation workflow to pass, and dismiss stale approvals when new commits land. That last one matters with an agent in the loop, because "approved, then three more commits appeared" is a common shape.

Put the rule in your project context file too, so the agent branches without being asked:

## Git
- Never commit directly to `main`. Branch first, always.
- One concern per branch. If the change grew, say so and stop.
- Do not force-push a branch that has an open pull request.

Better still, enforce it with a hook rather than a request, so it holds regardless of whether the instruction was read. The hooks guide covers the mechanics.

Secrets, and the rules that are not negotiable

Two secrets are needed for Cloudflare deployment, and CI is non-interactive so there is no browser login to fall back on:

  • CLOUDFLARE_API_TOKEN — scoped to the minimum needed, which for a static site deploy is Workers Scripts edit on the one account. Not a global API key.
  • CLOUDFLARE_ACCOUNT_ID — not secret in any meaningful sense, but conventionally stored alongside.

The rules, all of which have been learned expensively by somebody:

Never in the repository. Not in a config file, not in a comment, not gitignored. Gitignore protects against one specific accident and nothing else.

Never echoed. GitHub masks known secret values in logs, but only exact matches — a token that has been transformed, base64-encoded or interpolated into a longer string is not masked. Do not print things that might contain one.

Scoped tokens, and rotate on exposure. A secret that was ever public is public. Removing it from a file does not unpublish it; only rotation does.

Restrict what runs on pull requests from forks. Workflows triggered by pull_request from a fork do not receive secrets by default, and that default is correct. Do not defeat it with pull_request_target unless you have thought very carefully about running untrusted code with a token in the environment.

The validation workflow

One workflow, running on every pull request and on pushes to main. It should be fast enough that nobody is tempted to skip it.

Browser tests belong in this workflow too, as a gate that can fail. Playwright testing with Claude Code builds a small suite for a marketing site — form validation, a mocked submission, call-to-action destinations, mobile navigation — and includes the GitHub Actions job that runs it.

name: Validate

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: validate-${{ github.ref }}
  cancel-in-progress: true

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: 'npm'

      - name: Install
        run: npm ci

      - name: Lint
        run: npm run lint

      - name: Types
        run: npm run typecheck

      - name: Test
        run: npm test

      - name: Build
        run: npm run build

      - name: Upload build output
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 7

Four details are doing real work here.

permissions: contents: read narrows the default token. Workflows get more permission than they need unless you say otherwise, and a validation job needs none of it.

concurrency with cancel-in-progress stops five queued runs when someone pushes five times in a minute.

node-version-file reads the pin from the repository rather than duplicating it in YAML, so the two cannot drift.

Uploading the build output means the deploy job can use the same artefact that passed validation rather than building again. A pipeline that builds twice is a pipeline that can deploy something it did not test.

Link, accessibility and structural checks in CI

This is where a website pipeline earns more than a generic one, because the checks that matter most for a site are cheap and structural.

      - name: Every internal link resolves
        run: npm run check:links

      - name: Head tags present on every page
        run: |
          missing=0
          while IFS= read -r f; do
            grep -q '<title>' "$f" || { echo "no title: $f"; missing=1; }
            grep -q 'rel="canonical"' "$f" || { echo "no canonical: $f"; missing=1; }
            [ "$(grep -c '<h1' "$f")" = "1" ] || { echo "h1 count: $f"; missing=1; }
          done < <(find dist -name '*.html')
          exit $missing

      - name: No credentials in the tree
        run: |
          ! grep -rIlE "AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{36}|-----BEGIN [A-Z ]*PRIVATE KEY-----" \
            --exclude-dir=.git . | grep -q .

Those run against dist/, the built output, not the source. That distinction is the whole point: a link checker pointed at source files is checking something that is not what visitors receive, and it will happily report zero broken links forever.

Which raises the rule that matters more than any workflow syntax on this page. Prove every check can fail before you trust it passing. Break a link deliberately, push, watch the job go red. Then fix it and watch it go green. A check that has never failed is a line in a log that says PASS for reasons nobody has established — and a check pointed at the wrong directory will say PASS forever.

Preview deployments

A preview URL per pull request is the single highest-value addition to a website pipeline. Reviewing a diff tells you the code changed; opening the preview tells you the page is right.

With Workers, a preview is a deploy to a differently-named environment or a versioned upload that returns its own URL. The pattern that works with the least ceremony is a per-branch Worker name:

  preview:
    if: github.event_name == 'pull_request'
    needs: validate
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist }

      - name: Deploy preview
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: deploy --name acme-site-pr-${{ github.event.number }}

Two operational notes. Preview deployments accumulate, so add a cleanup step on pull request close or you will collect a Worker per pull request forever. And previews should not be indexable — serve X-Robots-Tag: noindex or a robots.txt disallow on preview hostnames, because a preview URL that gets crawled is a duplicate of your site with a different address.

Cloudflare: Workers or Pages

This is the part of Cloudflare deployment advice most likely to be stale, and getting it wrong sends you down a path that still works but is no longer the recommended one.

Cloudflare's own documentation now states that Workers supports most Pages use cases, offers a broader feature set, is Cloudflare's primary platform for building applications, and that new projects should start with Workers. Pages is not being switched off and existing projects continue to work — it is simply no longer where you should begin.

For a statically built site that means Workers Static Assets. The configuration is a wrangler.jsonc at the repository root naming the directory your build produced:

{
  "name": "acme-site",
  "compatibility_date": "2026-09-01",
  "assets": {
    "directory": "./dist",
    "not_found_handling": "404-page"
  }
}

not_found_handling deserves a moment. The default behaviour for a missing path on some static hosts is to serve the index page with a 200, which turns every typo and every stale inbound link into an indexable duplicate of your homepage. Setting it to serve a real 404 page with a real 404 status is a one-line fix for a genuine SEO defect. Use single-page-application only if you are genuinely deploying an SPA that routes on the client.

Deploying uploads the Worker and the assets in a single operation, so there is no separate asset-upload step to get out of sync.

The deploy workflow

name: Deploy

on:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: deploy-production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://example.com
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version-file: '.nvmrc'
          cache: 'npm'

      - run: npm ci
      - run: npm run build

      - name: Deploy to Cloudflare
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

      - name: Smoke test
        run: ./scripts/smoke.sh https://example.com

cancel-in-progress: false on the deploy concurrency group is deliberate and the opposite of the validation job. Cancelling a deploy halfway is how you get a half-deployed site; queueing them is correct.

The environment block gives you a deployment record in GitHub and, more usefully, a place to attach a required reviewer. If you want a human to approve every production deploy, that is a repository setting rather than a code change.

cloudflare/wrangler-action@v3 is the supported action and defaults to Wrangler v4. Pin the Wrangler version explicitly with wranglerVersion if you need reproducibility across a major bump.

When the site is not fully static

Everything above assumes a build that produces a directory of files. Plenty of sites need one or two routes that run at request time — a form handler, a search endpoint, a page personalised by a cookie — and it is worth being deliberate about that, because the usual mistake is to convert the whole site to server rendering for the sake of two routes.

Two approaches, and the first is almost always better.

Keep the site static and add an endpoint. A Worker route alongside your assets handles the dynamic path; everything else stays prerendered and served from cache. The site keeps its performance characteristics and only the route that needs a server has one.

Switch the framework to server or hybrid output. Necessary when many routes need request-time data. It changes the build target, adds an adapter, and means every page now costs a request-time invocation. Do this when the site genuinely calls for it, not because one contact form does.

Whichever you choose, the pipeline barely changes — the build produces something different, the deploy command is the same. What does change is your smoke test, which should now assert that the dynamic route behaves rather than just that it returns 200:

check "search endpoint returns JSON" \
  'curl -s "$BASE/api/search?q=test" | head -c1 | grep -q "[[{]"'
check "form handler rejects a GET" \
  '[ "$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/contact")" = "405" ]'

That second check is the kind people skip. An endpoint that accepts any method is a real defect and it never shows up in a browser, because a browser sends the method the form told it to.

Migrating an existing Pages project

If you already deploy to Cloudflare Pages, nothing is broken and there is no deadline. Pages continues to work. The reason to move is that Workers is where the feature development is going, and a migration is cheaper on a small site now than on a larger one later.

The shape of the move for a static site:

  1. Add a wrangler.jsonc with assets.directory pointing at your existing build output. Nothing about the build changes.
  2. Deploy to a new Worker name first and test it on the workers.dev URL. Do not move the custom domain yet.
  3. Compare the two deployments with the same smoke test. Pay particular attention to 404 handling and redirects, which are configured differently between the two products and are the most likely thing to differ.
  4. Move any _headers and _redirects rules to their Workers equivalents, and verify each one with curl -sI rather than assuming the translation was faithful.
  5. Move the custom domain once the Worker is serving correctly, then re-run the smoke test against the real hostname.
  6. Leave the Pages project in place for a few days before deleting it, so rollback is a DNS change rather than a rebuild.

The step people skip is the third, and redirects are where it bites. A redirect rule that silently stopped applying does not produce an error; it produces a 404 on a URL that used to work, discovered weeks later in Search Console.

Environment variables and bindings

Distinguish three things that people routinely conflate:

Kind Where it lives Visible to
Build-time public value Workflow env, or committed config Anyone — it is compiled into the output
Build-time secret GitHub secret, used only during the build CI. Never let it reach the bundle.
Runtime secret A Wrangler secret on the Worker The Worker at request time, never the client

The mistake worth naming: a build tool that inlines environment variables will happily inline a secret if the variable is named in a way the tool considers public. Once built, it is in the JavaScript your visitors download. Check the built output rather than the source:

grep -rE "sk_live|api[_-]?key|secret" dist/ | head

Runtime secrets go to the Worker, not the repository: npx wrangler secret put API_TOKEN. They are then readable in the Worker's environment and nowhere else.

Custom domains and DNS

Attach the custom domain to the Worker in the Cloudflare dashboard or via configuration; the DNS record is created for you when the zone is on Cloudflare. Three things to decide once and write down:

  • Apex or www, and a permanent redirect from the other. Serving both is a duplicate-content problem that no canonical tag fully fixes.
  • HTTPS everywhere, with HSTS once you are confident. HSTS is difficult to undo; enable it after you are sure every subdomain is ready.
  • Trailing slash behaviour, decided and consistent. Whichever you pick, the other must redirect with a 301, and your internal links should use the canonical form so no visitor takes the redirect.

Verify all three from the command line rather than in a browser, because a browser hides redirect chains:

curl -sIL https://example.com | grep -E '^HTTP|^location'
curl -sI http://example.com | head -1
curl -sI https://www.example.com | head -1

Caching

Static assets should be immutable and cached hard; HTML should not be. The distinction is the difference between a fast site and one that serves last week's page.

Hashed asset filenames — which every modern build tool produces — make this simple: the filename changes when the content changes, so the file can be cached for a year safely. HTML files keep the same URL forever, so they must revalidate.

Getting this backwards is a common and painful failure: cache the HTML aggressively and your deploy appears not to have happened, for hours, for everyone except you.

After any cache configuration change, verify from outside the network you deployed from, and check the actual headers:

curl -sI https://example.com/ | grep -iE 'cache-control|cf-cache-status|age'
curl -sI https://example.com/_astro/index.abc123.js | grep -i cache-control

Smoke testing production

The deploy step reporting success means the upload succeeded. It does not mean the site is right. A smoke test is a small script that asks the live URL a handful of questions with unambiguous answers, and it belongs in the pipeline immediately after deploy.

#!/usr/bin/env bash
# scripts/smoke.sh — fail the deploy if production is not serving what we shipped.
set -euo pipefail
BASE="${1:?usage: smoke.sh https://example.com}"
fail=0
check() { if eval "$2"; then echo "  ok   $1"; else echo "  FAIL $1"; fail=1; fi }

check "homepage returns 200" \
  '[ "$(curl -s -o /dev/null -w "%{http_code}" "$BASE/")" = "200" ]'
check "homepage has a title" \
  'curl -s "$BASE/" | grep -q "<title>[^<]"'
check "canonical is absolute" \
  'curl -s "$BASE/" | grep -qE "rel=\"canonical\" href=\"https://"'
check "unknown path returns 404" \
  '[ "$(curl -s -o /dev/null -w "%{http_code}" "$BASE/definitely-not-a-page")" = "404" ]'
check "robots.txt is served" \
  '[ "$(curl -s -o /dev/null -w "%{http_code}" "$BASE/robots.txt")" = "200" ]'
check "sitemap is served" \
  '[ "$(curl -s -o /dev/null -w "%{http_code}" "$BASE/sitemap-index.xml")" = "200" ]'
check "no accidental noindex" \
  '! curl -s "$BASE/" | grep -q "name=\"robots\" content=\"noindex"'

exit $fail

Seven checks, a few seconds, and each one has caught a real production problem somewhere. The 404 check is the most valuable: a misconfigured fallback serving 200 for every unknown path is invisible in a browser and catastrophic for indexing.

As with everything else — break each of these deliberately once and confirm the script goes red. A smoke test that cannot fail is decoration.

Rollback

Two mechanisms, and you want both.

A rollback nobody has rehearsed is a rollback that exists on paper. The maintenance checklist keeps rollback readiness on the monthly list next to the backup restore, for the same reason: both are only known to work once they have been done.

Revert and redeploy. git revert the merge commit, push, and the pipeline ships the previous state through the same gates. Slower, and always correct.

Roll back the deployment. Wrangler keeps previous versions and can roll back to one, which is faster and does not require a build. Use it to stop the bleeding, then revert in git so the repository and production agree — a rolled-back deployment with an un-reverted main is a trap for the next person who ships.

Whichever you use, decide in advance what triggers it. "The smoke test failed" is a good trigger. "Something looks odd" leads to an hour of investigation while the site is broken.

Monitoring

The minimum that is genuinely worth having on a small site:

  • Uptime check on the homepage and one deep page, alerting somewhere you will actually see it.
  • A weekly crawl for broken links and missing head tags — the same checks CI runs, pointed at production, because production can break without a deploy when an external link rots or a plugin updates.
  • Search Console coverage checks, which is where you find out that something is noindexed days before you would have noticed the traffic drop. The Search Console workflow covers the API and its traps.
  • Real-user Core Web Vitals, because INP cannot be measured in a lab — it depends on what people actually click.

CLAUDE.md for a deploying repository

# CLAUDE.md

## Deployment
- Production is Cloudflare Workers, deployed from `.github/workflows/deploy.yml`
  on push to `main`. Nothing deploys any other way.
- Never run `wrangler deploy` locally against production.
- `wrangler.jsonc` `assets.directory` must match the build output
  directory. If you change one, change the other and say so.

## Git
- Never commit to `main`. Branch, pull request, wait for Validate.
- One concern per branch.

## Secrets
- Read from the environment, never from a file in this repository.
- Never echo a value that might contain one, including in debug output.
- If a secret is ever printed or committed, say so immediately. It has
  to be rotated; removing the line does not undo it.

## Checks
- Every check must be able to fail. Before adding one, break the thing
  it checks, confirm it goes red, restore, confirm it goes quiet.
- Checks run against `dist/`, the built output. A check pointed at
  source is checking something visitors never receive.

## Definition of done
- Validate workflow green.
- Preview URL opened and the changed pages actually looked at.
- `scripts/smoke.sh` passes against the preview.
- No new dependency without saying what it costs in shipped bytes.

Troubleshooting

Symptom Usual cause
Works locally, fails in CI npm install locally versus npm ci in CI, or a different Node version. Pin both.
Deploy succeeds, site unchanged Cache. Check cf-cache-status and age headers before assuming the deploy failed.
Authentication error in the deploy step Token scope, not token validity. A token that can read cannot deploy.
404 on every route except the homepage assets.directory pointing at the wrong folder, or the build output changed location.
Every unknown path returns the homepage with a 200 not_found_handling set to SPA mode on a content site.
Secrets missing on a pull request from a fork Working as designed. Do not defeat it with pull_request_target.
Two deploys raced and the older one won Missing concurrency group on the deploy job.

The mistakes that cost the most

Building twice. If validation builds and deploy builds again, you can ship an artefact that was never tested. Pass the artefact between jobs.

Checks that run against source. The built output is what visitors get. Everything structural should be checked there.

Caching HTML. The deploy appears not to have happened, and only for other people.

A workflow with default permissions. The token is more powerful than the job needs. Set permissions explicitly on every workflow.

Indexable previews. A per-pull-request URL that gets crawled is a duplicate of your site.

No smoke test. "Deploy succeeded" and "the site works" are different claims, and only one of them was verified.

A rollback you have never performed. The first time you roll back should not be during an incident. Do it once, deliberately, on a quiet afternoon.

The pipeline is not the interesting part of the work and that is exactly the point. Once every change goes through the same gates, deploying stops being an event, and the question stops being "is this safe to ship" and becomes "did the checks pass". That is a much better question, provided the checks are ones you have watched fail.

Read these next

See how this fits into Claude Code Production Engineering

Continue your learning path

  1. Next

    A dozen browser tests that catch what breaks silently on a marketing site, written with Claude Code, run, broken on p...

  2. Then

    The question that matters is narrow: where does inference run, and who is billed. Everything else — policy, standards...

  3. Going deeper

    Mention @claude and it implements changes. Give it a prompt and it runs on every pull request without anyone asking. ...

The whole sequence: Claude Code Production Engineering

Free download

The CLAUDE.md Starter Kit, free

Four working CLAUDE.md files you can drop into a project today, plus the one-page checklist for what belongs in one and how to tell whether yours is actually working.

  • CLAUDE.md for a static marketing site
  • CLAUDE.md for a web application, with security and migration rules
  • CLAUDE.md for a Shopify theme, including the gotchas that cost hours
  • CLAUDE.md for a shared package in a monorepo
  • A one-page checklist, and how to test the file is actually working
What are you working on?

The download appears here as soon as you submit. I will also email you when there is a new guide worth reading. No fixed schedule, no selling your address, unsubscribe from any email. See the privacy policy.