Production cheatsheet from ActiveWizards deployment (CF Pages + Functions + DNS).
Wrangler CLI
# Deploy a static site (pre-built)
npx wrangler pages deploy dist --project-name=my-site --branch=main
# Deploy with dirty git state (CI/local)
npx wrangler pages deploy dist --project-name=my-site --commit-dirty=true --branch=main
# Set secrets (not visible in dashboard)
npx wrangler pages secret put MAILGUN_API_KEY
# List projects
npx wrangler pages list
# Tail logs (Functions)
npx wrangler pages deployment tail --project-name=my-site
Deploy Script Pattern (multi-site)
#!/usr/bin/env bash
set -euo pipefail
SITE_DIR="$(cd "$(dirname "$0")" && pwd)/site"
deploy_astro() {
local site="$1" project="$2" branch="${3:-staging}"
(cd "${SITE_DIR}/${site}" && npm run build)
npx wrangler pages deploy "${SITE_DIR}/${site}/dist" \
--project-name="${project}" --commit-dirty=true --branch="${branch}"
}
deploy_static() {
local site="$1" project="$2"
npx wrangler pages deploy "${SITE_DIR}/${site}" \
--project-name="${project}" --commit-dirty=true --branch=main
}
case "${1:-}" in
staging) deploy_astro my-site my-project staging ;;
prod) deploy_astro my-site my-project main ;;
*) echo "Usage: $0 [staging|prod]"; exit 1 ;;
esac
_redirects File
Place in public/_redirects (copied to dist/ on build).
Format
<from> <to> <status>
Examples
# Simple redirect
/old-url /new-url 301
# With trailing slash normalization
/contact/ /contact-us/ 301
# Splat (wildcard)
/old-blog/* /blog/:splat 301
# SPA fallback (DON'T use for SSG sites)
/* /index.html 200
Rules
- Max 2000 rules (hard limit)
- Processed top to bottom, first match wins
- Splats:
*captures everything,:splatinserts it in destination - Placeholders:
/blog/:slugmatches/blog/anything - Static files take priority over redirects (a redirect won't override an actual file)
- Duplicate rules are silently ignored (no error, first one wins)
Production note
AW site has 804+ redirect rules for MODx migration. Bulk-generated by scripts/migration_decision.py.
Pages Functions
Files in functions/ directory map to API routes:
| File path | Route |
functions/api/contact.ts | POST /api/contact |
functions/api/[id].ts | GET /api/:id |
functions/_middleware.ts | Runs on all routes |
Basic Function
interface Env { MAILGUN_API_KEY: string; MAILGUN_DOMAIN: string; NOTIFICATION_EMAIL: string; } export const onRequestPost: PagesFunction<Env> = async (context) => { const { request, env } = context; const formData = await request.formData(); const name = formData.get("name")?.toString().trim() || ""; const email = formData.get("email")?.toString().trim() || ""; if (!name || !email) { return new Response(JSON.stringify({ error: "Missing fields" }), { status: 400, headers: { "Content-Type": "application/json" }, }); } // Fire-and-forget with waitUntil (response returns immediately) context.waitUntil( fetch(, { method: "POST", headers: { Authorization: "Basic " + btoa(https://api.mailgun.net/v3/${env.MAILGUN_DOMAIN}/messagesapi:${env.MAILGUN_API_KEY}) }, body: buildFormData(name, email, env), }).catch((err) => console.error("Mailgun failed:", err)) ); return new Response(JSON.stringify({ success: true }), { status: 200, headers: { "Content-Type": "application/json" }, }); };
Key patterns
context.waitUntil(): Fire-and-forget async work. Response returns immediately while the email sends in the background.onRequestPost/onRequestGet: Method-specific handlers.onRequest: Catches all methods.- Env typing:
PagesFunctiongives typed access tocontext.env.
Dual response (JSON + redirect)
function jsonOrRedirect(request: Request, data: object, redirectUrl: string): Response {
const accept = request.headers.get("Accept") || "";
if (accept.includes("application/json")) {
return new Response(JSON.stringify(data), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return Response.redirect(new URL(redirectUrl, request.url).toString(), 302);
}
This handles both fetch() (returns JSON) and submissions (redirects).
Custom Domains
Subdomain (e.g., staging.example.com)
staging -> project-name.pages.dev (proxied)Root domain (e.g., example.com)
Preview deployments
Every non-main branch gets a URL:
Deploy to staging branch:
npx wrangler pages deploy dist --project-name=my-site --branch=staging
Staging URL: staging.
Environment Variables & Secrets
Dashboard
Settings -> Environment variables -> Add variable
- Can set different values for Production vs Preview
- Visible in dashboard (not suitable for API keys)
CLI (for secrets)
npx wrangler pages secret put MAILGUN_API_KEY
# Prompts for value (not stored in shell history)
In Functions
Access via context.env.VARIABLE_NAME (typed with PagesFunction).
In Astro (build-time)
CF Pages build environment variables are available as process.env.VARIABLE during build.
For client-side: use PUBLIC_ prefix and import.meta.env.PUBLIC_*.
Headers (public/_headers)
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
/fonts/*
Cache-Control: public, max-age=31536000, immutable
/*.css
Cache-Control: public, max-age=31536000, immutable
KV Bindings (for Functions)
In wrangler.toml:
[[kv_namespaces]]
binding = "CACHE"
id = "abc123"
Access in function: context.env.CACHE.get("key") / .put("key", "value")
Cache & Purge
- Static assets: automatic CDN caching
- Purge all: Dashboard -> Caching -> Configuration -> Purge Everything
- Purge by URL: API or Dashboard
- Pages Functions: not cached by default (dynamic)
Build Configuration (Dashboard)
| Setting | Value |
| Framework preset | Astro |
| Build command | npm run build |
| Build output directory | dist |
| Root directory | / (or subfolder if monorepo) |
| Node.js version | Set NODE_VERSION=20 in env vars |
Cloudflare + External Services
Behind Cloudflare (proxied)
If origin server should only accept CF traffic:
# UFW rules on origin server — allow only Cloudflare IPs on 80/443
# See https://www.cloudflare.com/ips/ for current list
Firewall Rules
- Dashboard -> Security -> WAF -> Custom rules
- Rate limiting: Security -> WAF -> Rate limiting rules
- Bot protection: free tier includes basic bot detection
Common Gotchas
_redirects in public/: Must be in public/ for Astro (gets copied to dist/). Putting it in dist/ directly gets overwritten on build./api/*).wrangler pages secret put overwrites immediately. No restart needed — Functions pick up new values on next invocation.commit-dirty=true: Required flag when deploying from a repo with uncommitted changes. Without it, wrangler may refuse to deploy.--branch=main = production. Any other branch name = preview. This is how staging/production separation works.@astrojs/cloudflare adapter (not needed for SSG sites).astro-compress to reduce file count where possible.