zudo-cloudflare-wisdom
GitHub repository

Type to search...

to open search from anywhere

Production Deploy

GitHub Actions workflow for main branch deploys

Standard Workflow

The production deploy workflow triggers on push to main:

name: Production Deploy

on:
  push:
    branches:
      - main

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

permissions:
  contents: read

Do NOT Cancel In-Progress Deploys

Set cancel-in-progress: false for the job that actually deploys to production. If two pushes land quickly, you want both to complete in order, not have the first one killed mid-deploy.

Why Cancellation Mid-Deploy Is Risky

Cancelling a workflow run doesn't just skip the remaining steps — GitHub Actions interrupts whatever step is currently executing too. If that happens while wrangler deploy is mid-upload, Cloudflare can end up serving a deployment assembled from two different builds: some assets from the run that got killed, some from whichever run completes next. This has shown up as a real incident more than once, not just a theoretical risk — a rapid second push cancels the first deploy job partway through the upload step, and the site serves a broken mix of old and new assets until the next deploy fixes it.

This is specifically a concern for the deploy job. cancel-in-progress: true is fine — even desirable — for build or preview-deploy jobs, where the worst outcome of a cancellation is a wasted build that reruns cleanly next time. Keep production deploy on its own concurrency.group, separate from any build/preview jobs that use true.

Build Job

jobs:
  build-site:
    name: Build Site
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Checkout repository
        uses: actions/checkout@v5
        with:
          fetch-depth: 0

      - name: Setup pnpm
        uses: pnpm/action-setup@v4

      - name: Setup Node.js
        uses: actions/setup-node@v5
        with:
          node-version: 22

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Build site
        run: pnpm build

      - name: Upload build artifact
        uses: actions/upload-artifact@v7
        with:
          name: dist-out
          path: dist/
          retention-days: 1

fetch-depth: 0

Use fetch-depth: 0 if your build needs git history (e.g., for doc metainfo showing creation/update dates).

Verifying Secrets Before Deploy

Scope Secrets to the Step, Not the Job

The deploy step later on this page sets CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in its own env: block, not at the job level. A job-level env: hands those secrets to every step in the job — checkout, install, any postinstall script a dependency happens to run — which is far more surface than the one step that actually needs them. Keep secrets scoped to the step that calls wrangler.

Before anything tries to deploy with the Cloudflare secrets, it's worth confirming they're actually set. The obvious approach doesn't work, though:

jobs:
  deploy:
    if: secrets.CLOUDFLARE_API_TOKEN != ''

