Deploy a Static Site to Cloudflare Pages
Table of Contents
- Deploy a Static Site to Cloudflare Pages
- Tools Available
- Phase 0: Understand the Repository
- Phase 1: Verify Cloudflare Auth
- Phase 2: Create the Pages Project
- Phase 3: Deploy Script
- Phase 4: First Deploy
- Phase 5: Custom Domain (If CUSTOM_DOMAIN is not “none”)
- Phase 6: CI/CD with GitHub Actions (If WANTS_CICD is “yes”)
- Phase 7: Gitignore
- Phase 8: Verify and Report
- Reference: Discovering wrangler Commands
- Reference: Cloudflare MCP Setup
- Troubleshooting
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 documentationcloudflare-bindings(https://bindings.mcp.cloudflare.com/mcp) — list accounts, workers, KV, R2, D1cloudflare-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 --versionIf 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.
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/, orbuild/ - Obsidian vault: Needs a publish tool (e.g., Quartz) to produce HTML first — the vault itself is not deployable
Build command — Check
package.jsonscripts,Makefile,justfile, or framework config. Record the exact command, or note “none” for pre-built/plain sites.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.devsubdomain. - Custom domain: Will the site use a custom domain? If yes, what is it? Will
wwwalso 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 loginwhich 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 mainIf 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:
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 previewNote: 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.tomlis 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 herePhase 4: First Deploy
DO: Run the build (if any), then deploy:
npx wrangler pages deploy BUILD_OUTPUT_DIR --project-name PROJECT_NAME --branch mainCritical: 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 --jsonCheck the first entry in the JSON output:
Environmentshould be"Production"Branchshould be"main"Deploymentcontains 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 -10A 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:
- Log into the Cloudflare dashboard
- Add
CUSTOM_DOMAINas a zone (if not already added)- Cloudflare will assign you two nameservers (e.g.,
ns1.cloudflare.com,ns2.cloudflare.com)- At your domain registrar, update the nameservers to the ones Cloudflare assigned
- Wait for propagation (usually minutes, can take up to 24h)
Once the zone is active:
- Go to Workers & Pages in the Cloudflare dashboard
- Select the PROJECT_NAME project
- Go to the Custom domains tab
- Click Set up a domain
- Enter
CUSTOM_DOMAINand confirm- 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:
- Custom domains tab > Set up a domain
- Enter
www.CUSTOM_DOMAIN- Cloudflare auto-creates the CNAME record
For a subdomain on external DNS (e.g., blog.example.com):
INSTRUCT the user:
- In the Cloudflare dashboard: Workers & Pages > PROJECT_NAME > Custom domains > Set up a domain
- Enter the subdomain (e.g.,
blog.example.com)- Then at your DNS provider, add a CNAME record:
Type Name Content CNAME blog.example.comPROJECT_NAME.pages.devImportant: 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 --jsonThe Project Domains field for PROJECT_NAME should now include the custom domain(s). Then confirm it resolves:
curl -sI https://CUSTOM_DOMAIN | head -10If 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:
CLOUDFLARE_ACCOUNT_ID: Your account ID is<the Account ID recorded in Phase 1>
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/actionsLet 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_NAMEUncomment 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.
Project exists:
npx wrangler pages project list --jsonConfirm
PROJECT_NAMEappears in output.Latest deploy is production:
npx wrangler pages deployment list --project-name PROJECT_NAME --environment production --jsonConfirm the latest entry shows
Environment: "Production"andBranch: "main".Site is reachable:
curl -sI https://PROJECT_NAME.pages.dev | head -5Confirm HTTP 200.
Custom domain resolves (if configured):
curl -sI https://CUSTOM_DOMAIN | head -5Confirm HTTP 200. If it fails, advise waiting for DNS propagation.
GitHub Actions workflow exists (if requested): Confirm
.github/workflows/deploy.ymlis present and properly configured.
Present a summary to the user:
Deployment configured. Here’s what was set up:
- Project:
PROJECT_NAMEon 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
listcommands accept--jsonfor machine-readable output — always prefer this over parsing tables --project-name(or--project) is required on most commands when there is nowrangler.toml--helpat 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
| Problem | Cause | Fix |
|---|---|---|
npx wrangler not found | Node.js not installed | Install 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 name | Choose 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_dir | Either add the field or delete wrangler.toml and use CLI flags |
| Custom domain shows 522 error | DNS record added before registering in Pages dashboard | Register domain in Pages dashboard first, then add DNS |
| Custom domain doesn’t resolve after setup | DNS propagation delay | Wait 5-10 minutes, can take up to 24h in rare cases |
npx wrangler pages deploy prompts for project name | No wrangler.toml and no --project-name flag | Always pass --project-name when not using wrangler.toml |
| Large file rejected | Cloudflare Pages has 25 MiB per-file limit | Optimize assets, or use R2 for large files and link to them |
wrangler login hangs | Browser didn’t open or OAuth wasn’t completed | Ask user to check browser for the Cloudflare auth page, or look for a URL in terminal output |
| Deploy succeeds but site shows old content | CDN cache or deployed to preview instead of production | Run deployment list --json to verify environment, wait a moment for cache |
Unwanted files uploaded when deploying . | Repo root includes non-web files | Wrangler auto-excludes .git/, node_modules/, and .gitignore matches; other files upload but are harmless |