Service tiers, competitive positioning, deployment quality gates, and automated cleanup for a rapid-prototyping workflow on Cloudflare Pages.
Cloudflare Pages ships a generous free tier that covers most early-stage and even moderate-production workloads. The paid upgrade path is the Workers Paid plan at $5/month — far below competitor thresholds — which unlocks higher build concurrency, more deployments, and Workers/Functions compute.
| Dimension | Free | Pro ($20/mo) | Business ($200/mo) |
|---|---|---|---|
| Deployments / month | 500 | 5,000 | 20,000 |
| Concurrent builds | 1 | 5 | 20 |
| Build timeout | 20 minutes (all tiers) | ||
| Files per site | 20,000 | 100,000 | 100,000 |
| Max file size | 25 MiB (all tiers) | ||
| Bandwidth | Unlimited — all tiers, no egress fees | ||
| Preview deployments | Unlimited active previews | ||
| Custom domains | Unlimited (contact CF if adding many) | ||
| Header rules | 100 max per _headers file |
||
| Redirects | 2,000 static + 100 dynamic | ||
| Workers Paid add-on | — | $5/mo — 10M requests included | |
Every deployment on Cloudflare Pages gets two kinds of automatically generated URLs — no DNS configuration required, no custom domain purchase, no setup delay. This is one of the platform's most practical features for a team that needs to put working software in front of customers fast.
Commit-level URLs — Each individual deploy gets a unique, permanent URL based on a short hash of the commit:
https://<COMMIT_HASH>.<PROJECT_NAME>.pages.dev
# Example:
https://373f31e2.qid-auth-demo.pages.dev
This URL is immutable. It will always point to exactly that version of the code, even after subsequent deploys. This makes it ideal for sharing a specific state of a prototype with a customer — the link won't break or change underneath them if the team keeps pushing code.
Branch-level URLs — Each branch also gets a stable, human-readable URL that always points to the latest deploy on that branch:
https://<BRANCH_NAME>.<PROJECT_NAME>.pages.dev
# Example:
https://staging.qid-auth-demo.pages.dev
https://feature-oauth.qid-auth-demo.pages.dev
This is the URL you share when you want the customer to always see the latest version — it auto-updates with every push to that branch. Share it once in a Slack thread or email, and the customer always gets the freshest build without needing a new link.
Production URL — The production branch (typically main) gets the root project URL:
https://qid-auth-demo.pages.dev
Add a custom domain at any time and it points to the same production deployment.
For a verified identity API company doing active development, these auto-generated URLs are a free, zero-config demo infrastructure. Push a branch, copy the URL, drop it in a customer conversation. No staging server to maintain, no deploy pipeline to babysit, no DNS propagation to wait for. The customer sees a live, globally-distributed build within seconds of a push.
Vercel and Netlify both offer preview URLs, but with meaningful constraints that make them less useful as a free prototyping tool:
deploy-preview-42--project.netlify.app), but the free tier caps bandwidth at 100 GB/month. A customer clicking through a demo eats into that cap. Branch-level stable URLs are less intuitive to configure than Cloudflare's automatic pattern.Cloudflare is the only platform where every commit and every branch gets a globally-distributed, SSL-enabled, unlimited-bandwidth URL on the free tier with no commercial-use restrictions. For a small team that needs to show working software to customers frequently, this is a significant operational advantage.
The three dominant platforms for frontend and full-stack deployment in 2026 are Cloudflare Pages, Vercel, and Netlify. Each has a meaningfully different pricing structure and developer experience. The right choice depends on your stack, team size, and cost sensitivity at scale.
| Dimension | Cloudflare Pages | Vercel | Netlify |
|---|---|---|---|
| Free bandwidth | Unlimited | 100 GB/mo | 100 GB/mo |
| Paid plan entry | $5/mo (Workers Paid) | $20/user/mo | $20/mo flat |
| Cost at 1 TB/mo traffic | $0 – $5 | $200 – $600 | $150 – $400 |
| Cold starts | <5 ms (V8 isolates) | 50 – 250 ms | 100 – 500 ms |
| Edge network | 330+ cities | ~20 regions | ~15 CDN PoPs |
| Object storage | R2 — zero egress | Blob Storage | Blobs (beta) |
| Next.js DX | Improving, not native | Best-in-class | Good |
| Deploy DX / polish | Rougher; CLI-oriented | Best dashboard UX | Clean, close second |
| Team seat pricing | No per-seat cost | Per-seat | Flat (unlimited members) |
For a company like QiD — building a verified identity API service and iterating rapidly through demos, proofs-of-concept, and internal tools — Cloudflare is the clear cost winner. Unlimited bandwidth on the free tier means prototype traffic costs nothing, and the rougher developer experience is a non-issue for a technical team comfortable with CLI-driven workflows. If a prototype graduates to production, the $5/month Workers Paid plan scales further than competitors at 10x the price.
QiD's development workflow involves rapid prototyping — building and iterating on API service components, developer-facing demos, and internal tools on short timelines. On Vercel or Netlify, each project's bandwidth and serverless invocations are metered — costs accumulate across projects and can spike unpredictably during testing or partner reviews. On Cloudflare, bandwidth is unlimited at every tier, including free. Spin up as many prototypes as needed, share URLs with stakeholders, and pay nothing until you need Workers compute.
This pairs directly with the auto-generated preview URLs described in Section 01. A customer asks for a change on a call — you push a fix, and within seconds you have a live URL you can text them. No staging server, no deploy queue, no "I'll send you the updated link tomorrow." The customer sees the change while the conversation is still warm. That speed of response is how a small team wins product-market fit against larger incumbents: the customer feels heard, sees proof immediately, and builds trust in QiD's ability to ship. The branch-level URL (https://staging.qid-auth-demo.pages.dev) stays the same across pushes, so you share it once and every subsequent fix is automatically visible — no follow-up texts needed.
Most prototypes have a short useful life. Cloudflare Pages makes this easy to manage: each prototype is its own Pages project with its own deployment history. When a prototype has served its purpose, the project and all its deployments can be deleted cleanly. Old deployments that linger beyond 180 days can be pruned automatically (see Section 05). This keeps the account tidy and the free-tier deployment budget available for active work.
Cloudflare's 330+ city edge network means prototypes and documentation load fast regardless of where the user is. Workers' V8 isolate model eliminates the cold-start latency that plagues Lambda-backed competitors — important when a partner or developer clicks a demo link for the first time and first impressions matter.
Prototypes that involve file uploads, image galleries, or document processing can use R2 for S3-compatible object storage with zero egress fees. Where AWS S3 would charge ~$90 to transfer 1 TB out, R2 charges $0. This removes cost anxiety from asset-heavy demos.
When a prototype graduates to production, the $5/month Workers Paid plan provides 10 million requests, 5 concurrent builds, and 5,000 monthly deployments. No migration needed; the same Pages project just scales up.
demo.qid.dev). Internal experiments stay on the default *.pages.dev URL. When a prototype concludes, delete the project entirely — or let the automated cleanup script handle stale deployments.
Cloudflare Pages doesn't impose an opinionated CI/CD pipeline, so quality gates must be built deliberately. Here's a layered approach for any QiD project deployed to Cloudflare.
By default, every commit to every branch triggers a build. Lock this down immediately:
main for each portal project.staging/*, feat/*, and fix/*. This prevents random experimental branches from consuming your 500 monthly deploy budget.[CF-Pages-Skip] as a commit-message prefix when pushing documentation or config-only changes that don't need a build.Preview deployment URLs are public by default. For pre-launch prototypes or anything touching sensitive identity data, this should be locked down.
*.<project>.pages.dev previews to your Cloudflare account email.staging.demo-acme.qid.dev), you must create a separate Access application for that domain — the Pages access policy only covers *.pages.dev subdomains.*.pages.dev subdomain. Your custom domain (e.g., demo.qid.dev) is not covered by this policy. If you need to gate a custom domain too (e.g., a pre-launch prototype), create a separate Access application for that domain.
Cloudflare Pages' built-in builds are minimal — they just run your build command. Layer a proper CI pipeline in front:
# .github/workflows/ci.yml — runs before Cloudflare Pages build
name: QiD CI
on:
pull_request:
branches: [main, staging/*]
jobs:
quality-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run lint # ESLint
- run: npm run type-check # TypeScript strict
- run: npm run test # Vitest / Jest
- run: npm run build # Catch build failures before CF
Configure branch protection rules on GitHub to require this workflow to pass before merging to main.
For any project that graduates to production, consider requiring manual approval before production deploys:
environment: production gate that requires reviewer approval.Cloudflare Pages automatically adds X-Robots-Tag: noindex to all preview deployments, preventing search engines from indexing duplicate content. No action needed — this is on by default. Verify it hasn't been overridden in your _headers file.
Never commit API keys to your repo. Use Cloudflare Pages' Environment Variables panel (under Settings → Environment variables) and mark sensitive values as Encrypted. Set different values for Production and Preview environments. This applies to any third-party service credentials (database URLs, API tokens, etc.).
Preview deployments accumulate indefinitely on Cloudflare Pages. Over months of rapid prototyping, stale deployments pile up — consuming visual clutter in the dashboard and making it harder to find what's current. The script below deletes any deployment older than 180 days that hasn't been revisited, across all specified projects.
DRY_RUN flag below) before executing deletions.
#!/usr/bin/env bash
# ──────────────────────────────────────────────────────────────
# cleanup-old-deployments.sh
# Deletes Cloudflare Pages deployments older than 180 days
# that have not been "revisited" (modified_on == created_on).
#
# Requirements: curl, jq, date (GNU coreutils)
# Usage: ./cleanup-old-deployments.sh
# ──────────────────────────────────────────────────────────────
# ─── CONFIGURATION ────────────────────────────────────────────
ACCOUNT_ID="YOUR_CLOUDFLARE_ACCOUNT_ID"
API_TOKEN="YOUR_CLOUDFLARE_API_TOKEN"
# QiD projects — add/remove as prototypes are created
PROJECTS=(
"demo-acme-dashboard"
"prototype-intake-form"
"internal-tooling-v2"
# Add new project names here as they're created
)
MAX_AGE_DAYS=180
DRY_RUN=true # Set to false to actually delete
PER_PAGE=50 # API pagination size (max 100)
RATE_LIMIT=0.3 # Seconds between API calls
# ─── COMPUTED ─────────────────────────────────────────────────
CUTOFF_EPOCH=$(date -d "-${MAX_AGE_DAYS} days" +%s 2>/dev/null \
|| date -v-${MAX_AGE_DAYS}d +%s) # GNU || macOS fallback
CUTOFF_ISO=$(date -d "@${CUTOFF_EPOCH}" -Iseconds 2>/dev/null \
|| date -r "${CUTOFF_EPOCH}" -Iseconds)
BASE_URL="https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/pages/projects"
TOTAL_DELETED=0
TOTAL_SKIPPED=0
echo "═══════════════════════════════════════════════════════"
echo " Cloudflare Pages Deployment Cleanup"
echo " Cutoff: ${CUTOFF_ISO} (${MAX_AGE_DAYS} days ago)"
echo " Dry run: ${DRY_RUN}"
echo " Projects: ${#PROJECTS[@]}"
echo "═══════════════════════════════════════════════════════"
echo ""
for PROJECT in "${PROJECTS[@]}"; do
echo "──── Project: ${PROJECT} ────"
PAGE=1
PROJECT_DELETED=0
PROJECT_SKIPPED=0
while true; do
# Fetch a page of deployments
RESPONSE=$(curl -s \
"${BASE_URL}/${PROJECT}/deployments?per_page=${PER_PAGE}&page=${PAGE}" \
-H "Authorization: Bearer ${API_TOKEN}" \
-H "Content-Type: application/json")
# Check for API errors
SUCCESS=$(echo "${RESPONSE}" | jq -r '.success')
if [ "${SUCCESS}" != "true" ]; then
echo " ✗ API error for ${PROJECT}: $(echo ${RESPONSE} | jq -r '.errors[0].message // "Unknown"')"
break
fi
# Extract deployments as JSON lines
DEPLOYMENTS=$(echo "${RESPONSE}" | jq -c '.result[]')
# Exit pagination if no results
if [ -z "${DEPLOYMENTS}" ]; then
break
fi
while IFS= read -r deploy; do
DEPLOY_ID=$(echo "${deploy}" | jq -r '.id')
CREATED=$(echo "${deploy}" | jq -r '.created_on')
MODIFIED=$(echo "${deploy}" | jq -r '.modified_on')
ENV=$(echo "${deploy}" | jq -r '.environment')
ALIAS=$(echo "${deploy}" | jq -r '.aliases[0] // "none"')
# Parse created_on to epoch
CREATED_EPOCH=$(date -d "${CREATED}" +%s 2>/dev/null \
|| date -jf "%Y-%m-%dT%H:%M:%S" "${CREATED%%.*}" +%s)
# Parse modified_on to epoch
MODIFIED_EPOCH=$(date -d "${MODIFIED}" +%s 2>/dev/null \
|| date -jf "%Y-%m-%dT%H:%M:%S" "${MODIFIED%%.*}" +%s)
# Skip if modified recently (someone revisited it)
if [ "${MODIFIED_EPOCH}" -gt "${CUTOFF_EPOCH}" ]; then
PROJECT_SKIPPED=$((PROJECT_SKIPPED + 1))
continue
fi
# Skip if created after cutoff
if [ "${CREATED_EPOCH}" -gt "${CUTOFF_EPOCH}" ]; then
PROJECT_SKIPPED=$((PROJECT_SKIPPED + 1))
continue
fi
# This deployment is old and not revisited — delete it
if [ "${DRY_RUN}" = true ]; then
echo " [DRY RUN] Would delete: ${DEPLOY_ID} (${ENV}) created ${CREATED}"
else
echo " Deleting: ${DEPLOY_ID} (${ENV}) created ${CREATED}"
curl -s -X DELETE \
"${BASE_URL}/${PROJECT}/deployments/${DEPLOY_ID}" \
-H "Authorization: Bearer ${API_TOKEN}" > /dev/null
sleep "${RATE_LIMIT}"
fi
PROJECT_DELETED=$((PROJECT_DELETED + 1))
done <<< "${DEPLOYMENTS}"
PAGE=$((PAGE + 1))
done
echo " → Deleted: ${PROJECT_DELETED} Skipped: ${PROJECT_SKIPPED}"
echo ""
TOTAL_DELETED=$((TOTAL_DELETED + PROJECT_DELETED))
TOTAL_SKIPPED=$((TOTAL_SKIPPED + PROJECT_SKIPPED))
done
echo "═══════════════════════════════════════════════════════"
echo " Complete. Deleted: ${TOTAL_DELETED} Skipped: ${TOTAL_SKIPPED}"
echo "═══════════════════════════════════════════════════════"
modified_on timestamp is older than this cutoff is eligible for deletion.modified_on is more recent than the cutoff, the deployment is skipped — someone or something has interacted with it recently.DRY_RUN=true flag logs what would be deleted without touching anything. Set to false only after reviewing the dry-run output.# 1. Make it executable
chmod +x cleanup-old-deployments.sh
# 2. Dry run first — review the output
./cleanup-old-deployments.sh
# 3. When satisfied, edit DRY_RUN=false and run again
./cleanup-old-deployments.sh
# 4. Schedule monthly via cron (first Monday at 2am)
0 2 1-7 * 1 /path/to/cleanup-old-deployments.sh >> /var/log/cf-cleanup.log 2>&1
schedule: cron: '0 2 1 * *' for monthly cleanup on the first of each month.
The GitHub integration is what turns Cloudflare Pages from a hosting platform into a deployment pipeline. Once connected, every git push becomes a live, globally-distributed build — no deploy commands, no CLI steps, no manual intervention. Here's how it works and how to configure it optimally for QiD.
When you connect a GitHub repo to a Cloudflare Pages project, four things happen automatically with zero additional configuration:
main and production updates. Push to feat/oauth-flow and a preview deployment appears.[CF-Pages-Skip] (or [CI Skip], [Skip CI]) and Cloudflare will skip the build entirely. Useful for README changes, config tweaks, or documentation commits that don't need a deploy.# Step-by-step:
# 1. Cloudflare Dashboard → Workers & Pages → Create project
# 2. Select "Connect to Git" → Authorize the Cloudflare GitHub App
# 3. Grant access to the QiD org (or specific repos)
# 4. Select the repo → configure build settings:
#
# Production branch: main
# Build command: npm run build
# Build output dir: dist/ (or build/, out/, etc.)
#
# 5. Deploy — Cloudflare runs the first build immediately.
#
# That's it. Every subsequent push triggers a build automatically.
The default behavior — build every commit on every branch — burns through the free-tier deploy budget fast during active development. Lock it down to branches that matter:
# In Cloudflare Dashboard → your project → Settings → Builds → Branch control
Production branch: main
Preview branches: Custom
Include: staging/*, feat/*, fix/*
Exclude: (leave empty)
# This means:
# - Pushes to main → production deploy
# - Pushes to staging/*, feat/*, fix/* → preview deploy
# - Pushes to any other branch (docs/*, chore/*, wip/*) → no build
#
# Wildcard (*) matches zero or more characters.
# A branch named "feat/oauth" matches "feat/*".
Pair the Cloudflare integration with GitHub's own guardrails to create a clean deployment pipeline:
main — Require PR reviews before merging. Require the Cloudflare Pages check run to pass. Disable direct pushes to main. This means nothing reaches production without a passing build and a human sign-off.CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN as repository secrets (Settings → Secrets → Actions). These are needed if you layer GitHub Actions on top of the native integration (for CI gates, cleanup scripts, or PR comment bots).The native Git integration handles builds and deploys. GitHub Actions handles everything around it — linting, testing, notifications, and cleanup. Here's a workflow that runs CI checks, waits for the Cloudflare build to complete, and posts the preview URL as a PR comment:
# .github/workflows/deploy.yml
name: QiD Deploy Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm run lint
- run: npm run type-check
- run: npm run test
deploy:
needs: ci
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
contents: read
deployments: write
pull-requests: write
steps:
- uses: actions/checkout@v4
- name: Deploy to Cloudflare Pages
id: deploy
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: >-
pages deploy dist
--project-name=YOUR_PROJECT
--branch=${{ github.head_ref }}
- name: Comment preview URL on PR
uses: thollander/actions-comment-pull-request@v3
with:
message: |
## ⚡ Deployed to Cloudflare Pages
| **Commit:** | `${{ github.event.pull_request.head.sha }}` |
|:--|:--|
| **Preview:** | ${{ steps.deploy.outputs.deployment-url }} |
| **Branch:** | ${{ steps.deploy.outputs.pages-deployment-alias-url }} |
comment-tag: cf-deploy
Cloudflare Pages allows only one project per GitHub repo via the native integration. If QiD ever consolidates multiple prototypes into a monorepo, you have two options:
wrangler pages deploy in a GitHub Actions workflow with path filters in the on.push.paths trigger. This gives you full control over which subdirectory deploys to which Pages project.Here's what the optimal QiD setup looks like end-to-end:
# Developer pushes a branch
git push origin feat/verify-endpoint
│
├──→ GitHub Actions: lint, type-check, test
│ └── must pass before merge is allowed
│
├──→ Cloudflare Pages: build + deploy preview
│ └── preview URL posted as PR check run
│
└──→ Developer texts branch URL to customer:
https://feat-verify-endpoint.qid-auth.pages.dev
# Customer gives feedback → developer pushes fix
git push origin feat/verify-endpoint
│
└──→ Same branch URL auto-updates. Customer refreshes.
# PR approved → merge to main
│
└──→ Cloudflare Pages: production deploy
https://qid-auth.pages.dev (+ custom domain)