Cloudflare Pages skill for Codex

Headers and redirects that actually apply. Codex deploys Pages sites from the correct build output, lints _headers and _redirects with a bundled script before each deploy, and limits Pages Functions to the routes that need them. It knows which rules skip Function responses, keeps preview URLs out of search indexes, and says when a new project belongs on Workers with static assets instead.

  • Cloud Platforms
  • $cloudflare-pages
  • 222 lines
  • 3 files
  • ~2,100 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- cloudflare-pages

Installs to ~/.agents/skills/cloudflare-pages/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $cloudflare-pages, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Deploys static and full-stack sites to Cloudflare Pages - Git-connected builds and Direct Upload with wrangler pages deploy, preview deployments per branch, _headers and _redirects files, Pages Functions in the functions directory with middleware and bindings, _routes.json, custom domains, and when to move to Workers with static assets instead. Use when deploying a site to Pages, adding security headers or redirects, writing a Pages Function or middleware, protecting preview URLs, or debugging rules that do not apply.

Use it when

  • Deploying a static site or SPA to Cloudflare Pages
  • Adding security headers, caching headers or redirects
  • Writing Pages Functions or middleware
  • Rules in _headers or _redirects do not seem to apply

Use something else when

How to check the result

The rules linter exits 0, curl shows the expected headers and redirect Location values on production, and preview hostnames return noindex or an Access login.

The mistake it prevents

Expecting _headers and _redirects to apply to responses generated by Pages Functions, which they skip.

~/.agents/skills/cloudflare-pages/SKILL.md

--- name: cloudflare-pages description: Deploys static and full-stack sites to Cloudflare Pages - Git-connected builds and Direct Upload with wrangler pages deploy, preview deployments per… license: MIT ---

Done well means: the build output directory is correct and reproducible, _headers and _redirects pass the bundled linter and live in that output directory, Functions run only on routes that need them, preview deployments are not publicly indexable, and production changes ship only from the production branch.

Pages or Workers with static assets?

Cloudflare now steers new full-stack projects toward Workers with static assets; Pages remains supported and is still the simplest path for Git-connected static sites.

SituationChoose
Static site or SPA, Git push to deploy, per-branch previewsPages
Existing Pages project with FunctionsStay on Pages unless you need a Workers-only feature
New app needing Durable Objects, Queues consumers, cron, or fine-grained configWorkers with assets (see cloudflare-workers)
Framework adapter defaults to Workers (recent Astro, Next via OpenNext, etc.)Follow the adapter’s default

Inspect first

  1. Read AGENTS.md, package.json scripts and the framework config to learn the build command and output directory (dist, build, out, .svelte-kit/cloudflare).
  2. Check for a Pages config (wrangler.toml / wrangler.jsonc with pages_build_output_dir) and whether the project is Git-connected or Direct Upload:
npx wrangler pages project list                  # needs network and auth
npx wrangler pages deployment list --project-name=shop-site

A Direct Upload project cannot later be switched to Git integration; you would create a new project. Ask before creating one.

Configuration

# wrangler.toml for a Pages project
name = "shop-site"
pages_build_output_dir = "./dist"
compatibility_date = "2026-10-01"
compatibility_flags = ["nodejs_compat"]

[[kv_namespaces]]
binding = "SESSIONS"
id = "<kv-id>"

[env.production.vars]
API_ORIGIN = "https://api.example.com"

When this file exists it is the source of truth for bindings; dashboard edits to the same settings are overridden. Build settings (command, Node version via NODE_VERSION or .node-version) stay in the dashboard or CI.

Deploy

Git-connected: push to the production branch for production, any other branch for a preview at <branch>.<project>.pages.dev plus a unique <hash>.<project>.pages.dev URL.

Direct Upload from CI:

npm ci && npm run build
python3 scripts/check_pages_rules.py dist
npx wrangler pages deploy dist --project-name=shop-site --branch="$BRANCH"

--branch=main (or whatever the production branch is) publishes to production. Treat that as a production deploy: confirm with the user unless it runs from the agreed CI pipeline. Rollback is a dashboard action (or API call) on a previous deployment; note its ID first.

_headers

Must sit in the build output directory (copy it from public/ or static/).

/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()
  Strict-Transport-Security: max-age=31536000; includeSubDomains

