DEV Community

Othmane ETTAIB
Othmane ETTAIB

Posted on Originally published at indiecore.net

Deploying a static site to Cloudflare Workers

Originally published on indiecore.net.

I moved this site off a hosted blogging platform onto Cloudflare, with GitHub Actions doing
the building and Cloudflare doing the serving. It costs nothing, deploys in about two and a
half minutes, and refuses to publish anything that fails its checks.

Getting there took longer than it should have. Here is the setup, and the five things that
tripped me up — none of which are obvious from the documentation.

The shape of it

git push
   └─ GitHub Actions
        ├─ build          generate the site
        ├─ verify         dead links, missing images, bad metadata, broken redirects
        ├─ Lighthouse     fail if performance/accessibility/SEO drop below budget
        └─ deploy         upload to Cloudflare
Enter fullscreen mode Exit fullscreen mode

The important part is that deploy depends on the checks. A broken build never reaches
the internet. Pull requests get a preview URL; merges to main go live.

The triggers and permissions that make that safe:

ci-cd.yml — triggers and permissions

name: CI/CD

# Build and verify every change; deploy previews for PRs and production from main.
# Deploy jobs depend on the quality gates, so nothing ships unverified.

on:
  push:
    branches: [main]
    # The SEO watch ledger is machine-written, is never part of dist/, and is
    # committed daily. Deploying the site again for it would be pure noise — and
    # would re-trigger the SEO ping through workflow_run every single day.
    paths-ignore:
      - '_source/seo-watch.json'
      # hub/ publishes to a different repo entirely and never reaches dist/,
      # so a change to it alone has nothing to deploy.
      - 'hub/**'
  pull_request:
    branches: [main]
  workflow_dispatch:

# Least privilege by default; individual jobs elevate only what they need.
permissions:
  contents: read

# Supersede in-flight runs for a branch, but never interrupt a production deploy.
concurrency:
  group: ci-cd-${{ github.ref }}
  cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}

env:
  # wrangler ships as a pinned devDependency; never phone home from CI
  WRANGLER_SEND_METRICS: "false"
Enter fullscreen mode Exit fullscreen mode

permissions: contents: read at the top means every job starts with the minimum, and only
the one that comments on pull requests gets more. The concurrency block cancels superseded
runs on a branch but never interrupts a production deploy.

Trap 1: "Upload assets" gives you a Worker, not Pages

Cloudflare has two products that host static sites: Pages, the older one, and Workers,
the current one. Their dashboard now puts the Upload assets button under Workers.

So you follow a guide that says "create a Pages project", you click the obvious button, and
you end up with a Worker. Your site is live at something.workers.dev, and the
something.pages.dev address every tutorial tells you to visit returns:

DNS_PROBE_FINISHED_NXDOMAIN
Enter fullscreen mode Exit fullscreen mode

Nothing is broken. You simply never created a Pages project.

Both products work. Workers is the one Cloudflare is investing in, and it supports
everything a static site needs — I verified each of these on a live deployment:

Feature Workers static assets
_redirects yes
_headers yes
Custom 404 page yes
Custom domains yes, if the nameservers are Cloudflare's

Pick Workers and stop worrying about pages.dev.

Trap 2: the API token needs the right template

To deploy from CI you create a scoped API token. The obvious move is to build a custom one
with a single permission. Don't.

A Worker deploy needs several permissions working together — Workers Scripts, plus read
access to account and user details. Miss one and you get:

Authentication error [code: 10000]
Enter fullscreen mode Exit fullscreen mode

which tells you nothing about which permission is absent. Use the built-in
"Edit Cloudflare Workers" template instead; it ticks exactly the right boxes. Scope it to
your account and your zone, and give it an expiry.

If you already made a Cloudflare Pages · Edit token because you thought you were using
Pages, it will not deploy a Worker.

Test it before wiring it into CI:

export CLOUDFLARE_API_TOKEN=...
npx wrangler whoami
Enter fullscreen mode Exit fullscreen mode

Ten seconds, and it prints the permissions it actually has.

