Deployment Verification Checklist: Multi-Site Astro Monorepo on Cloudflare Pages

Deployment Verification Checklist: Multi-Site Astro Monorepo on Cloudflare Pages

This checklist covers the first-ever deployment (Phase 1B) and every subsequent deployment of the pnpm + Turborepo monorepo to Cloudflare Pages via GitHub Actions. It is designed to be executed literally, step by step.


Section 0: Invariants

These conditions must remain true at every stage. If any invariant is violated, STOP the deployment and investigate.

  • Every site under apps/ has a corresponding Cloudflare Pages project
  • Every site under apps/ is listed in the dorny/paths-filter filter block in .github/workflows/deploy.yml
  • pnpm-lock.yaml is committed and up-to-date with all package.json files
  • Every site in apps/ builds successfully with pnpm turbo build --filter=@repo/<slug>
  • No secrets (API tokens, account IDs) appear in committed files — they live exclusively in GitHub Secrets
  • The packages/** glob is included as a trigger for EVERY site filter entry (shared changes rebuild all sites)
  • Each Cloudflare Pages project name matches the directory name under apps/ exactly

Section 1: Pre-Deploy — First-Ever Setup (One-Time)

Complete these steps ONCE before the very first deployment to Cloudflare.

1.1 Cloudflare Account and API Token

# Verify wrangler is installed and authenticated
wrangler --version
wrangler whoami
  • Cloudflare account exists with Pages enabled
  • API Token created with the following permissions:
    • Account: Cloudflare Pages: Edit
    • Account: Account Settings: Read
    • Zone: DNS: Edit (only if configuring custom domains)
  • API Token scoped to the correct Cloudflare account (not “All accounts”)

1.2 Create Cloudflare Pages Projects

For EACH site (starting with zendee-adventures), create the Pages project. Wrangler will auto-create on first deploy, but explicit creation avoids surprises:

# Option A: Let wrangler auto-create on first deploy (simpler)
# The first `wrangler pages deploy` will create the project if it does not exist.

# Option B: Create explicitly via API (more control)
wrangler pages project create zendee-adventures --production-branch=main

Verification:

# List all Pages projects -- confirm each site appears
wrangler pages project list
  • zendee-adventures project exists in Cloudflare Pages
  • (Repeat for each additional site as they are scaffolded)

1.3 GitHub Secrets

  • CLOUDFLARE_API_TOKEN is set in the repository’s GitHub Secrets (Settings > Secrets and variables > Actions)
  • CLOUDFLARE_ACCOUNT_ID is set in the repository’s GitHub Secrets
  • Neither secret is set as an Environment secret that might be scoped to only some environments

Verification:

# From local machine with gh CLI:
gh secret list --repo <owner>/<repo>
# Expected output includes:
#   CLOUDFLARE_API_TOKEN  Updated <date>
#   CLOUDFLARE_ACCOUNT_ID Updated <date>

1.4 Workflow File Validation

# Lint the workflow file for YAML syntax errors
# (actionlint is recommended: https://github.com/rhysd/actionlint)
actionlint .github/workflows/deploy.yml
  • Workflow file passes YAML linting with no errors
  • dorny/paths-filter@v3 is pinned to a tag (not main)
  • cloudflare/wrangler-action@v3 is pinned to a tag (not main)
  • pnpm/action-setup@v4 is pinned to a tag
  • actions/checkout@v4 and actions/setup-node@v4 are pinned to tags
  • Node version in workflow matches the project requirement (node-version: 22)

1.5 Local Build Verification

# Clean install from scratch (simulates CI)
rm -rf node_modules apps/*/node_modules packages/*/node_modules
pnpm install --frozen-lockfile

# Build every site
pnpm turbo build

# Verify output exists for each site
ls -la apps/zendee-adventures/dist/index.html
# (repeat for each site)
  • pnpm install --frozen-lockfile succeeds without errors
  • pnpm turbo build succeeds for all sites
  • Each apps/<site>/dist/ directory contains a valid index.html

Section 2: Pre-Deploy — Every Deployment

Run these checks before every push to main that will trigger a deployment.

2.1 Lockfile Consistency

# Verify lockfile is in sync with package.json files
pnpm install --frozen-lockfile
echo "Exit code: $?"
# Expected: 0 (no changes needed)
  • pnpm install --frozen-lockfile exits with code 0
  • There are no uncommitted changes to pnpm-lock.yaml after install

If this fails: run pnpm install locally, commit the updated lockfile, and restart this checklist.

2.2 Change Detection Audit

The dorny/paths-filter step determines which sites get built. Verify the filter configuration matches reality:

# List all site directories
ls -d apps/*/

# For each site directory, confirm it appears in the filter block:
grep -A2 'zendee-adventures:' .github/workflows/deploy.yml
# Expected output:
#   zendee-adventures:
#     - 'apps/zendee-adventures/**'
#     - 'packages/**'
  • Every directory under apps/ has a corresponding entry in the paths-filter
  • Every filter entry includes 'packages/**' as a trigger (shared package changes must rebuild all sites)
  • Filter entries do NOT use 'apps/**' as a global catch-all (this defeats per-site filtering)

Known edge cases for dorny/paths-filter:

ScenarioWhat HappensMitigation
New site added, filter not updatedNew site never deploys/site-scaffold skill MUST update deploy.yml filter block
Root config files changed (turbo.json, tsconfig)No sites rebuildAdd root config globs to every filter entry, or accept manual redeploy
File renamed (not just modified)Detected correctly by dornyNo action needed
Merge commit with squashed changesDetected correctlyNo action needed
workflow_dispatch with site: "all"Bypasses dorny entirely — handle in workflow logicVerify the workflow_dispatch job handles "all" input
First commit on a new branch (no base to diff)dorny reports ALL files as changedAcceptable — all sites build on first push

2.3 Matrix Strategy Validation

The matrix ${{ fromJson(needs.detect-changes.outputs.sites) }} must produce a valid JSON array. Verify:

# Simulate dorny output -- the "changes" output is a JSON array of matched filter names
# Example: '["zendee-adventures"]' or '["zendee-adventures","second-business"]'
# Edge case: '[]' (no changes detected)
  • The workflow has if: needs.detect-changes.outputs.sites != '[]' to skip the matrix job when no sites changed
  • Each matrix value matches an apps/<value>/ directory exactly (slug consistency)
  • The Turborepo filter --filter=@repo/${{ matrix.site }} matches the name field in each site’s package.json

Edge cases:

ScenarioWhat HappensMitigation
Empty matrix []build-and-deploy job is skipped entirelyCorrect behavior — the if condition handles this
Matrix with one siteSingle parallel job runsNo issue
Matrix site slug does not match package.json nameturbo build --filter finds nothing, silent no-opEnforce naming convention: apps/<slug>/package.json must have "name": "@repo/<slug>"
Matrix job fails for one site, succeeds for othersBy default, GitHub Actions cancels remaining matrix jobsAdd fail-fast: false to the strategy to let healthy sites deploy

2.4 Build Verification (Local Dry Run)

# Build only the changed site(s) -- replace <slug> with the site being deployed
pnpm turbo build --filter=@repo/<slug>

# Verify output
find apps/<slug>/dist -name "*.html" | head -20
# Expected: At least index.html exists

# Check for common build issues
# 1. Empty dist directory
test -f apps/<slug>/dist/index.html && echo "OK: index.html exists" || echo "FAIL: no index.html"

# 2. Broken asset references (look for absolute paths that won't work on Pages)
grep -r 'src="/' apps/<slug>/dist/ --include="*.html" | grep -v 'src="/assets' | head -5
# Expected: Only asset paths, no localhost or absolute server paths
  • Build produces non-empty dist/ directory
  • dist/index.html exists and is valid HTML
  • No broken asset references in built output

2.5 workflow_dispatch Input Validation

If deploying via manual trigger (workflow_dispatch):

  • The site input is either a valid site slug (matching an apps/<slug>/ directory) OR the literal string "all"
  • The workflow handles the "all" case by building every site (not passing "all" to the matrix)

Verify the workflow has logic like:

# Pseudo-logic that should exist in deploy.yml for workflow_dispatch:
# If inputs.site == "all": build all sites
# Else: build only inputs.site
# If inputs.site is not a valid slug and not "all": fail with clear error
  • Invalid site slugs are rejected (the build will fail naturally if the directory does not exist, but a pre-check is better)