/assets/*
  Cache-Control: public, max-age=31536000, immutable

https://:project.pages.dev/*
  X-Robots-Tag: noindex

/embed/*
  ! X-Frame-Options
  • Applies only to static asset responses. Responses produced by Pages Functions do not get these headers; set them in code or middleware.
  • Multiple matching rules merge; the same header from two rules is joined with a comma.
  • The *.pages.dev rule keeps preview and default hostnames out of search indexes.
  • CSP is easier to iterate on as Content-Security-Policy-Report-Only first.

_redirects

/home              /                      301
/blog/*            /posts/:splat          301
/docs/:version/*   /v/:version/:splat     302
/app/*             /app/index.html        200
  • Format: source destination [status], default 302. Supported: 301, 302, 303, 307, 308, and 200 for rewrites to a path inside the site.
  • First matching rule wins; put specific rules before wildcards.
  • Limits: 2,000 static and 100 dynamic rules. For thousands of redirects or domain-level redirects use Bulk Redirects on the zone.
  • Requests handled by Functions skip _redirects; implement those redirects in the Function or exclude the route from Functions.
  • SPAs: if there is no 404.html, Pages already serves index.html for unknown paths; a catch-all /* /index.html 200 is usually unnecessary.

Run the linter before every deploy:

python3 scripts/check_pages_rules.py dist   # exit 1 on syntax errors, bad codes, external 200s, over-limit counts

Pages Functions

File-based routing under functions/ at the project root:

FileRoute
functions/api/health.ts/api/health
functions/api/products/[id].ts/api/products/:id (context.params.id)
functions/api/files/[[path]].ts/api/files/* (context.params.path is an array)
functions/_middleware.tsRuns before every Function and static asset below it
// functions/api/products/[id].ts
interface Env { DB: D1Database }

export const onRequestGet: PagesFunction<Env> = async ({ params, env }) => {
  const row = await env.DB.prepare("SELECT id, name, price FROM products WHERE id = ?1")
    .bind(params.id).first();
  return row ? Response.json(row) : new Response("not found", { status: 404 });
};
// functions/_middleware.ts
export const onRequest: PagesFunction = async ({ request, next }) => {
  const res = await next();
  const out = new Response(res.body, res);
  out.headers.set("X-Content-Type-Options", "nosniff");
  if (new URL(request.url).pathname.startsWith("/api/")) out.headers.set("Cache-Control", "no-store");
  return out;
};

Every request that hits a Function is a billable Functions invocation. Limit them with _routes.json in the output directory (generated automatically for many frameworks):

{ "version": 1, "include": ["/api/*"], "exclude": ["/api/public/*"] }

Up to 100 combined include/exclude rules; exclude wins over include.

Local dev: npx wrangler pages dev dist (or -- npm run dev style proxy for frameworks). Generate binding types with npx wrangler types.

Preview protection

  • Preview URLs are public by default. Protect them with Cloudflare Access on the project’s preview deployments setting if the branch contains unreleased or private content.
  • Preview builds can use different env vars and bindings ([env.preview]); never point previews at production databases unless the user insists.

Custom domains

Add domains in the project (dashboard or API). Apex domains require the zone to be on Cloudflare; subdomains can use a CNAME to <project>.pages.dev but must also be added to the project, or requests fail with a 522. Redirect www to apex (or the reverse) with a zone redirect rule rather than _redirects.

Gotchas

  • _headers/_redirects in the repo root instead of the output folder are silently ignored.
  • Max 25 MiB per file and a cap on file count per deployment (about 20,000 on Free); large media belongs in R2.
  • A framework adapter may emit _worker.js (advanced mode), which disables the functions/ directory.
  • Build cache and Node version differences between local and Pages builds explain most “works locally” failures; pin NODE_VERSION.
  • Environment variable changes apply to the next deployment, not the current one.

Verification

python3 scripts/check_pages_rules.py dist
curl -sI https://shop.example.com/ | grep -iE 'strict-transport|x-content-type'
curl -sI https://shop.example.com/blog/hello | grep -i '^location'
curl -sI https://<hash>.shop-site.pages.dev/ | grep -i x-robots-tag
  • Headers appear on static responses; Function responses carry headers set in middleware.
  • Each redirect returns the expected status and Location.
  • Preview hostname returns X-Robots-Tag: noindex or an Access login.

Report template

**Project:** shop-site (Git-connected, production branch main, output dist)
**Rules:** _headers 4 rules, _redirects 12 static / 3 dynamic, linter clean
**Functions:** /api/* only via _routes.json; middleware adds security headers
**Previews:** Access-protected, noindex on *.pages.dev
**Deployed:** deployment <id> to production; previous <id> kept for rollback

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Cloudflare Pages"
  short_description: "Headers and redirects that actually apply"
  default_prompt: "$cloudflare-pages Deploy this Vite site to Cloudflare Pages from GitHub Actions with preview builds per branch"
scripts/check_pages_rules.py143 lines, 5.7 KB
#!/usr/bin/env python3
"""Lint Cloudflare Pages _redirects and _headers files before deploying.

Usage:
    python3 check_pages_rules.py <build-output-dir>

Looks for <dir>/_redirects and <dir>/_headers (either may be absent) and checks
syntax, status codes, rule-count limits, line lengths, duplicate sources, self
redirects and external 200 rewrites. Prints one line per problem and exits 1 if
any error was found, 0 otherwise. Standard library only.

Limits used (from Cloudflare's Pages docs; re-check if they change):
  _redirects: 2,000 static + 100 dynamic rules, 1,000 chars per rule
  _headers:   100 rules, 2,000 chars per line
"""
import re
import sys
from pathlib import Path

VALID_CODES = {"200", "301", "302", "303", "307", "308"}
MAX_STATIC, MAX_DYNAMIC, MAX_REDIRECT_LINE = 2000, 100, 1000
MAX_HEADER_RULES, MAX_HEADER_LINE = 100, 2000
HEADER_NAME = re.compile(r"^[!]?\s*[A-Za-z0-9!#$%&'*+.^_`|~-]+$")


def check_redirects(path):
    errors, warnings = [], []
    static = dynamic = 0
    seen = {}
    for n, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        where = f"{path.name}:{n}"
        if len(line) > MAX_REDIRECT_LINE:
            errors.append(f"{where}: rule longer than {MAX_REDIRECT_LINE} chars")
        parts = line.split()
        if len(parts) not in (2, 3):
            errors.append(f"{where}: expected 'source destination [code]', got {len(parts)} fields")
            continue
        src, dst = parts[0], parts[1]
        code = parts[2] if len(parts) == 3 else "302"
        if code.endswith("!"):
            errors.append(f"{where}: forced '!' codes are not supported on Pages")
            code = code[:-1]
        if code not in VALID_CODES:
            errors.append(f"{where}: status {code} not in {sorted(VALID_CODES)}")
        if not src.startswith("/"):
            errors.append(f"{where}: source must be a path starting with '/' (domain-level redirects need Bulk Redirects)")
        if code == "200" and re.match(r"^https?://", dst):
            errors.append(f"{where}: 200 rewrites (proxying) only work to relative paths, not external URLs")
        if src == dst:
            errors.append(f"{where}: redirect to itself loops")
        if "*" in src or ":" in src:
            dynamic += 1
        else:
            static += 1
        if src in seen:
            warnings.append(f"{where}: duplicate source {src} (line {seen[src]} wins, first match applies)")
        else:
            seen[src] = n
        if ":splat" in dst and "*" not in src:
            errors.append(f"{where}: destination uses :splat but source has no '*'")
    if static > MAX_STATIC:
        errors.append(f"{path.name}: {static} static rules exceeds {MAX_STATIC}")
    if dynamic > MAX_DYNAMIC:
        errors.append(f"{path.name}: {dynamic} dynamic rules exceeds {MAX_DYNAMIC}")
    summary = f"{path.name}: {static} static, {dynamic} dynamic rules"
    return errors, warnings, summary


def check_headers(path):
    errors, warnings = [], []
    rules = 0
    current = None
    has_headers = False
    for n, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        where = f"{path.name}:{n}"
        if len(raw) > MAX_HEADER_LINE:
            errors.append(f"{where}: line longer than {MAX_HEADER_LINE} chars")
        if not raw.strip() or raw.strip().startswith("#"):
            continue
        if not raw[0].isspace():
            if current is not None and not has_headers:
                warnings.append(f"{path.name}: pattern '{current}' has no headers")
            current, has_headers = raw.strip(), False
            rules += 1
            if not (current.startswith("/") or current.startswith("https://")):
                errors.append(f"{where}: URL pattern must start with '/' or 'https://'")
            continue
        if current is None:
            errors.append(f"{where}: indented header before any URL pattern")
            continue
        body = raw.strip()
        if body.startswith("!"):
            if not HEADER_NAME.match(body):
                errors.append(f"{where}: detach line must be '! Header-Name'")
            has_headers = True
            continue
        if ":" not in body:
            errors.append(f"{where}: header line needs 'Name: value'")
            continue
        name = body.split(":", 1)[0].strip()
        if not HEADER_NAME.match(name):
            errors.append(f"{where}: invalid header name '{name}'")
        has_headers = True
    if current is not None and not has_headers:
        warnings.append(f"{path.name}: pattern '{current}' has no headers")
    if rules > MAX_HEADER_RULES:
        errors.append(f"{path.name}: {rules} rules exceeds {MAX_HEADER_RULES}")
    return errors, warnings, f"{path.name}: {rules} rules"


def main():
    if len(sys.argv) != 2:
        print("usage: python3 check_pages_rules.py <build-output-dir>", file=sys.stderr)
        return 2
    out = Path(sys.argv[1])
    if not out.is_dir():
        print(f"error: {out} is not a directory", file=sys.stderr)
        return 2
    errors, warnings = [], []
    found = False
    for name, fn in (("_redirects", check_redirects), ("_headers", check_headers)):
        p = out / name
        if p.exists():
            found = True
            e, w, s = fn(p)
            errors += e
            warnings += w
            print(s)
    if not found:
        print(f"no _redirects or _headers in {out} (they must be in the build output directory)")
    for w in warnings:
        print(f"warn:  {w}")
    for e in errors:
        print(f"error: {e}")
    print(f"{len(errors)} error(s), {len(warnings)} warning(s)")
    return 1 if errors else 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem