---
name: cloudflare-hosting
description: "Use when deploying a web project to Cloudflare (Workers or Pages), wiring a custom domain, or fixing a broken deploy. Ships three execution modes — wrangler CLI, browser automation, or step-by-step human guidance."
version: 1.0.0
author: Pranav Chavda (built with Hermes)
license: MIT
platforms: [linux, macos, windows]
metadata:
  hermes:
    tags: [cloudflare, workers, pages, deploy, hosting, wrangler, dns, static-site]
    related_skills: [github, web-scraping]
    verified: "2026-10-24"
---

# Deploying to Cloudflare

Take a local web project from `npm run dev` to a public HTTPS URL on Cloudflare's **free tier**, without breaking it, without leaking a secret, and without spending money.

Cloudflare's free plan genuinely hosts a static site or SPA for ₹0 forever: static asset requests are **not metered on any plan**. That is the whole reason this skill exists — most "hosting is expensive" advice is wrong for this category of project.

## When to use

- "Put my project online" / "deploy this" / "host this on Cloudflare"
- "Add my domain to this site" / "why does my custom domain show an error"
- "Why does my deployed app 404 on refresh / show a blank page"
- Migrating a Vibecoded/Vite/Next/Astro app from a laptop to a real URL

**Don't** use this for: serverful apps needing a long-running process or Postgres connection pooling (Cloudflare Workers are not that — point the user at Railway/Render/Fly), or for deploying to Netlify/Vercel (different config, different failure modes).

## Ask this first — one question, then act

> "I can do this three ways: **(A)** wrangler CLI — fastest and most reliable, **(B)** drive the Cloudflare dashboard in a browser for you, or **(C)** walk you through it click by click so you learn it. Which?"

Default to **A** when you have shell access. Use **B** only if the user has no Node/npm or explicitly wants the dashboard. Use **C** when you cannot execute anything, or when the user says they want to learn.

Every mode ends at the same place: **Modes → Verification**. Never claim success without running it.

---

## Mode A — wrangler CLI (preferred)

### A1. Recon, don't assume

```bash
ls -a                      # find package.json, framework config, lockfile
cat package.json | head -40
```

Determine **build command** and **output directory** from the framework, don't guess:

| Framework | Build command | Output dir | Notes |
|---|---|---|---|
| Vite (React/Vue/Svelte) | `npm run build` | `dist` | SPA by default |
| Next.js (static export) | `npx next build` | `out` | needs `output: 'export'` in next.config |
| Next.js (SSR/route handlers) | — | — | **stop**: needs the OpenNext adapter, out of scope here |
| Astro | `npm run build` | `dist` | |
| SvelteKit | `npm run build` | `.svelte-kit/cloudflare` | needs the cloudflare adapter |
| Remix | `npm run build` | `build/client` | |
| CRA | `npm run build` | `build` | |
| Plain HTML/CSS/JS | *(none)* | the folder itself | set `directory` to `.` |
| Monorepo | as above | as above | set `directory` to the app's output, e.g. `apps/web/dist` |

Confirm the output dir actually contains `index.html` **before** going further:

```bash
npm ci && npm run build && ls dist/index.html
```

If the build fails, stop here and fix the build — no host can rescue a failing build. Common causes: unpinned Node version, missing lockfile, TypeScript errors that `vite dev` never surfaced, case-sensitive imports.

### A2. Auth

Interactive (a human is present, they can open a browser):

```bash
npx wrangler login          # opens browser, stores OAuth in ~/.wrangler
npx wrangler whoami         # confirm account + scopes
```

Headless / CI / remote box — use a scoped API token instead. **Never ask the user to paste the token into the chat.** Tell them to export it in their own shell:

```bash
export CLOUDFLARE_API_TOKEN=...      # see scopes below
export CLOUDFLARE_ACCOUNT_ID=...     # wrangler whoami shows it
npx wrangler whoami
```

Minimum token permissions (create at dash.cloudflare.com → My Profile → API Tokens → *Edit Cloudflare Workers* template):

| Scope | Permission | Needed for |
|---|---|---|
| Account → Workers Scripts | Edit | deploy |
| Account → Workers KV / R2 / D1 | Edit | only if the app uses them |
| Zone → Workers Routes | Edit | custom domain |
| Zone → DNS | Edit | wiring the custom domain |

### A3. Write `wrangler.jsonc`

Static site or SPA — no server code:

```jsonc
{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "my-app",
  "compatibility_date": "2026-10-24",
  "assets": {
    "directory": "./dist",
    "not_found_handling": "single-page-application"
  }
}
```

`not_found_handling` choices: `"single-page-application"` serves your root document for unmatched paths (fixes the deep-link 404), `"404-page"` serves `/404.html`, `"none"` returns a bare 404. **Pick SPA for anything with client-side routing.**

Adding an API — same file, add `main` + `binding`:

```jsonc
{
  "name": "my-app",
  "main": "src/index.js",
  "compatibility_date": "2026-10-24",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS",
    "not_found_handling": "single-page-application",
    "run_worker_first": ["/api/*"]
  }
}
```

```js
export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    if (url.pathname.startsWith("/api/")) return Response.json({ ok: true });
    return env.ASSETS.fetch(request);   // everything else falls through to the files
  }
};
```

Note: on the free plan, paths matched by `run_worker_first` return HTTP 429 once the daily request cap is hit — they do **not** silently fall back to static.

### A4. Deploy and verify

```bash
npx wrangler deploy           # ships script + assets in one operation
```

Then — and this is not optional —

```bash
BASE=https://<name>.<subdomain>.workers.dev
curl -sI $BASE | head -3                          # expect 200
curl -s $BASE | grep -o '<title>[^<]*</title>'    # expect the real title, not a CF error page
curl -s -o /dev/null -w '%{http_code}\n' $BASE/some/deep/path   # 200, not 404
curl -s -o /dev/null -w '%{http_code}\n' $BASE/assets/$(curl -s $BASE | grep -oE '[^"/]+\.js' | head -1)
```

All four must pass. If the deep-path check returns 404, your `not_found_handling` is wrong or missing. If the asset check 404s, the output directory is wrong (you deployed the source tree, not the build).

### A5. Secrets

```bash
npx wrangler secret put OPENAI_API_KEY    # reads from stdin, encrypts, deploys a new version
```

Local dev reads `.dev.vars` (dotenv syntax) — **must be in `.gitignore`**. Plain config values can go in `"vars": {}` in the config; anything that authenticates is a secret.

---

## Mode B — browser automation

Use only when CLI is impossible or the user asked for the dashboard. Prefer the user's own logged-in browser profile; if a login wall appears, **stop and hand it back to the human** — never attempt credentials, never ask for a password.

1. `https://dash.cloudflare.com/` → verify the account owns the intended domain (check the zone list before assuming).
2. `Workers & Pages` → `Create` → `Connect to Git` (or `Pages` tab for a Pages project) → authorise GitHub → pick the repo.
3. Set: project name, production branch (`main`), **build command** and **output directory** from the recon table in A1. Set root directory for monorepos.
4. If Cloudflare offers autoconfig, it opens a **PR** adding `wrangler.jsonc` — tell the user to merge it rather than blindly merging for them.
5. Deploy, wait for the build log to go green, then read the URL off the deployment page.
6. Go to **Modes → Verification** and run the same curl checks from the shell. Do not trust the dashboard's green checkmark alone.

Fragility warning: dashboard UI changes often. If a selector or label you expect isn't there, screenshot, describe what you see, and ask — do not click around hunting for a matching button.

---

## Mode C — human-guided

Numbered instructions, one step per message, and say what they should *see* after each. Keep it to this skeleton:

1. **Create the repo** — the project must be on GitHub for the easy path. `git init && git add -A && git commit -m "deploy" && gh repo create --source=. --push` (or the web UI).
2. **Sign in to Cloudflare** — dash.cloudflare.com, free plan is enough.
3. **Workers & Pages → Create → Connect to Git** → choose the repo.
4. **Fill in exactly these fields** — from A1's table: build command, output directory, root directory.
5. **Deploy** → watch the build log → report the URL back.
6. **Test** — hit the URL, then paste a deep link into a fresh tab (this is the one people skip, and it's where SPAs break).
7. **Custom domain** — see below; expect a few minutes for DNS and certificate.
8. **Report what you saw** — URL, build status, any red text in the log.

Ask for the build log text when something fails; it names the real cause 90% of the time.

---

## Custom domain

First ask: **is the domain's DNS already on Cloudflare?**

**If yes** (zone visible in the dashboard): Worker → Settings → Domains & Routes → Add → Custom Domain → enter `app.example.com`. Cloudflare creates the record and issues a certificate automatically. Nothing else to do.

**If the domain is elsewhere, and you want `app.example.com`:** add a `CNAME` at the current DNS host pointing to `<name>.<subdomain>.workers.dev`. No nameserver change needed *for Pages*; **Workers Custom Domains require the hostname to be inside a Cloudflare zone**, so for Workers you must either move the zone or use a Worker Route instead.

**If you need the apex (`example.com`):** apex records can't be CNAMEs. Move the zone to Cloudflare — change the registrar's nameservers to the pair Cloudflare assigns, then wait (up to 24h, usually minutes).

TLS gotchas worth checking before you blame the host:

- **Certificate stuck "pending" → the record is wrong or proxied.** If the zone is on Cloudflare and the record is orange-clouded while an origin (Netlify, Vercel, another host) issues its own cert, validation fails. Set **DNS-only (grey)** until the cert is active.
- **`ERR_TOO_MANY_REDIRECTS`** is an SSL-mode problem, not a DNS one. Flexible SSL in front of an origin that already forces HTTPS loops forever → use **Full (strict)**.
- **Apex and `www` both serving** → pick one canonical host and 301 the other. Don't set apex→www at the host *and* www→apex at DNS.

Verify:

```bash
dig +short app.example.com                  # the address the host told you
curl -sI https://app.example.com | head -3  # 200 over https
curl -sI http://app.example.com | head -3   # exactly one 301 to the https canonical
```

---

## Verification (all modes)

Run every line. Report the actual output, not a summary.

```bash
# 1. root serves your app
curl -s https://$HOST | grep -o '<title>[^<]*</title>'
# 2. deep link works cold (the SPA killer)
curl -s -o /dev/null -w '%{http_code}\n' https://$HOST/dashboard
# 3. assets resolve (wrong output dir shows up here)
for f in $(curl -s https://$HOST | grep -oE '/[^" ]+\.(js|css)' | sort -u | head -5); do
  printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' https://$HOST$f)" "$f"
done
# 4. no secrets baked into the client bundle
for f in $(curl -s https://$HOST | grep -oE '/[^" ]+\.js' | sort -u); do
  curl -s https://$HOST$f | grep -oE 'sk-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}|eyJhbGciOi[A-Za-z0-9]{20,}' \
    && echo "  ^ LEAKED KEY in $f"
done
# 5. https redirect hygiene
curl -sI http://$HOST | head -1
```

**Definition of done:** root 200 with the right title · deep link 200 · assets 200 · no leaked key · https 200 with one redirect from http. Anything less is not shipped.

Report back in this shape:

```
URL:            https://...
Deployed:       <worker name> @ <version id from wrangler output>
Checks:         root 200 · deep-link 200 · N/M assets 200 · secret scan clean · https ok
Custom domain:  <attached | pending DNS | n/a>
Free-tier risk: <none | 100k req/day cap relevant because...>
Left for you:   <things the human must do, with exact clicks>
```

---

## Troubleshooting

| Symptom | Real cause | Fix |
|---|---|---|
| `Wrong version of wrangler` / config schema errors | old global wrangler | always `npx wrangler@latest`, never a global install |
| Deploy "succeeds", site is blank | wrong output directory (deployed source, not build) or a build-time env var missing → **redeploy after adding vars** | check `assets.directory`; check the build log for the var |
| Deep link 404s, clicking works | no SPA fallback | `"not_found_handling": "single-page-application"` |
| Everything 404s after adding `_redirects` | Cloudflare `_redirects` rules **always** apply, unlike Netlify's — a `/* /index.html 200` rule shadows real assets | drop the catch-all; use `not_found_handling` instead |
| `Error 1027` | free-tier daily request cap (100k/day, resets 00:00 UTC) | wait, cache at the edge, or tell the user about the $5/mo Workers Paid plan — **ask before spending** |
| `Error 1102` | CPU limit exceeded (10 ms free) | move heavy work out of the request path, or discuss Workers Paid |
| `429` on API paths only | `run_worker_first` paths hit the cap and don't fall back | same as 1027 |
| Build image differs from laptop | Node version unpinned | set `NODE_VERSION` / `.nvmrc`, and `engines.node` in package.json |
| `1000 files` limit on upload | direct upload cap (drag-and-drop) | use `wrangler deploy` (20,000 files, 25 MiB each) |
| Certificate pending forever | record wrong or proxied | see Custom domain above |
| Env var "not working" | it was added after the last build | redeploy — Vite/Next inline them at build time |

## Limits you must state out loud before a user commits

| Thing | Free | Notes |
|---|---|---|
| Static asset requests | **unlimited** | not metered on any plan |
| Worker requests | 100,000/day | resets 00:00 UTC |
| Worker CPU | 10 ms/request | excludes time waiting on fetch/DB |
| File count | 20,000 · 25 MiB per file | per deployment |
| Builds (Git) | 500/month, 1 concurrent | 20-min timeout |
| Workers Paid | $5/mo | 10M req + 30M CPU-ms included |

Re-verify current numbers at `developers.cloudflare.com/workers/platform/limits/` and `.../pricing/` before quoting them as exact — these were checked **2026-10-24**.

## Safety rules

1. **Never** put a secret in chat, in `wrangler.jsonc`, or in a `VITE_`/`NEXT_PUBLIC_`/`REACT_APP_` variable. If the user shows you a key, treat it as burned and tell them to rotate it.
2. **Never** paste credentials into a browser, and never guess them. Login walls go back to the human.
3. **Ask before** anything that costs money, deletes a project/domain, or changes DNS for a domain that serves other things (mail, other subdomains). Free-tier deploys need no approval; plan upgrades do.
4. **Do not** touch a zone's nameservers or existing MX/A records to make your deploy work — pick a subdomain instead.
5. **Do not** claim a deploy succeeded from the dashboard's green tick or wrangler's exit code alone — run Verification.
6. Prod is not a testbed: deploy to a `*.workers.dev` hostname first, confirm, then attach the domain.