Section 3: Deploy Steps

3.1 Production Deployment (Push to Main)

StepActionEstimated TimeRollback
1Push/merge to mainInstantgit revert
2GitHub Actions: detect-changes job runs dorny/paths-filter~15 secondsRe-run workflow
3GitHub Actions: build-and-deploy matrix jobs start~5 secondsCancel workflow
4pnpm install —frozen-lockfile~30-60 secondsFix lockfile, re-run
5pnpm turbo build —filter=@repo/~15-60 seconds per siteFix build, re-push
6wrangler pages deploy apps//dist —project-name=~15-30 seconds per siteRollback via Cloudflare dashboard
  • Monitor workflow run at https://github.com/<owner>/<repo>/actions
  • All matrix jobs complete with green checkmarks
  • Wrangler deploy step outputs a deployment URL for each site

3.2 Preview Deployment (Pull Request)

StepActionNotes
1Open PR targeting mainBranch name becomes the preview identifier
2GitHub Actions: Same build pipelineUses --branch=${{ github.head_ref }}
3Wrangler deploys to preview URLURL pattern: <branch>.<project>.pages.dev
  • Preview URL is accessible and renders the site correctly
  • Preview URL appears as a comment or check on the PR (configure cloudflare/wrangler-action to comment)

Branch naming concerns:

IssueImpactMitigation
Branch name contains special charactersCloudflare may reject or sanitize the branch nameUse only alphanumeric + hyphens in branch names
Branch name exceeds 28 charactersPreview subdomain may be truncatedKeep branch names concise
Multiple PRs for same siteEach gets its own preview URL based on branch nameNo conflict
Stale preview deploymentsOld previews remain accessible on CloudflarePeriodically clean up via Cloudflare dashboard or API

Section 4: Post-Deploy Verification (Within 5 Minutes)

Run these checks immediately after a successful deployment.

4.1 Site Accessibility

# For each deployed site, verify it responds with 200:
curl -s -o /dev/null -w "%{http_code}" https://zendee-adventures.pages.dev
# Expected: 200

# Verify the HTML is not an error page:
curl -s https://zendee-adventures.pages.dev | head -20
# Expected: Valid HTML with expected <title> tag

# Check response headers for Cloudflare:
curl -s -I https://zendee-adventures.pages.dev | grep -i 'cf-ray\|server'
# Expected: server: cloudflare, cf-ray header present
  • Each deployed site returns HTTP 200
  • Page content matches expected output (not a Cloudflare error page)
  • Cloudflare headers are present (confirming CDN delivery)

4.2 Content Integrity

# Verify key pages exist (not just index):
for path in "/" "/blog" "/about"; do
  status=$(curl -s -o /dev/null -w "%{http_code}" "https://zendee-adventures.pages.dev${path}")
  echo "${path}: ${status}"
done
# Expected: 200 for existing pages, 404 for pages not yet created (which is fine)

# Verify static assets load:
curl -s https://zendee-adventures.pages.dev | grep -oP 'href="[^"]*\.css"' | head -5
# Then fetch one CSS file to confirm it loads:
curl -s -o /dev/null -w "%{http_code}" "https://zendee-adventures.pages.dev/<css-path>"
# Expected: 200
  • Index page loads with correct content
  • CSS and JS assets load successfully (no 404s in asset requests)
  • Images referenced in HTML are accessible

4.3 Deployment Metadata

# Verify the deployment details via Wrangler:
wrangler pages deployment list --project-name=zendee-adventures
# Expected: Most recent deployment shows current commit SHA and "Active" status

# Verify the production deployment is the one just pushed:
wrangler pages deployment list --project-name=zendee-adventures | head -5
# The top entry should show the commit SHA you just deployed
  • Latest deployment in Cloudflare matches the commit SHA that was just deployed
  • Deployment status is “Active” (not “Failed” or “Building”)

4.4 Cross-Site Verification (Multi-Site Deploys)

