Deploy a Static Site to Cloudflare Pages

11 min read2116 words Copy .md .md
Table of Contents

Deploy a Static Site to Cloudflare Pages

You are adding Cloudflare Pages static site deployment to this repository using the Direct Upload pattern. You will build locally (or in CI) and push built assets to Cloudflare’s edge network. There is no git integration on the Cloudflare side.

Follow this guide phase by phase. Do not skip phases. Where a step says ASK — stop and wait for the user’s answer. Where a step says INSTRUCT — present the instructions to the user for them to complete manually. Where a step says DO — execute it yourself.

Tools Available

Primary: wrangler CLI — Use npx wrangler for all Pages operations (create project, deploy, list deployments, manage secrets). This is the only tool that directly manages Cloudflare Pages.

Secondary: Cloudflare MCP — If configured, useful for account-level inspection only. The MCP servers do NOT have Pages-specific tools. Use them for:

  • cloudflare-docs (https://docs.mcp.cloudflare.com/mcp) — look up Cloudflare documentation
  • cloudflare-bindings (https://bindings.mcp.cloudflare.com/mcp) — list accounts, workers, KV, R2, D1
  • cloudflare-builds (https://builds.mcp.cloudflare.com/mcp) — inspect Workers build logs (not Pages)
  • cloudflare-observability (https://observability.mcp.cloudflare.com/mcp) — logs and analytics

For everything Pages-related (project creation, deployment, secrets, config download), use npx wrangler pages. Run npx wrangler pages --help to discover available subcommands. Many list commands accept --json for machine-readable output — always prefer --json over parsing tables.


Phase 0: Understand the Repository

DO: First, verify that npx and wrangler are available:

npx wrangler --version

If this fails, Node.js is not installed. INSTRUCT the user to install Node.js (v18+) before continuing.

DO: Examine the repo and determine the following. Present your findings to the user before proceeding.

  1. Build output directory — Where do the final static assets (HTML/CSS/JS/images) live?

    • No build step (plain HTML/assets): The repo root or a subfolder IS the output
    • Static site generator (Hugo, Jekyll, Eleventy, Astro, Quartz, etc.): Check config for output dir (public/, _site/, dist/, build/)
    • Frontend framework (Vite, Next.js static export, etc.): Usually dist/, out/, or build/
    • Obsidian vault: Needs a publish tool (e.g., Quartz) to produce HTML first — the vault itself is not deployable
  2. Build command — Check package.json scripts, Makefile, justfile, or framework config. Record the exact command, or note “none” for pre-built/plain sites.

  3. Existing deployment config — Check for wrangler.toml, wrangler.json, .github/workflows/*cloudflare*, or any deploy scripts already present.

ASK the user to confirm your findings, then ask:

  • Project name: Suggest one derived from the repo name. Must be lowercase, alphanumeric, hyphens only. This becomes the <PROJECT_NAME>.pages.dev subdomain.
  • Custom domain: Will the site use a custom domain? If yes, what is it? Will www also be needed?
  • CI/CD: Do they want GitHub Actions automated deployment, or just local deploys for now?

Wait for answers before proceeding. Record the values:

  • PROJECT_NAME = (user’s answer)
  • BUILD_OUTPUT_DIR = (from your analysis)
  • BUILD_COMMAND = (from your analysis, or “none”)
  • CUSTOM_DOMAIN = (user’s answer, or “none”)
  • WANTS_CICD = (yes/no)

Phase 1: Verify Cloudflare Auth

DO: Run npx wrangler whoami and check the exit code.

If exit code is 0: Record the Account ID from the table in the output. Continue to Phase 2.

If it fails (non-zero exit code — not authenticated):

INSTRUCT the user:

You need to authenticate with Cloudflare. I’ll run npx wrangler login which will open a browser window. Please complete the OAuth flow in your browser and then let me know when it’s done.

If the browser doesn’t open automatically, look for a URL in the terminal output and open it manually.

DO: Run npx wrangler login, then re-run npx wrangler whoami to confirm. Record the Account ID.

Note: OAuth login grants access based on the user’s Cloudflare account permissions. No scope selection is needed here — that only matters for API tokens (covered in Phase 6).


Phase 2: Create the Pages Project

DO: Run npx wrangler pages project list --json and check if PROJECT_NAME already exists in the output.

If it already exists: Confirm with the user that this is the right project, then skip to Phase 3.

If it does NOT exist:

DO: Run:

npx wrangler pages project create PROJECT_NAME --production-branch main

If this errors with a message about the name already being taken (by another Cloudflare account), ASK the user for an alternative project name. The name must be globally unique across all Cloudflare Pages projects.

This creates a Direct Upload project (no git provider). Deployments to branch main are production; any other branch name produces preview deployments.


Phase 3: Deploy Script

Choose the approach that fits the repo. Do NOT create a package.json if the project isn’t Node-based.

Important: deploying the repo root. If BUILD_OUTPUT_DIR is . (the repo root), wrangler will upload all files in the directory. It automatically excludes .git/, node_modules/, and files matched by .gitignore. However, be aware that any other non-web files (e.g., README.md, Makefile, config files) will be uploaded too. This is usually harmless but worth knowing.

If the repo has a package.json:

DO: Add deploy scripts to the existing package.json:

If there is NO build step:

{
  "scripts": {
    "deploy": "npx wrangler pages deploy BUILD_OUTPUT_DIR --project-name PROJECT_NAME --branch main --commit-hash \"$(git rev-parse HEAD)\" --commit-message \"$(git log -1 --pretty=%s)\"",
    "deploy:preview": "npx wrangler pages deploy BUILD_OUTPUT_DIR --project-name PROJECT_NAME --branch preview"
  }
}

If there IS a build step:

{
  "scripts": {
    "build": "BUILD_COMMAND",
    "deploy": "npm run build && npx wrangler pages deploy BUILD_OUTPUT_DIR --project-name PROJECT_NAME --branch main --commit-hash \"$(git rev-parse HEAD)\" --commit-message \"$(git log -1 --pretty=%s)\"",
    "deploy:preview": "npm run build && npx wrangler pages deploy BUILD_OUTPUT_DIR --project-name PROJECT_NAME --branch preview"
  }
}

If the repo does NOT have a package.json:

DO: Create a Makefile (or justfile if the project already uses just):

.PHONY: deploy deploy-preview

deploy:
	BUILD_COMMAND_HERE_OR_REMOVE_THIS_LINE
	npx wrangler pages deploy BUILD_OUTPUT_DIR --project-name PROJECT_NAME --branch main \
		--commit-hash "$$(git rev-parse HEAD)" \
		--commit-message "$$(git log -1 --pretty=%s)"

deploy-preview:
	BUILD_COMMAND_HERE_OR_REMOVE_THIS_LINE
	npx wrangler pages deploy BUILD_OUTPUT_DIR --project-name PROJECT_NAME --branch preview

Note: In Makefiles, $ must be escaped as $$ for shell variable expansion.

Why CLI flags instead of wrangler.toml

This guide uses explicit CLI flags (--project-name, --branch, positional directory) rather than a wrangler.toml with pages_build_output_dir. Reasons:

  • Simpler — no config file to maintain
  • More portable — the deploy command is self-documenting
  • Avoids the common “missing pages_build_output_dir” warning when wrangler.toml exists but is incomplete
  • A wrangler.toml is only needed if you add Pages Functions, KV bindings, or environment-specific variables later

If the project later needs bindings or environment overrides, a wrangler.toml can be added at that point:

name = "PROJECT_NAME"
pages_build_output_dir = "BUILD_OUTPUT_DIR"
compatibility_date = "YYYY-MM-DD"

[env.production]
# production-specific bindings here

[env.preview]
# preview-specific bindings here

Phase 4: First Deploy

DO: Run the build (if any), then deploy:

npx wrangler pages deploy BUILD_OUTPUT_DIR --project-name PROJECT_NAME --branch main

Critical: the --branch main flag. Without it, wrangler infers the branch from git. If the repo has no git history, is on a detached HEAD, or is on a non-main branch, the deploy will be marked as Preview instead of Production. Always pass --branch main explicitly for production deploys.

DO: Verify the deploy succeeded programmatically:

npx wrangler pages deployment list --project-name PROJECT_NAME --environment production --json

Check the first entry in the JSON output:

  • Environment should be "Production"
  • Branch should be "main"
  • Deployment contains the live URL (e.g., https://<HASH>.PROJECT_NAME.pages.dev)

DO: Verify the site is reachable:

curl -sI https://PROJECT_NAME.pages.dev | head -10

A 200 status confirms the site is live. Report the production URL to the user.


Phase 5: Custom Domain (If CUSTOM_DOMAIN is not “none”)

Custom domains are configured in the Cloudflare dashboard. You cannot do this via wrangler CLI. Present the following instructions to the user.

For an apex domain (e.g., example.com):

INSTRUCT the user:

Custom domain setup for CUSTOM_DOMAIN:

Your domain’s nameservers must point to Cloudflare. If they don’t already:

  1. Log into the Cloudflare dashboard
  2. Add CUSTOM_DOMAIN as a zone (if not already added)
  3. Cloudflare will assign you two nameservers (e.g., ns1.cloudflare.com, ns2.cloudflare.com)
  4. At your domain registrar, update the nameservers to the ones Cloudflare assigned
  5. Wait for propagation (usually minutes, can take up to 24h)

Once the zone is active:

  1. Go to Workers & Pages in the Cloudflare dashboard
  2. Select the PROJECT_NAME project
  3. Go to the Custom domains tab
  4. Click Set up a domain
  5. Enter CUSTOM_DOMAIN and confirm
  6. Cloudflare will automatically create the required DNS record

Let me know when this is done so I can verify.

For www subdomain (if requested):

INSTRUCT the user:

After the apex domain is active, repeat the same process:

  1. Custom domains tab > Set up a domain
  2. Enter www.CUSTOM_DOMAIN
  3. Cloudflare auto-creates the CNAME record

For a subdomain on external DNS (e.g., blog.example.com):

INSTRUCT the user:

  1. In the Cloudflare dashboard: Workers & Pages > PROJECT_NAME > Custom domains > Set up a domain
  2. Enter the subdomain (e.g., blog.example.com)
  3. Then at your DNS provider, add a CNAME record:
TypeNameContent
CNAMEblog.example.comPROJECT_NAME.pages.dev

Important: You must register the domain in the Pages dashboard FIRST, then add the DNS record. Doing it backwards causes a 522 error.

After the user confirms the domain setup:

DO: Verify by checking the project’s domain list:

npx wrangler pages project list --json

The Project Domains field for PROJECT_NAME should now include the custom domain(s). Then confirm it resolves:

curl -sI https://CUSTOM_DOMAIN | head -10

If curl returns a connection error, DNS may still be propagating. INSTRUCT the user: “DNS propagation can take a few minutes. If the domain doesn’t resolve yet, wait 5-10 minutes and try again. It can take up to 24 hours in rare cases.”


Phase 6: CI/CD with GitHub Actions (If WANTS_CICD is “yes”)

ASK the user:

  • Do they already have GitHub secrets set up for Cloudflare? (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID)
  • If not, they will need to create an API token.

INSTRUCT the user (if they need to create secrets):

You need two GitHub repository secrets:

  1. CLOUDFLARE_ACCOUNT_ID: Your account ID is <the Account ID recorded in Phase 1>

  2. CLOUDFLARE_API_TOKEN: Create one at https://dash.cloudflare.com/profile/api-tokens

    • Use the “Custom token” template
    • Permissions: Account > Cloudflare Pages > Edit
    • Account resources: Include the relevant account

Add both as secrets at: https://github.com/<OWNER>/<REPO>/settings/secrets/actions

Let me know when done.

After confirmation:

DO: Create .github/workflows/deploy.yml:

name: Deploy to Cloudflare Pages

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      deployments: write
    steps:
      - uses: actions/checkout@v4

      # BUILD_STEP_HERE (remove this comment block if no build step)
      # Example for Node projects:
      # - uses: actions/setup-node@v4
      #   with:
      #     node-version: "20"
      # - run: npm ci && npm run build

      - name: Deploy to Cloudflare Pages
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          command: pages deploy BUILD_OUTPUT_DIR --project-name=PROJECT_NAME

Uncomment and adapt the build step if needed. The wrangler action infers the branch from git context — this is reliable in CI because GitHub Actions checks out the correct branch. Pushes to main deploy to production; PR branches deploy as previews with unique URLs.


Phase 7: Gitignore

DO: Ensure .gitignore includes:

# Wrangler local state
.wrangler/

If the build output directory is generated (e.g., dist/, public/ from a build step), also add it to .gitignore. If there is no build step and the repo root IS the content, no additional entries are needed.


Phase 8: Verify and Report

DO: Run through this checklist programmatically and report results to the user.

  1. Project exists:

    npx wrangler pages project list --json

    Confirm PROJECT_NAME appears in output.

  2. Latest deploy is production:

    npx wrangler pages deployment list --project-name PROJECT_NAME --environment production --json

    Confirm the latest entry shows Environment: "Production" and Branch: "main".

  3. Site is reachable:

    curl -sI https://PROJECT_NAME.pages.dev | head -5

    Confirm HTTP 200.

  4. Custom domain resolves (if configured):

    curl -sI https://CUSTOM_DOMAIN | head -5

    Confirm HTTP 200. If it fails, advise waiting for DNS propagation.

  5. GitHub Actions workflow exists (if requested): Confirm .github/workflows/deploy.yml is present and properly configured.

Present a summary to the user:

Deployment configured. Here’s what was set up:

  • Project: PROJECT_NAME on Cloudflare Pages (Direct Upload)
  • Production URL: https://PROJECT_NAME.pages.dev
  • Custom domain: CUSTOM_DOMAIN (if applicable)
  • Deploy command: <the actual command from Phase 3>
  • CI/CD: GitHub Actions on push to main (if applicable)

Reference: Discovering wrangler Commands

Do not memorize command signatures — they change across wrangler versions. Instead, use --help to discover available commands and their flags at each level:

npx wrangler --help              # Top-level commands
npx wrangler pages --help        # Pages subcommands (deploy, project, deployment, secret, download)
npx wrangler pages deploy --help # Deploy flags (--project-name, --branch, --commit-hash, etc.)
npx wrangler pages project --help       # Project management (list, create, delete)
npx wrangler pages deployment --help    # Deployment inspection (list with --json, --environment)
npx wrangler pages secret --help        # Secret management (put, list, delete, bulk)

Key patterns to know:

  • Most list commands accept --json for machine-readable output — always prefer this over parsing tables
  • --project-name (or --project) is required on most commands when there is no wrangler.toml
  • --help at any level shows all available flags and positional arguments

Reference: Cloudflare MCP Setup

If the AI client does not yet have Cloudflare MCP configured, here is the config to add:

{
  "mcpServers": {
    "cloudflare-docs": {
      "command": "npx",
      "args": ["mcp-remote", "https://docs.mcp.cloudflare.com/mcp"]
    },
    "cloudflare-bindings": {
      "command": "npx",
      "args": ["mcp-remote", "https://bindings.mcp.cloudflare.com/mcp"]
    },
    "cloudflare-builds": {
      "command": "npx",
      "args": ["mcp-remote", "https://builds.mcp.cloudflare.com/mcp"]
    },
    "cloudflare-observability": {
      "command": "npx",
      "args": ["mcp-remote", "https://observability.mcp.cloudflare.com/mcp"]
    }
  }
}

Each server uses OAuth — a browser window opens on first use for the user to authenticate.

These MCP servers are useful for account-level inspection but do NOT have Pages-specific tools. All Pages operations go through npx wrangler pages.

Troubleshooting

ProblemCauseFix
npx wrangler not foundNode.js not installedInstall Node.js v18+
Deploy says “Preview” not “Production”Branch was not main (detached HEAD, wrong branch, or no git repo)Pass --branch main explicitly
Project create fails “name already taken”Another Cloudflare account owns that project nameChoose a different project name — names are globally unique
wrangler.toml warning “missing pages_build_output_dir”wrangler.toml exists but has no pages_build_output_dirEither add the field or delete wrangler.toml and use CLI flags
Custom domain shows 522 errorDNS record added before registering in Pages dashboardRegister domain in Pages dashboard first, then add DNS
Custom domain doesn’t resolve after setupDNS propagation delayWait 5-10 minutes, can take up to 24h in rare cases
npx wrangler pages deploy prompts for project nameNo wrangler.toml and no --project-name flagAlways pass --project-name when not using wrangler.toml
Large file rejectedCloudflare Pages has 25 MiB per-file limitOptimize assets, or use R2 for large files and link to them
wrangler login hangsBrowser didn’t open or OAuth wasn’t completedAsk user to check browser for the Cloudflare auth page, or look for a URL in terminal output
Deploy succeeds but site shows old contentCDN cache or deployed to preview instead of productionRun deployment list --json to verify environment, wait a moment for cache
Unwanted files uploaded when deploying .Repo root includes non-web filesWrangler auto-excludes .git/, node_modules/, and .gitignore matches; other files upload but are harmless
2116 words11 min read