# Cloudflare Pages Deployment Playbook
Production-tested patterns from deploying the ActiveWizards Astro site (147 pages) and 5 static microsites to Cloudflare Pages. Covers staging/production branching, custom domains, redirects, serverless functions, and troubleshooting.
Table of Contents
1. Project Creation
Via Wrangler CLI (recommended)
``bash
# First deployment creates the project automatically
npx wrangler pages deploy dist --project-name=my-site --branch=main
`
Via Dashboard
)Naming convention
Use consistent project names across environments:
| Site | CF Pages Project | Staging URL | Production URL |
| Main site | my-site | staging.my-site.pages.dev | mysite.com |
| Microsite | my-landing | my-landing.pages.dev | landing.mysite.com |
2. Deploy Script Pattern
A single deploy.sh script handles all sites with staging/production branching.
deploy.sh
`bash
#!/usr/bin/env bash
# Deploy sites to Cloudflare Pages
# Usage: ./deploy.sh [site|site-prod|all]
# Requires: npx wrangler + CLOUDFLARE_API_TOKEN env var
#
# Examples:
# ./deploy.sh aw → build + deploy to staging branch
# ./deploy.sh aw-prod → build + deploy to main (production)
# ./deploy.sh landing → deploy static site (no build step)
set -euo pipefail
SITE_DIR="$(cd "$(dirname "$0")" && pwd)/site"
# Deploy a static site (no build step)
deploy_site() {
local site="$1"
local project="$2"
echo "deploying ${site} → ${project}..."
npx wrangler pages deploy "${SITE_DIR}/${site}" \
--project-name="${project}" \
--commit-dirty=true \
--branch=main
echo "${site} deployed."
}
# Build Astro site, then deploy
deploy_astro() {
local site="$1"
local project="$2"
local branch="${3:-staging}"
echo "building ${site}..."
(cd "${SITE_DIR}/${site}" && npm run build)
echo "deploying ${site} → ${project} (${branch})..."
npx wrangler pages deploy "${SITE_DIR}/${site}/dist" \
--project-name="${project}" \
--commit-dirty=true \
--branch="${branch}"
echo "${site} deployed."
}
case "${1:-help}" in
aw) deploy_astro aw my-site staging ;;
aw-prod) deploy_astro aw my-site main ;;
landing) deploy_site my-landing my-landing ;;
all)
deploy_astro aw my-site staging
deploy_site my-landing my-landing
;;
*)
echo "Usage: $0 [aw|aw-prod|landing|all]"
exit 1
;;
esac
`
Key concepts
- --branch=staging
creates a preview deployment at staging.my-site.pages.dev
- --branch=main
deploys to production (the custom domain)
- --commit-dirty=true
allows deploying with uncommitted changes (useful during development)
- The script builds the Astro site locally and deploys the dist/
output
Make it executable
`bash
chmod +x deploy.sh
`
3. Wrangler CLI Setup
Install
`bash
npm install -g wrangler
# or use npx (no global install needed)
npx wrangler --version
`
Authentication
`bash
npx wrangler login
# Opens browser for OAuth — stores token in ~/.wrangler/
`
Or set the API token as environment variable:
`bash
export CLOUDFLARE_API_TOKEN="your-token-here"
`
To create an API token:
Deploy command anatomy
`bash
npx wrangler pages deploy
--project-name=
--branch=
--commit-dirty=true
`
| Flag | Purpose |
| Local folder to upload (dist/ for Astro, or root for static sites) |
--project-name | CF Pages project name |
--branch | main = production, anything else = preview deployment |
--commit-dirty | Deploy even with uncommitted git changes |
4. Custom Domains
Production domain
Subdomain for staging
Staging deploys to automatically. For a custom staging subdomain:
-> my-site.pages.dev to target itSubdomain microsites
For microsites like landing.mysite.com:
-> my-landing.pages.devDNS records
| Type | Name | Content | Proxy |
| CNAME | @ | my-site.pages.dev | Proxied |
| CNAME | www | my-site.pages.dev | Proxied |
| CNAME | staging | my-site.pages.dev | Proxied |
| CNAME | landing | my-landing.pages.dev | Proxied |
5. Redirects File
public/_redirects
Cloudflare Pages uses a _redirects file (flat text, deployed to site root).
`
# Format: from to status-code
# Simple redirect
/old-page/ /new-page/ 301
# Old CMS URL to new Astro URL
/contact/ /contact-us/ 301
# Catch-all for a removed section
/legacy-blog/* /blog/ 301
# External redirect
/github https://github.com/your-org 302
# Splat with placeholder
/blog/page/:num /blog/:num/ 301
`
Rules and limits
- 2000 rule limit per project (hard limit as of 2026)
- Rules are processed top-to-bottom, first match wins
- Use 301
for permanent redirects (SEO-safe),302for temporary
- Trailing slashes matter: /blog/
and/blogare different paths
- No regex support — use :placeholder
and*splats
- Static file takes precedence over redirect rule
Handling 800+ redirects (real scenario)
When migrating from a CMS with hundreds of pages:
`python
# Generate _redirects from migration data
import json
with open('data/migration/redirects.jsonl') as f:
rules = [json.loads(line) for line in f]
with open('site/aw/public/_redirects', 'w') as f:
f.write('# Redirects for CMS migration\n\n')
for r in rules:
f.write(f"{r['from']} {r['to']} 301\n")
print(f"Generated {len(rules)} redirect rules")
`
6. Environment Variables and Secrets
Setting secrets
`bash
# Set a secret (prompted for value — never in command line)
npx wrangler pages secret put MAILGUN_API_KEY --project-name=my-site
# Set for specific environment
npx wrangler pages secret put MAILGUN_API_KEY --project-name=my-site --env=production
npx wrangler pages secret put MAILGUN_API_KEY --project-name=my-site --env=preview
`
Required secrets for contact form
`bash
npx wrangler pages secret put MAILGUN_API_KEY --project-name=my-site
npx wrangler pages secret put MAILGUN_DOMAIN --project-name=my-site
npx wrangler pages secret put NOTIFICATION_EMAIL --project-name=my-site
`
Build-time environment variables
For variables needed at build time (not runtime secrets), set in CF Dashboard:
Or in wrangler.toml:
`toml
[vars]
PUBLIC_SITE_URL = "https://mysite.com"
`
Accessing in Pages Functions
`ts
interface Env {
MAILGUN_API_KEY: string;
MAILGUN_DOMAIN: string;
NOTIFICATION_EMAIL: string;
}
export const onRequestPost: PagesFunction
const { env } = context;
console.log(env.MAILGUN_API_KEY); // Available at runtime
};
`
7. Pages Functions (Serverless)
File convention
Place functions in functions/ directory at project root. The path maps to the URL:
`
functions/
api/
contact.ts → POST /api/contact
health.ts → GET /api/health
webhook.ts → POST /webhook
`
Example: Contact form handler
See CONTACT_FORM.md for the complete implementation.
Key patterns
`ts
// TypeScript types for CF Pages Functions
interface Env {
MY_SECRET: string;
MY_KV: KVNamespace; // if using KV
}
// Handle POST requests only
export const onRequestPost: PagesFunction
const { request, env, waitUntil } = context;
// Parse body
const formData = await request.formData();
// Fire-and-forget with waitUntil (response returns immediately)
context.waitUntil(
fetch('https://api.external.com/endpoint', {
method: 'POST',
body: JSON.stringify({ data: 'value' }),
})
);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};
// Handle GET requests
export const onRequestGet: PagesFunction
return new Response('OK', { status: 200 });
};
`
CORS (if needed)
`ts
const corsHeaders = {
'Access-Control-Allow-Origin': 'https://mysite.com',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
export const onRequestOptions: PagesFunction = async () => {
return new Response(null, { status: 204, headers: corsHeaders });
};
`
Local development
`bash
# Run Astro dev with CF Pages Functions
npx wrangler pages dev -- npx astro dev
# or
npx wrangler pages dev dist # after building
`
8. Build Settings
For Astro with Wrangler CLI deploys
No build settings needed in CF Dashboard when deploying via CLI — the deploy script handles the build locally.
If using Git integration (alternative approach)
CF Dashboard > Pages > Project > Settings > Build:
| Setting | Value |
| Build command | npm run build |
| Build output | dist |
| Root directory | site/aw (if monorepo) |
| Node version | 20 |
Recommended: CLI deploy over Git integration
Reasons to prefer CLI:
- Full control over when deployments happen
- No surprise deploys from pushes
- Can deploy from any branch without CF knowing your git history
- Faster iteration during development
- Staging/production branching without git branch management
9. Preview Deployments
Every non-main branch deploy creates a preview URL:
`bash
# Deploy to staging preview
npx wrangler pages deploy dist --project-name=my-site --branch=staging
# → https://staging.my-site.pages.dev
# Deploy a feature branch preview
npx wrangler pages deploy dist --project-name=my-site --branch=feat-new-nav
# → https://feat-new-nav.my-site.pages.dev
`
Preview vs Production
| Aspect | Preview | Production |
| Branch | Any non-main | main |
| URL | | Custom domain |
| Indexed | No (X-Robots-Tag: noindex) | Yes |
| Secrets | Uses preview environment | Uses production environment |
Cleanup
Old preview deployments don't cost anything but can be deleted:
`bash
# List deployments
npx wrangler pages deployments list --project-name=my-site
# Delete a specific deployment
npx wrangler pages deployments delete
`
10. Troubleshooting
Cache purge
After deploying, if you see stale content:
`bash
curl -X POST "https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache" \
-H "Authorization: Bearer {api_token}" \
-H "Content-Type: application/json" \
--data '{"purge_everything":true}'
`
Build failures
"Error: Cannot find module..."- Run npm install
locally before deploying
- Ensure node_modules/
exists in the project directory
- Astro outputs to dist/
— make sure to deploydist/, not the project root
- Check the deploy script deploys the correct directory
- CF Pages has a 25MB per-file limit and 20,000 file limit per deployment
- Compress images before deploying
- Remove unused assets from public/
Redirect issues
"Too many redirects" loop- Check for conflicting redirect rules (e.g., /blog
->/blog/AND/blog/->/blog)
- Ensure CF SSL mode is "Full (strict)" not "Flexible"
- Check that Astro's trailing slash setting matches your redirect rules
- Static files take precedence over _redirects
rules
- Rules are case-sensitive
- Maximum 2000 rules — check count with wc -l public/_redirects
Pages Functions not executing
- Functions must be in functions/
at the project root (same level aspublic/)
- For Astro: functions/
sits alongsidesrc/, not inside it
- File must export onRequest
,onRequestGet,onRequestPost, etc.
- Check function logs: CF Dashboard > Pages > Project > Functions > Logs
Deployment stuck / failed
`bash
# Check deployment status
npx wrangler pages deployments list --project-name=my-site
# Retry deploy
npx wrangler pages deploy dist --project-name=my-site --branch=main
`
Custom domain not resolving
Deployment Checklist
- [ ] CLOUDFLARE_API_TOKEN
set as environment variable
- [ ] Project created on CF Pages
- [ ] Custom domain configured with CNAME records
- [ ] SSL set to "Full (strict)" on the zone
- [ ] All required secrets set via wrangler pages secret put
- [ ] _redirects
file inpublic/with all migration redirects
- [ ] Build succeeds locally (npm run build
)
- [ ] Staging deployment works (./deploy.sh aw
)
- [ ] Production deployment works (./deploy.sh aw-prod`)
- [ ] Pages Functions respond correctly
- [ ] Cache purged after first production deploy
- [ ] Verify in browser: check 3 random pages, form submission, redirects