When packages/** changes trigger all sites to rebuild:

# Verify ALL sites are accessible and updated:
for site in zendee-adventures; do
  # Add more sites to this list as they are onboarded
  status=$(curl -s -o /dev/null -w "%{http_code}" "https://${site}.pages.dev")
  echo "${site}: ${status}"
done
# Expected: All sites return 200
  • Every site in the monorepo returns HTTP 200
  • Shared component changes render correctly on each site (spot-check visually)
  • No site is stuck on a stale deployment from a previous build

4.5 Custom Domain Verification (If Configured)

# Verify custom domain resolves to Cloudflare:
dig +short zendee-adventures.com
# Expected: Cloudflare IP addresses

# Verify HTTPS works on the custom domain:
curl -s -o /dev/null -w "%{http_code}" https://zendee-adventures.com
# Expected: 200

# Verify SSL certificate is valid:
echo | openssl s_client -connect zendee-adventures.com:443 -servername zendee-adventures.com 2>/dev/null | openssl x509 -noout -dates
# Expected: Valid date range covering today
  • Custom domain resolves to Cloudflare IPs
  • HTTPS returns 200 with valid SSL certificate
  • Both www and apex domain work (if both are configured)
  • HTTP redirects to HTTPS

Section 5: Rollback Plan

5.1 Can We Roll Back?

Yes — Cloudflare Pages retains previous deployments.

Rollback is non-destructive. Cloudflare keeps every deployment and allows instant rollback to any previous deployment through the dashboard or API.

Static site deployments have no database state, no data migrations, and no server-side state. Rollback is always safe.

5.2 Rollback Procedure: Via Cloudflare Dashboard (Fastest)

  1. Navigate to https://dash.cloudflare.com > Pages > <project-name> > Deployments
  2. Find the last known good deployment (identified by commit SHA and timestamp)
  3. Click the three-dot menu on that deployment
  4. Select “Rollback to this deployment”
  5. Confirm

Estimated time: under 60 seconds.

5.3 Rollback Procedure: Via Wrangler CLI

# List recent deployments to find the last known good one:
wrangler pages deployment list --project-name=zendee-adventures

# Note the deployment ID of the last good deployment.
# Unfortunately, Wrangler does not have a direct "rollback" command.
# The fastest CLI rollback is to redeploy from the last good commit:

git checkout <last-good-commit-sha>
pnpm install --frozen-lockfile
pnpm turbo build --filter=@repo/zendee-adventures
wrangler pages deploy apps/zendee-adventures/dist --project-name=zendee-adventures

# Then return to main:
git checkout main

5.4 Rollback Procedure: Via Git Revert (Safest for CI-Based Deploys)

# Revert the problematic commit(s):
git revert <bad-commit-sha>
git push origin main

# This triggers the normal CI/CD pipeline which will build and deploy
# the reverted state.

Estimated time: 2-3 minutes (includes CI build time).

5.5 Rollback Verification

After any rollback, re-run the full Section 4 post-deploy verification.

  • Site returns HTTP 200
  • Content matches the last known good state
  • Deployment metadata shows the expected commit SHA

5.6 Rollback Edge Cases

ScenarioApproach
Bad deploy to one site, others are fineRollback only the affected site via Cloudflare dashboard
Shared package change broke all sitesgit revert the shared package commit, push to main to redeploy all
Cloudflare API outage prevents deploySites remain on the last successful deployment — no action needed, wait for Cloudflare recovery
Custom domain DNS misconfiguredDNS changes take time to propagate — rollback DNS change in Cloudflare dashboard, allow up to 5 minutes for propagation
Preview deployment broke, not productionNo rollback needed — close the PR or push a fix to the branch

Section 6: Post-Deploy Monitoring (First 24 Hours)

6.1 Monitoring Sources

What to MonitorWhereAlert ConditionCheck Frequency
Site availability (HTTP 200)Cloudflare Analytics or external uptime monitorAny 5xx responseEvery 5 minutes
Page load timeCloudflare Analytics > PerformanceP95 > 3 secondsHourly
Total requestsCloudflare Analytics > TrafficUnexpected drop to zeroHourly
Build failuresGitHub Actions > Workflow runsAny red statusOn every push
SSL certificate statusCloudflare dashboard > SSL/TLSCertificate expiring < 7 daysDaily
DNS resolutiondig <domain>Fails to resolveOn deploy and daily

6.2 Manual Checks at Scheduled Intervals

At +1 hour:

# Quick smoke test for each site:
for site in zendee-adventures; do
  status=$(curl -s -o /dev/null -w "%{http_code}" "https://${site}.pages.dev")
  echo "${site}: ${status}"
done
  • All sites return HTTP 200
  • No error reports from users

At +4 hours:

  • Check Cloudflare Analytics for any anomalies (error spikes, traffic drops)
  • Review GitHub Actions for any failed runs since deployment

At +24 hours:

  • Final review of Cloudflare Analytics (full day)
  • Confirm no stale preview deployments need cleanup
  • Close the deployment tracking issue/ticket

Set up a free uptime monitor for each production site. Options:

  • Cloudflare Health Checks (if using Cloudflare Pro or higher)
  • UptimeRobot (free tier: 50 monitors, 5-minute intervals)
  • Better Stack (formerly Better Uptime) (free tier available)

Configuration per site:

URL: https://zendee-adventures.pages.dev (or custom domain)
Check interval: 5 minutes
Alert method: Email (and/or Slack)
Expected status: 200
Keyword check: (optional) look for a known string in the HTML, e.g., "Zendee Adventures"
  • Uptime monitor configured for each production site
  • Alert notifications confirmed working (send a test alert)

6.4 Cloudflare Pages Quotas to Watch

ResourceFree Tier LimitMonitor When
Pages projects per account100Adding new sites (currently planning 4-6)
Builds per month500Heavy development periods
Custom domains per project100Adding domains
Max file size25 MBAdding large assets (images, PDFs)
Max files per deployment20,000Large sites with many pages
Max deployment size25 MB compressedAdding media assets

Section 7: Ongoing Deployment Hygiene

7.1 Adding a New Site

When /site-scaffold creates a new site, the following MUST happen before it can deploy:

  • apps/<new-slug>/ directory created with valid package.json (name: @repo/<new-slug>)
  • Brand config created at packages/config/src/brands/<new-slug>.ts
  • Filter entry added to .github/workflows/deploy.yml:
    <new-slug>:
      - 'apps/<new-slug>/**'
      - 'packages/**'
  • Cloudflare Pages project created (or will auto-create on first deploy)
  • Site builds successfully: pnpm turbo build --filter=@repo/<new-slug>
  • (Optional) Custom domain configured in Cloudflare

7.2 Removing a Site

  • Remove the apps/<slug>/ directory
  • Remove the filter entry from deploy.yml
  • Remove the brand config from packages/config/src/brands/<slug>.ts
  • Delete the Cloudflare Pages project: wrangler pages project delete <slug>
  • Remove any custom domain DNS records

7.3 Periodic Maintenance

Monthly:

  • Review and clean up stale preview deployments on Cloudflare
  • Verify all GitHub Actions dependencies are up to date (Dependabot or manual)
  • Check Cloudflare Pages build quota usage

Quarterly:

  • Audit API token permissions (least privilege)
  • Review and rotate CLOUDFLARE_API_TOKEN if policy requires it
  • Update pinned action versions (checkout, setup-node, wrangler-action, paths-filter)

Quick Reference: Common Failure Modes

SymptomLikely CauseFix
Workflow runs but no sites deploydorny/paths-filter returned []Check filter config; use workflow_dispatch for manual deploy
pnpm install --frozen-lockfile fails in CILockfile out of syncRun pnpm install locally, commit pnpm-lock.yaml
turbo build --filter=@repo/<slug> builds nothingPackage name mismatchVerify apps/<slug>/package.json has "name": "@repo/<slug>"
Wrangler deploy fails with 403API token lacks permissionsRegenerate token with correct scopes
Wrangler deploy fails with “project not found”Project not yet createdRun wrangler pages project create <slug> or let first deploy auto-create
Preview URL shows wrong contentBranch name mismatch or cacheClear Cloudflare cache; verify --branch flag in workflow
Custom domain shows SSL errorDNS propagation or certificate pendingWait 5-15 minutes; check Cloudflare SSL/TLS settings
Shared package change did not rebuild a siteSite missing 'packages/**' in its filter entryAdd the glob to the site’s filter
workflow_dispatch with invalid site slugNo input validation in workflowBuild will fail at turbo filter step; add a validation step
Matrix job fails for one site, cancels allDefault fail-fast: true behaviorAdd fail-fast: false to the matrix strategy