GitHub Actions refuses to even parse this — the secrets context isn't available in a job's if: (or a step's if:) at all, so this fails with Unrecognized named-value: 'secrets' before the workflow ever runs. Secrets are only readable inside a step's run: or env:.

The workaround is a dedicated job that reads the secrets where it's allowed to, then republishes the result as a plain (non-secret) job output:

  check-secrets:
    name: Verify Required Secrets
    runs-on: ubuntu-latest
    outputs:
      ok: ${{ steps.check.outputs.ok }}

    steps:
      - name: Check secrets are set
        id: check
        run: |
          if [ -n "${{ secrets.CLOUDFLARE_API_TOKEN }}" ] && [ -n "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]; then
            echo "ok=true" >> "$GITHUB_OUTPUT"
          else
            echo "ok=false" >> "$GITHUB_OUTPUT"
            echo "::error::Missing CLOUDFLARE_API_TOKEN or CLOUDFLARE_ACCOUNT_ID"
            exit 1
          fi

This Job Is Supposed to Fail Red When Secrets Are Missing

The exit 1 matters here in a way it wouldn't on Template Repo CI's preflight job. A template repo's whole point is that a fresh, unconfigured clone stays green -- missing secrets there is the expected first-run state. This is the opposite: it's the production workflow, secrets are supposed to already be configured, and a missing token means something regressed (an expired token, a deleted secret). Without exit 1, this step writes ok=false and still exits 0 -- check-secrets shows green, migrate-d1 and deploy skip via their if: gates, and the whole run finishes green having deployed nothing. exit 1 makes the job itself fail, which turns the run red and is what should page someone.

Downstream jobs can then gate on that output, since needs.<job>.outputs.* (unlike secrets) is allowed in a job's if::

  deploy:
    name: Deploy to Cloudflare Pages
    needs: [build-site, migrate-d1, check-secrets]
    if: needs.check-secrets.outputs.ok == 'true'

Job outputs are always strings, so the comparison is against the string 'true', not a boolean.

A Job-Level Gate Beats Per-Step Guards

It's tempting to skip the extra job and instead guard individual steps with something like if: env.CLOUDFLARE_API_TOKEN != ''. The problem is that per-step guards fail open: forget to add the guard to one step — or add a new step later without one — and that step just runs anyway, unprotected. A single job-level if: blocks the entire job when the gate fails, so there's no step left unguarded by accident.

Applying D1 Migrations Before Deploy

If your Worker depends on D1 schema changes, apply migrations before the new code that expects them goes live. Give the migration job its own step in the pipeline, gated by the same secrets check:

  migrate-d1:
    name: Apply D1 Migrations
    needs: [build-site, check-secrets]
    if: needs.check-secrets.outputs.ok == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 5

    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Apply D1 migrations
        run: npx wrangler@4 d1 migrations apply my-database --remote
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

needs: [build-site, check-secrets] isn't just about the token gate. Gating on check-secrets alone lets the migration start as soon as secrets are confirmed present, in parallel with a build-site that's still running -- or that goes on to fail. Adding build-site to needs: keeps the schema from advancing ahead of code that might not even make it to deploy.

Migrate Before Deploy, Not After

List migrate-d1 in the deploy job's needs: so the workflow fails fast if a migration errors, before any new code that expects the new schema goes live. Running migrations after deploy leaves a window where already-live code queries columns or tables that don't exist yet.

Deploy Job

  deploy:
    name: Deploy to Cloudflare Pages
    needs: [build-site, migrate-d1, check-secrets]
    if: needs.check-secrets.outputs.ok == 'true'
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Download site artifact
        uses: actions/download-artifact@v7
        with:
          name: dist-out
          path: dist-out/

      - name: Prepare deploy directory
        run: |
          mkdir -p deploy/pj/my-site
          if [ -d dist-out/client ]; then
            cp -r dist-out/client/. deploy/pj/my-site/
          else
            cp -r dist-out/. deploy/pj/my-site/
          fi
          echo '/ /pj/my-site/ 302' > deploy/_redirects

      - name: Deploy to Cloudflare Pages (production)
        run: |
          for attempt in 1 2 3; do
            if npx wrangler@4 pages deploy deploy \
              --project-name=my-site \
              --branch=main \
              --commit-hash="${GITHUB_SHA}" \
              --commit-message="Production deploy: ${GITHUB_SHA}"; then
              exit 0
            fi
            echo "Deploy attempt ${attempt} failed."
            if [ "${attempt}" -lt 3 ]; then
              echo "Retrying in 150s..."
              sleep 150
            fi
          done
          echo "::error::Deploy failed after 3 attempts — treating this as a real error, not a transient blip."
          exit 1
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

Cloudflare's API occasionally returns a transient 5xx error mid-deploy. Wrapping the deploy command in a small retry loop means a blip doesn't fail the whole workflow run: three attempts, 150 seconds apart, and if all three fail, that's treated as a real error rather than something worth retrying further.

Account for the Retry Budget in timeout-minutes

Two 150-second waits between three attempts add up to 5 minutes of pure backoff, on top of however long each wrangler deploy call takes. This example bumps the job's timeout-minutes from 10 to 15 to leave room for that — without the extra budget, the job's own timeout can fire before the loop gets to try a third time.

Retry Transient Failures Only — Not Auth or Config Errors

This loop exists for transient Cloudflare API 5xx errors, not for anything else. If wrangler fails with an auth or config error — an invalid API token, a Cloudflare error code like 10000 — retrying won't help, since the same failure repeats on every attempt. That turns a fast, clear failure into 5+ minutes of wasted CI time before the job finally reports red. If auth/config errors start showing up inside the retry loop, fix the credential or config problem directly rather than letting the loop mask it.

Separating Build and Deploy

Splitting build and deploy into separate jobs is recommended because:

  1. Artifact inspection: You can download and inspect the build output if needed

  2. Retry deploys: If the deploy fails (network issue), you can re-run just the deploy job

  3. Test between build and deploy: Insert test jobs (e2e, lighthouse) between build and deploy

Post-deploy Smoke Tests

A deploy that completes without error and a site that's actually serving the new build are two different things. Cloudflare can return 200 OK from a parked page, a stale *.pages.dev deployment still sitting behind an old route, or a domain that hasn't finished propagating yet — none of which the deploy job can see. A smoke-test job right after deploy closes that gap by checking the live production URL for the actual content of this build, not just its status code.

DNS-first: Decide Skip vs. Fail Before Touching HTTP

The first question a smoke test needs to answer isn't "did the site respond correctly?" but "is there even a site to ask yet?" A brand-new custom domain can take anywhere from minutes to hours to propagate, and hitting it with fetch() during that window throws — but so does a genuinely broken TLS cert, a genuinely wrong hostname, or a real DNS outage. Trying to tell those apart by inspecting fetch()'s error shape (TypeError: fetch failed, an ENOTFOUND or ECONNREFUSED buried in a cause chain that varies between Node versions) means guessing at implementation details that were never a contract.

node:dns/promises answers the actual question directly: does this hostname have an A or AAAA record at all?

import { resolve4, resolve6 } from "node:dns/promises";

async function hasDnsRecord(hostname) {
  const results = await Promise.allSettled([resolve4(hostname), resolve6(hostname)]);
  return results.some((r) => r.status === "fulfilled" && r.value.length > 0);
}

No record yet means the domain hasn't propagated — that's a skip, not a failure. A record that resolves but everything downstream is wrong is a different problem, checked next.

Content Markers Beat Bare 200

A 200 OK on its own proves nothing about which site answered. Two situations return it just as happily as a correct deploy:

  • A domain still routed to Cloudflare's parked-page product answers 200 with placeholder content.

  • A custom domain still pointed at last week's Cloudflare Pages deployment answers 200 with a perfectly valid — and perfectly stale — site.

Checking the status code alone passes both. The fix is to bake a literal marker into the actual build output — the deploy commit SHA in a footer comment or meta tag — and check the response body for that exact string, not for a hardcoded value that assumes the build worked:

const marker = process.env.SMOKE_CONTENT_MARKER; // baked into the page at build time, e.g. the commit SHA
const body = await res.text();
if (!body.includes(marker)) {
  throw new Error(`response is missing content marker "${marker}"`);
}

The Marker Has to Come From the Real Build, Not a Guess

Don't hardcode an expected string in the smoke-test script and hope the build matches it. Read the literal marker out of the actual HTML the build produces (or pass in the same value — e.g. github.sha — that the build step embedded), so the check verifies this specific build's output, not a string that happened to work once.

Don't Follow Redirects

A stale custom domain doesn't always serve old content directly — sometimes it 301s to the *.pages.dev URL of whichever deployment currently owns the route. fetch() follows redirects by default, so a naive check lands on the destination page, reads its marker, and passes, even though the domain itself is misrouted. Pass redirect: "manual" and inspect the response Node actually got back from the URL under test, before anything follows the pointer elsewhere:

const res = await fetch(target, { redirect: "manual" });
if (res.status >= 300 && res.status < 400) {
  throw new Error(`unexpected redirect (${res.status}) — check that ${target.hostname} isn't still pointed at a stale deployment`);
}

SMOKE_REQUIRE_LIVE: Tolerant on Day One, Strict Once Established

A brand-new site's first deploy can legitimately fail every check above — DNS hasn't propagated, there's no content to match yet — and none of that means the deploy is broken. An established site failing the exact same checks means something regressed. One smoke test needs both behaviors, at different points in its life:

  • Unset / false: any failure (no DNS record, a failed request, wrong status, redirect, missing marker) logs a warning and exits 0 — treated as "not live yet," not as a broken deploy.

  • true: the same failures exit 1 and fail the workflow.

Leave it unset while a domain is new, then flip it to true in the repo's variables once the site is confirmed live — from that point on, the smoke test should never look away from a real regression.

The Script

// scripts/smoke-test.mjs
import { resolve4, resolve6 } from "node:dns/promises";

const target = new URL(process.env.SMOKE_URL);
const marker = process.env.SMOKE_CONTENT_MARKER;
const requireLive = process.env.SMOKE_REQUIRE_LIVE === "true";

function skipOrFail(reason) {
  if (requireLive) {
    console.error(`::error::${reason}`);
    process.exit(1);
  }
  console.log(`::warning::${reason} -- treating as not-live-yet (SMOKE_REQUIRE_LIVE is unset)`);
  process.exit(0);
}

async function hasDnsRecord(hostname) {
  const results = await Promise.allSettled([resolve4(hostname), resolve6(hostname)]);
  return results.some((r) => r.status === "fulfilled" && r.value.length > 0);
}

if (!(await hasDnsRecord(target.hostname))) {
  skipOrFail(`${target.hostname} has no A/AAAA record yet`);
}

let res;
try {
  res = await fetch(target, { redirect: "manual" });
} catch (err) {
  skipOrFail(`request to ${target} failed: ${err.message}`);
}

if (res.status >= 300 && res.status < 400) {
  skipOrFail(`${target} redirected (${res.status}) instead of serving directly`);
} else if (res.status !== 200) {
  skipOrFail(`${target} returned HTTP ${res.status}`);
} else if (!(await res.text()).includes(marker)) {
  skipOrFail(`${target} is missing content marker "${marker}"`);
} else {
  console.log(`smoke test passed: ${target} is serving ${marker}`);
}

The DNS Check Doesn't Cover Every Way fetch() Can Throw

A resolvable A/AAAA record only proves the domain is routable -- it says nothing about whether TLS is provisioned yet or the route behind it is actually serving. fetch() still throws (rejects) for a cert that hasn't issued, a connection that's refused, or a route that's mid-propagation, and an uncaught rejection here crashes the script with a non-zero exit regardless of SMOKE_REQUIRE_LIVE -- bypassing skipOrFail entirely and hard-failing even in tolerant mode. Wrapping the fetch() call in try/catch and routing the error through skipOrFail keeps every failure mode, not just DNS, subject to the same tolerant-vs-strict switch.

Wiring It Into the Workflow

  smoke-test:
    name: Post-deploy Smoke Test
    needs: [deploy]
    runs-on: ubuntu-latest
    timeout-minutes: 5

    steps:
      - name: Checkout repository
        uses: actions/checkout@v5

      - name: Run smoke test
        run: node scripts/smoke-test.mjs
        env:
          SMOKE_URL: https://my-site.example.com
          SMOKE_CONTENT_MARKER: ${{ github.sha }}
          SMOKE_REQUIRE_LIVE: ${{ vars.SMOKE_REQUIRE_LIVE }}

SMOKE_REQUIRE_LIVE reads from vars, not secrets — it's a plain repository variable the team flips once the domain is confirmed live, not a credential.

API Worker Variant: Auth, Create, Verify, Delete

A static site's smoke test is a single GET. An API Worker's smoke test needs to prove the whole write path works, which means actually calling it: authenticate, create a record, verify it comes back correctly, then delete it. Two details make this safe to run on every deploy:

  • A unique identity per run — a timestamp plus a random suffix — so concurrent runs (two quick pushes) never collide on the same resource, and any record left behind is traceable back to the run that created it.

  • trap ... EXIT for cleanup — so the created record is deleted whether the script succeeds, fails at the verify step, or is killed by the job's timeout-minutes, not just on the happy path. The cleanup itself has to fail loudly too: a DELETE that silently no-ops leaves the test record behind with nothing in the job log saying so.

#!/usr/bin/env bash
set -euo pipefail

RUN_ID="smoke-$(date +%s)-$RANDOM"
ITEM_ID=""

cleanup() {
  local exit_code=$?
  if [ -n "$ITEM_ID" ]; then
    if ! curl -fsS -X DELETE "$API_URL/items/$ITEM_ID" \
      -H "Authorization: Bearer $API_TOKEN" >/dev/null; then
      echo "::error::cleanup failed to delete $ITEM_ID -- test record left behind"
      [ "$exit_code" -eq 0 ] && exit_code=1
    fi
  fi
  exit "$exit_code"
}
trap cleanup EXIT

curl -fsS "$API_URL/auth/verify" -H "Authorization: Bearer $API_TOKEN" >/dev/null

ITEM_ID=$(curl -fsS -X POST "$API_URL/items" \
  -H "Authorization: Bearer $API_TOKEN" -H "Content-Type: application/json" \
  -d "{\"name\":\"$RUN_ID\"}" | jq -r '.id')

curl -fsS "$API_URL/items/$ITEM_ID" -H "Authorization: Bearer $API_TOKEN" \
  | jq -e --arg name "$RUN_ID" '.name == $name' >/dev/null

echo "API smoke test passed for $RUN_ID"

A Cleanup Failure Can't Be Allowed to Look Like Success

[ -n "$ITEM_ID" ] && curl ... ; return 0 -- the naive version of this trap -- always returns 0 from cleanup, no matter what the curl -X DELETE did. A failed delete (auth expired, network blip, the item already gone) then vanishes without a trace: the job goes green, and the test record stays in the database forever. cleanup captures $? on entry (exit_code=$?), so a failure earlier in the script — the verify step, the create step — is preserved and still exits the job non-zero, exactly as before. What's new is the other direction: if the script itself succeeded but the DELETE failed, exit_code gets bumped from 0 to 1 so a broken cleanup can't hide behind an otherwise-passing run. Either way, the explicit exit "$exit_code" at the end is what actually sets the job's outcome, not whatever bash would infer from the trap's own last command.

trap ... EXIT Only Survives Ordinary Failures

trap ... EXIT runs on a normal exit, an exit call, or set -e aborting the script — covering every failure path above. It does not run if the process is killed with SIGKILL, which is how some CI runners enforce a hard timeout. Keep timeout-minutes generous enough that the script's own logic is what ends the job, not the outer limit.

Swap the run: step for this script in the API Worker's smoke-test job in place of the DNS/fetch version above; the surrounding job (needs: [deploy], timeout-minutes) stays the same.

Revision History

CreatedUpdated