Trap 3: wrangler turns things off when you're not looking

This one cost me the most time. Once you add a wrangler.jsonc, that file becomes the
complete description of your Worker — and anything absent from it is treated as disabled.

Deploy with a minimal config and you get:

▲ [WARNING] Because 'workers_dev' is not in your Wrangler file,
  it will be disabled for this deployment by default.

▲ [WARNING] Because your 'workers.dev' route is disabled and your
  'preview_urls' setting is not in your Wrangler file, Preview URLs
  will be disabled for this deployment by default.
Enter fullscreen mode Exit fullscreen mode

Two warnings among the normal output, and easy to skim past. The result: my staging URL
started returning 404, and per-version preview URLs stopped being generated — which would
have silently broken pull request previews, since the CI job reads the preview URL out of
wrangler versions upload.

This is the job that publishes a preview per pull request, and reads the URL back out of
wrangler's output:

ci-cd.yml — preview job

  preview:
    name: Deploy preview
    needs: [build, lighthouse]
    # Skip where secrets are unavailable rather than failing:
    #  - fork PRs never receive them
    #  - Dependabot runs use a separate secret store, so repository secrets are
    #    empty even though the branch lives in this repo
    if: >-
      github.event_name == 'pull_request' &&
      github.event.pull_request.head.repo.full_name == github.repository &&
      github.actor != 'dependabot[bot]'
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      contents: read
      pull-requests: write
    environment:
      name: preview
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - name: Checkout
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Node
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version-file: .nvmrc
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Download site
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: dist
          path: dist

      - name: Upload preview version
        id: deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
        run: |
          set -o pipefail
          npx wrangler versions upload 2>&1 | tee upload.log
          url=$(grep -oE 'https://[a-z0-9-]+\.[a-z0-9-]+\.workers\.dev' upload.log | head -1)
          if [ -z "$url" ]; then
            echo "::warning::could not parse a preview URL from wrangler output"
          else
            echo "url=$url" >> "$GITHUB_OUTPUT"
          fi

      - name: Comment preview URL
        if: steps.deploy.outputs.url != ''
        uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
        env:
          PREVIEW_URL: ${{ steps.deploy.outputs.url }}
        with:
          script: |
            const marker = '<!-- cf-preview -->';
            const body = `${marker}\n**Preview deployed** → ${process.env.PREVIEW_URL}`;
            const { owner, repo } = context.repo;
            const issue_number = context.issue.number;
            const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
            const mine = comments.data.find(c => c.body?.startsWith(marker));
            if (mine) await github.rest.issues.updateComment({ owner, repo, comment_id: mine.id, body });
            else await github.rest.issues.createComment({ owner, repo, issue_number, body });
Enter fullscreen mode Exit fullscreen mode

A dashboard-created Worker has both on by default. Writing a config turns them off. Say so
explicitly — this is the whole config this site runs on:

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "my-site",
  "compatibility_date": "2026-08-26",

  // Keep the *.workers.dev URL alive as a staging target, and keep per-version
  // Preview URLs on  the CI preview job publishes with `wrangler versions
  // upload` and reads the preview URL from its output. Wrangler disables both
  // by default when they are absent from this file.
  "workers_dev": true,
  "preview_urls": true,

  // Static-assets-only Worker: no server code, Cloudflare serves dist/ directly.
  // _headers and _redirects inside dist/ are honoured natively.
  "assets": {
    "directory": "./dist",
    // /about/ -> about/index.html, and strip .html from URLs
    "html_handling": "auto-trailing-slash",
    // unknown paths get dist/404.html with a real 404 status
    "not_found_handling": "404-page"
  },

  // Attaching the domains here rather than in the dashboard: `wrangler deploy`
  // creates the custom domains and their DNS records, and re-asserts them on
  // every deploy. custom_domain:true means Cloudflare manages the record and
  // the TLS certificate for each hostname.
  "routes": [
    { "pattern": "www.example.com", "custom_domain": true },
    { "pattern": "example.com", "custom_domain": true }
  ]
}
Enter fullscreen mode Exit fullscreen mode

