Infrastructure

Cloudflare Deploy

# 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

  • Project Creation
  • Deploy Script Pattern
  • Wrangler CLI Setup
  • Custom Domains
  • Redirects File
  • Environment Variables and Secrets
  • Pages Functions (Serverless)
  • Build Settings
  • Preview Deployments
  • Troubleshooting

  • 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

  • Go to Workers & Pages > Create
  • Choose "Direct Upload" (not Git integration — we deploy via CLI for control)
  • Name the project (e.g., my-site)
  • Upload is optional at this step — first real deploy comes from CLI
  • Naming convention

    Use consistent project names across environments:

    SiteCF Pages ProjectStaging URLProduction URL
    Main sitemy-sitestaging.my-site.pages.devmysite.com
    Micrositemy-landingmy-landing.pages.devlanding.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:

  • CF Dashboard > My Profile > API Tokens
  • Create Token > "Edit Cloudflare Workers" template
  • Permissions: Account > Workers & Pages > Edit
  • Include your account
  • Deploy command anatomy

    `bash

    npx wrangler pages deploy \

    --project-name= \

    --branch= \

    --commit-dirty=true

    `

    FlagPurpose
    Local folder to upload (dist/ for Astro, or root for static sites)
    --project-nameCF Pages project name
    --branchmain = production, anything else = preview deployment
    --commit-dirtyDeploy even with uncommitted git changes

    4. Custom Domains

    Production domain

  • CF Dashboard > Pages > Your Project > Custom Domains
  • Add domain: mysite.com
  • CF automatically creates CNAME records if the domain is on Cloudflare DNS
  • Subdomain for staging

    Staging deploys to .my-site.pages.dev automatically. For a custom staging subdomain:

  • Add custom domain: staging.mysite.com
  • Create CNAME: staging -> my-site.pages.dev
  • Deploy with --branch=staging to target it
  • Subdomain microsites

    For microsites like landing.mysite.com:

  • Create separate CF Pages project: my-landing
  • Add custom domain: landing.mysite.com
  • Create CNAME: landing -> my-landing.pages.dev
  • Deploy: npx wrangler pages deploy site/my-landing --project-name=my-landing --branch=main
  • DNS records

    TypeNameContentProxy
    CNAME@my-site.pages.devProxied
    CNAMEwwwmy-site.pages.devProxied
    CNAMEstagingmy-site.pages.devProxied
    CNAMElandingmy-landing.pages.devProxied

    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), 302 for temporary
    • Trailing slashes matter: /blog/ and /blog are 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")

    `

    If you hit the 2000 limit: Use CF Workers for dynamic redirect logic instead of the static file.

    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:

  • Pages > Project > Settings > Environment Variables
  • Add variable for Production and/or Preview
  • 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 = async (context) => {

    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 = async (context) => {

    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 = async (context) => {

    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:

    SettingValue
    Build commandnpm run build
    Build outputdist
    Root directorysite/aw (if monorepo)
    Node version20

    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

    AspectPreviewProduction
    BranchAny non-mainmain
    URL.project.pages.devCustom domain
    IndexedNo (X-Robots-Tag: noindex)Yes
    SecretsUses preview environmentUses 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 --project-name=my-site

    `


    10. Troubleshooting

    Cache purge

    After deploying, if you see stale content:

  • CF Dashboard > Caching > Purge Everything
  • Or via API:
  • `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}'

    `

  • Browser: Hard refresh with Ctrl+Shift+R
  • Build failures

    "Error: Cannot find module..."
    • Run npm install locally before deploying
    • Ensure node_modules/ exists in the project directory
    "Error: Build output directory not found"
    • Astro outputs to dist/ — make sure to deploy dist/, not the project root
    • Check the deploy script deploys the correct directory
    "413: Request Entity Too Large"
    • 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
    Redirect not working
    • 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 as public/)
    • For Astro: functions/ sits alongside src/, 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

  • Verify DNS records are proxied (orange cloud) in CF Dashboard
  • Wait 5-10 minutes for DNS propagation
  • Check SSL certificate status: Dashboard > Pages > Project > Custom Domains
  • Ensure the domain's zone is on the same CF account as the Pages project

  • 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 in public/ 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