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: readDo 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: 1fetch-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
fiThis 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:
Artifact inspection: You can download and inspect the build output if needed
Retry deploys: If the deploy fails (network issue), you can re-run just the deploy job
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
200with placeholder content.A custom domain still pointed at last week's Cloudflare Pages deployment answers
200with 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 exits0— treated as "not live yet," not as a broken deploy.true: the same failures exit1and 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 ... EXITfor cleanup — so the created record is deleted whether the script succeeds, fails at the verify step, or is killed by the job'stimeout-minutes, not just on the happy path. The cleanup itself has to fail loudly too: aDELETEthat 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.