not_found_handling: "404-page" serves your 404.html with a real 404 status.
auto-trailing-slash makes /about/ resolve to about/index.html.

Trap 4: the Custom Domain dialog matches zones, not hostnames

Adding www.example.com through the dashboard gave me:

No zones match www.example.com.
Enter fullscreen mode Exit fullscreen mode

I own the domain. www is a subdomain of it, covered by the same zone. But the dialog was
matching against zone names — typing example.com found it, typing www.example.com found
nothing.

The error offers a helpful-looking link to "add the domain to Cloudflare". Do not click
it.
That wizard creates a separate zone for www.example.com and offers to import DNS
records into it. You would end up with two overlapping zones and a genuine mess.

The fix is to skip the dashboard. Those routes entries above create the custom domains and
their DNS records on deploy, and re-assert them every time — so the configuration can't drift.

One thing to know: attaching fails while an old record is in the way.

Hostname 'example.com' already has externally managed DNS records (A, CNAME, etc).
Delete them first or try a different hostname. [code: 100117]
Enter fullscreen mode Exit fullscreen mode

Delete the old records for that hostname, then deploy again.

Trap 5: overlapping _headers rules concatenate

Cache headers looked right in my config:

/assets/images/*
  Cache-Control: public, max-age=31536000, immutable

/assets/*
  Cache-Control: public, max-age=86400
Enter fullscreen mode Exit fullscreen mode

What actually came back over the wire:

cache-control: public, max-age=86400, public, max-age=31536000, immutable
Enter fullscreen mode Exit fullscreen mode

Every matching rule is applied and the values are joined. An image matches both, so it
gets two max-age values in one header. Browsers take the first — 1 day — and the year-long
immutable cache silently does nothing.

I only caught it because I curled the response headers after deploying. Make the rules
non-overlapping:

/assets/images/*
  Cache-Control: public, max-age=31536000, immutable

/assets/fonts/*
  Cache-Control: public, max-age=31536000, immutable

/assets/app.js
  Cache-Control: public, max-age=86400
Enter fullscreen mode Exit fullscreen mode

If your domain also handles email

This is the part that makes people nervous, so it's worth being plain about it.

Your domain has two independent groups of records:

  • Email lives in MX and TXT records (SPF, DKIM)
  • Website lives in A and CNAME records

Mail servers only read the first group. Browsers only read the second. Repointing your
website cannot affect mail delivery.
The only way to break email is to delete an MX or
TXT record by hand.

So: change records one at a time, never use a bulk delete, and if a screen offers to remove
MX or TXT records, say no. Take a zone export first — Cloudflare has an Export records
button — and check afterwards:

dig +short MX example.com
dig +short TXT example.com | grep spf
Enter fullscreen mode Exit fullscreen mode

Then send a real test email. DNS looking correct is not the same as mail flowing.

One deployment system, not two

The Worker's settings page offers to connect a Git repository. If you are deploying from
GitHub Actions, leave it empty.

Connecting it enables Cloudflare's own build system, and you end up with two pipelines
publishing the same site: yours, which runs checks, and Cloudflare's, which doesn't. They
race, and whichever finishes last wins. A build your pipeline correctly refused to ship can
get published anyway.

GitHub pushes to Cloudflare. Cloudflare never pulls.

Worth doing while you're in there

  • Verify the deploy actually landed. Change something with a visible fingerprint — a header, a version string — and curl for it. "The deploy succeeded" and "the new build is serving" are different claims.
  • Redirect old URLs, don't drop them. A _redirects file with 301 lines preserves search ranking and keeps any link you've published elsewhere working.
  • Budget your Lighthouse scores in CI. A number that only gets checked when you remember to check it will drift.

None of this is exotic. It is a static site on free hosting. But the gap between "it
deployed" and "it deployed correctly, and nothing else broke" is where the evening goes.


Originally published at Deploying a static site to Cloudflare Workers.

The code is on GitHub: Both files, ready to copy

Top comments (0)