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 thedorny/paths-filterfilter block in.github/workflows/deploy.yml -
pnpm-lock.yamlis committed and up-to-date with allpackage.jsonfiles - Every site in
apps/builds successfully withpnpm 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-adventuresproject exists in Cloudflare Pages - (Repeat for each additional site as they are scaffolded)
1.3 GitHub Secrets
-
CLOUDFLARE_API_TOKENis set in the repository’s GitHub Secrets (Settings > Secrets and variables > Actions) -
CLOUDFLARE_ACCOUNT_IDis 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@v3is pinned to a tag (notmain) -
cloudflare/wrangler-action@v3is pinned to a tag (notmain) -
pnpm/action-setup@v4is pinned to a tag -
actions/checkout@v4andactions/setup-node@v4are 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-lockfilesucceeds without errors -
pnpm turbo buildsucceeds for all sites - Each
apps/<site>/dist/directory contains a validindex.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-lockfileexits with code 0 - There are no uncommitted changes to
pnpm-lock.yamlafter 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:
| Scenario | What Happens | Mitigation |
|---|---|---|
| New site added, filter not updated | New site never deploys | /site-scaffold skill MUST update deploy.yml filter block |
| Root config files changed (turbo.json, tsconfig) | No sites rebuild | Add root config globs to every filter entry, or accept manual redeploy |
| File renamed (not just modified) | Detected correctly by dorny | No action needed |
| Merge commit with squashed changes | Detected correctly | No action needed |
workflow_dispatch with site: "all" | Bypasses dorny entirely — handle in workflow logic | Verify the workflow_dispatch job handles "all" input |
| First commit on a new branch (no base to diff) | dorny reports ALL files as changed | Acceptable — 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 thenamefield in each site’spackage.json
Edge cases:
| Scenario | What Happens | Mitigation |
|---|---|---|
Empty matrix [] | build-and-deploy job is skipped entirely | Correct behavior — the if condition handles this |
| Matrix with one site | Single parallel job runs | No issue |
| Matrix site slug does not match package.json name | turbo build --filter finds nothing, silent no-op | Enforce naming convention: apps/<slug>/package.json must have "name": "@repo/<slug>" |
| Matrix job fails for one site, succeeds for others | By default, GitHub Actions cancels remaining matrix jobs | Add 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.htmlexists 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
siteinput is either a valid site slug (matching anapps/<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)
| Step | Action | Estimated Time | Rollback |
|---|---|---|---|
| 1 | Push/merge to main | Instant | git revert |
| 2 | GitHub Actions: detect-changes job runs dorny/paths-filter | ~15 seconds | Re-run workflow |
| 3 | GitHub Actions: build-and-deploy matrix jobs start | ~5 seconds | Cancel workflow |
| 4 | pnpm install —frozen-lockfile | ~30-60 seconds | Fix lockfile, re-run |
| 5 | pnpm turbo build —filter=@repo/ | ~15-60 seconds per site | Fix build, re-push |
| 6 | wrangler pages deploy apps/ | ~15-30 seconds per site | Rollback 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)
| Step | Action | Notes |
|---|---|---|
| 1 | Open PR targeting main | Branch name becomes the preview identifier |
| 2 | GitHub Actions: Same build pipeline | Uses --branch=${{ github.head_ref }} |
| 3 | Wrangler deploys to preview URL | URL 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-actionto comment)
Branch naming concerns:
| Issue | Impact | Mitigation |
|---|---|---|
| Branch name contains special characters | Cloudflare may reject or sanitize the branch name | Use only alphanumeric + hyphens in branch names |
| Branch name exceeds 28 characters | Preview subdomain may be truncated | Keep branch names concise |
| Multiple PRs for same site | Each gets its own preview URL based on branch name | No conflict |
| Stale preview deployments | Old previews remain accessible on Cloudflare | Periodically 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
wwwand 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)
- Navigate to
https://dash.cloudflare.com> Pages ><project-name>> Deployments - Find the last known good deployment (identified by commit SHA and timestamp)
- Click the three-dot menu on that deployment
- Select “Rollback to this deployment”
- 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
| Scenario | Approach |
|---|---|
| Bad deploy to one site, others are fine | Rollback only the affected site via Cloudflare dashboard |
| Shared package change broke all sites | git revert the shared package commit, push to main to redeploy all |
| Cloudflare API outage prevents deploy | Sites remain on the last successful deployment — no action needed, wait for Cloudflare recovery |
| Custom domain DNS misconfigured | DNS changes take time to propagate — rollback DNS change in Cloudflare dashboard, allow up to 5 minutes for propagation |
| Preview deployment broke, not production | No 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 Monitor | Where | Alert Condition | Check Frequency |
|---|---|---|---|
| Site availability (HTTP 200) | Cloudflare Analytics or external uptime monitor | Any 5xx response | Every 5 minutes |
| Page load time | Cloudflare Analytics > Performance | P95 > 3 seconds | Hourly |
| Total requests | Cloudflare Analytics > Traffic | Unexpected drop to zero | Hourly |
| Build failures | GitHub Actions > Workflow runs | Any red status | On every push |
| SSL certificate status | Cloudflare dashboard > SSL/TLS | Certificate expiring < 7 days | Daily |
| DNS resolution | dig <domain> | Fails to resolve | On 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
6.3 Uptime Monitoring Setup (Recommended)
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
| Resource | Free Tier Limit | Monitor When |
|---|---|---|
| Pages projects per account | 100 | Adding new sites (currently planning 4-6) |
| Builds per month | 500 | Heavy development periods |
| Custom domains per project | 100 | Adding domains |
| Max file size | 25 MB | Adding large assets (images, PDFs) |
| Max files per deployment | 20,000 | Large sites with many pages |
| Max deployment size | 25 MB compressed | Adding 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 validpackage.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_TOKENif policy requires it - Update pinned action versions (checkout, setup-node, wrangler-action, paths-filter)
Quick Reference: Common Failure Modes
| Symptom | Likely Cause | Fix |
|---|---|---|
| Workflow runs but no sites deploy | dorny/paths-filter returned [] | Check filter config; use workflow_dispatch for manual deploy |
pnpm install --frozen-lockfile fails in CI | Lockfile out of sync | Run pnpm install locally, commit pnpm-lock.yaml |
turbo build --filter=@repo/<slug> builds nothing | Package name mismatch | Verify apps/<slug>/package.json has "name": "@repo/<slug>" |
| Wrangler deploy fails with 403 | API token lacks permissions | Regenerate token with correct scopes |
| Wrangler deploy fails with “project not found” | Project not yet created | Run wrangler pages project create <slug> or let first deploy auto-create |
| Preview URL shows wrong content | Branch name mismatch or cache | Clear Cloudflare cache; verify --branch flag in workflow |
| Custom domain shows SSL error | DNS propagation or certificate pending | Wait 5-15 minutes; check Cloudflare SSL/TLS settings |
| Shared package change did not rebuild a site | Site missing 'packages/**' in its filter entry | Add the glob to the site’s filter |
workflow_dispatch with invalid site slug | No input validation in workflow | Build will fail at turbo filter step; add a validation step |
| Matrix job fails for one site, cancels all | Default fail-fast: true behavior | Add fail-fast: false to the matrix strategy |