Deploying on Cloudflare Pages: Git Integration, Wrangler, Pages Functions and a Vercel/Netlify Comparison
Key takeaways
A static site on Pages is easy until you need secrets, an apex domain or an API route. This post covers dashboard Git builds versus Wrangler deploys from GitHub Actions, how Pages Functions map files to routes and middleware, local development with environment variables, build caching, and how Pages compares with Vercel and Netlify.
Introduction
Cloudflare Pages is a platform for deploying static sites and Server Side Rendering (SSR) apps to a global Edge network. Similar to Vercel/Netlify, but strengths include unlimited free bandwidth, 300+ city CDN, and integration with Workers, D1, R2. This article covers GitHub repository integration, Wrangler CLI deployment, environment variables, custom domains, Functions (Edge server logic), build optimization, and comparison with Vercel through practical examples. For overall Node.js app deployment, see Node.js Deployment Guide (PM2, Docker, AWS, Nginx), and for CI/CD pipeline, see Node.js + GitHub Actions CI/CD.
Reality in Practice
When learning development, everything seems clean and theoretical. But practice is different. You wrestle with legacy code, chase tight deadlines, and face unexpected bugs. The content covered in this article was initially learned as theory, but it was through applying it to actual projects that I realized “Ah, this is why it’s designed this way.” What stands out in my memory is the trial and error from my first project. I did everything by the book but couldn’t figure out why it wasn’t working, spending days struggling. Eventually, through a senior developer’s code review, I discovered the problem and learned a lot in the process. In this article, I’ll cover not just theory but also the pitfalls you might encounter in practice and how to solve them.
What is Cloudflare Pages?
Core Features
| Item | Description |
|---|---|
| CDN | 300+ cities worldwide, Cloudflare network |
| Free Plan | 500 builds/month, unlimited requests/bandwidth |
| Supported Frameworks | React, Vue, Astro, Next.js, SvelteKit, Remix, etc. |
| SSR/Edge | Cloudflare Workers-based Functions |
| Build Environment | Node.js, Python, Ruby, Go, etc. |
When to Use
- Static Sites: Blogs, documentation, landing pages
- JAMstack: Astro, Hugo, Jekyll, Eleventy
- SSR Apps: Next.js App Router, SvelteKit, Remix
- Edge API: Cloudflare Workers + D1(SQLite) + R2(S3 compatible) If traffic is global, bandwidth cost is a concern, or you want to run server logic on Edge, Cloudflare Pages is a strong choice.
GitHub Integration Deployment
The simplest method is connecting GitHub repository from Cloudflare dashboard.
2-1. Basic Setup
- Cloudflare Dashboard → Pages → Create a project
- Connect to Git → Connect GitHub account
- Select repository → Branch (
mainorproduction) - Build settings:
- Framework preset: Auto-detect Astro, Next.js, React, etc.
- Build command:
npm run build - Build output directory:
dist(Astro),out(Next.js),.next(Next.js SSR)
- Save and Deploy
2-2. Automatic Deployment
- Push to
mainbranch → Automatic build and deploy - Preview deployment created for each PR (URL:
<branch>.<project>.pages.dev) - Build logs viewable in real-time from dashboard
Pros: Easy setup, automatic PR preview.
Cons: Consumes Cloudflare build environment (500 free/month), limited build time/cache control.
Wrangler CLI Deployment
Wrangler is Cloudflare’s official CLI, allowing local build → upload to save Cloudflare build count.
3-1. Install Wrangler
npm install -g wrangler
# Or project local
npm install --save-dev wrangler
3-2. Authentication
wrangler login
Authenticate Cloudflare account in browser.
3-3. Deploy
# Build locally
npm run build
# Upload dist folder to Cloudflare Pages
wrangler pages deploy dist --project-name=my-project
On first deployment, project is automatically created if it doesn’t exist.
3-4. Using Wrangler in GitHub Actions
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max-old-space-size=4096'
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=my-project
Required secrets:
CLOUDFLARE_API_TOKEN: Dashboard → API Tokens → Edit Cloudflare Workers permissionCLOUDFLARE_ACCOUNT_ID: Check in dashboard URL (dash.cloudflare.com/<ACCOUNT_ID>/...) Pros: Full build environment control, free cache strategy, saves Cloudflare build count.
Environment Variables and Secrets
4-1. Set in Cloudflare Dashboard
Pages project → Settings → Environment variables
- Production: Used for
mainbranch deployment - Preview: Used for PR/branch deployment
DATABASE_URL=postgresql://...
API_KEY=abc123
Accessible during both build time (npm run build) and runtime (Functions).
4-2. Access in Code
Build Time (Node.js):
// astro.config.mjs, next.config.js, etc.
const apiKey = process.env.API_KEY;
Runtime (Cloudflare Functions):
// functions/api/data.js
export async function onRequest(context) {
const apiKey = context.env.API_KEY;
return new Response(JSON.stringify({ key: apiKey }));
}
4-3. Local Development
.dev.vars file (add to .gitignore):
DATABASE_URL=postgresql://localhost/dev
API_KEY=dev-key-123
wrangler pages dev dist --local
Custom Domain Setup
5-1. Add Domain
Pages project → Custom domains → Set up a custom domain
- Enter domain (e.g.,
blog.example.com) - Add DNS record:
- CNAME:
blog→<project>.pages.dev - Or A/AAAA: IP provided by Cloudflare
- CNAME:
- Automatic SSL issuance (Let’s Encrypt, free)
5-2. Apex Domain (example.com)
If managing domain with Cloudflare:
- CNAME flattening automatically applied
- Add
example.com→<project>.pages.devCNAME For other DNS providers, need to add A record with Cloudflare IP.
Functions (Edge Server Logic)
Cloudflare Pages Functions are Cloudflare Workers based, running server code on Edge.
6-1. Basic Structure
my-project/
├── functions/
│ ├── api/
│ │ ├── hello.js # /api/hello
│ │ └── users/[id].js # /api/users/:id
│ └── _middleware.js # Applied to all requests
├── public/
└── dist/
6-2. Example: API Endpoint
// functions/api/hello.js
export async function onRequest(context) {
const { request, env, params } = context;
return new Response(
JSON.stringify({ message: 'Hello from Edge!' }),
{ headers: { 'Content-Type': 'application/json' } }
);
}
After deployment, access at https://my-project.pages.dev/api/hello.
6-3. Dynamic Routes
// functions/api/users/[id].js
export async function onRequest(context) {
const userId = context.params.id;
// D1 (Cloudflare SQLite) example
const db = context.env.DB;
const user = await db.prepare('SELECT * FROM users WHERE id = ?')
.bind(userId)
.first();
return new Response(JSON.stringify(user), {
headers: { 'Content-Type': 'application/json' }
});
}
6-4. Middleware
// functions/_middleware.js
export async function onRequest(context) {
const start = Date.now();
// Execute next handler
const response = await context.next();
// Add response header
response.headers.set('X-Response-Time', `${Date.now() - start}ms`);
return response;
}
6-5. SSR Frameworks
Astro SSR:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server', // or 'hybrid'
adapter: cloudflare()
});
Next.js:
npm install @cloudflare/next-on-pages
// next.config.js
module.exports = {
experimental: {
runtime: 'experimental-edge'
}
};
Build Optimization
7-1. GitHub Actions Build Cache
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
node_modules
.astro
.next/cache
key: ${{ runner.os }}-build-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-build-
7-2. Reduce Build Time
Parallel Processing:
// scripts/build.mjs
import { execSync } from 'child_process';
const tasks = [
'node scripts/generate-og-images.mjs',
'node scripts/generate-sitemap.mjs',
'node scripts/generate-rss.mjs'
];
await Promise.all(tasks.map(cmd =>
execSync(cmd, { stdio: 'inherit' })
));
execSync('astro build', { stdio: 'inherit' });
Incremental Build:
- Astro: Cache
.astrofolder - Next.js: Cache
.next/cachefolder
7-3. Large Page Optimization
Problem: 1,000+ pages → 10+ minute build Solution:
- OG Image Cache: Regenerate only changed posts
- Static Pages First:
output: 'static'orhybrid - Parallel Rendering: Astro 4.0+ automatic parallelization
// astro.config.mjs
export default defineConfig({
output: 'static',
build: {
concurrency: 8 // Parallel rendering
}
});
Vercel/Netlify Comparison
8-1. Feature Comparison Table
| Item | Cloudflare Pages | Vercel | Netlify |
|---|---|---|---|
| Free Builds | 500/month | 6,000 min/month | 300 min/month |
| Free Bandwidth | Unlimited | 100GB/month | 100GB/month |
| CDN Locations | 300+ cities | 100+ cities | 100+ cities |
| Edge Functions | Workers (V8) | Edge Functions (V8) | Edge Functions (Deno) |
| DB Integration | D1 (SQLite), R2 (S3) | Vercel Postgres, Blob | Netlify Blobs |
| Build Time | Medium | Fast (Next.js optimized) | Medium |
| DX | Good | Very Good | Good |
8-2. Selection Criteria
Choose Cloudflare Pages when:
- High global traffic and bandwidth cost is a concern
- Using Cloudflare ecosystem like Workers, D1, R2
- Need unlimited bandwidth on free plan
- Building blog with static generators like Astro/Hugo (Astro Blog Guide reference) Choose Vercel when:
- Using Next.js App Router and prioritizing developer experience
- Build time is important and many preview deployments
- Using Vercel Analytics/Speed Insights Choose Netlify when:
- Need Form processing, Identity(auth), Split Testing
- Prefer Deno-based Edge Functions
Real Example: Astro Blog Deployment
9-1. Project Structure
my-blog/
├── src/
│ ├── pages/
│ ├── content/
│ └── components/
├── public/
├── functions/
│ └── api/
│ └── views.js # View count API
├── astro.config.mjs
├── wrangler.toml
└── package.json
9-2. GitHub Actions Workflow
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Validate frontmatter
run: npm run validate
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max-old-space-size=4096'
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name=my-blog --commit-message="Deploy from GitHub Actions"
9-3. View Count API (Functions)
// functions/api/views.js
export async function onRequest(context) {
const { request, env } = context;
const url = new URL(request.url);
const slug = url.searchParams.get('slug');
if (!slug) {
return new Response('Missing slug', { status: 400 });
}
// D1 (Cloudflare SQLite)
const db = env.DB;
if (request.method === 'POST') {
// Increment view count
await db.prepare('INSERT INTO views (slug, count) VALUES (?, 1) ON CONFLICT(slug) DO UPDATE SET count = count + 1')
.bind(slug)
.run();
}
// Query view count
const result = await db.prepare('SELECT count FROM views WHERE slug = ?')
.bind(slug)
.first();
return new Response(
JSON.stringify({ slug, views: result?.count || 0 }),
{ headers: { 'Content-Type': 'application/json' } }
);
}
D1 Binding (wrangler.toml):
name = "my-blog"
pages_build_output_dir = "dist"
[[d1_databases]]
binding = "DB"
database_name = "my-blog-db"
database_id = "abc123..."
Advanced Tips
10-1. Redirects
_redirects file (include in build output folder):
/old-url /new-url 301
/blog/* /posts/:splat 302
Or _headers:
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
10-2. Limit Preview Branches
# .github/workflows/deploy-cloudflare.yml
on:
push:
branches: [main]
# Leave PR preview to Cloudflare automatic deployment
10-3. Build Failure Notification
- name: Notify on failure
if: failure()
run: |
curl -X POST ${{ secrets.SLACK_WEBHOOK }} \
-H 'Content-Type: application/json' \
-d '{"text":"Cloudflare Pages deployment failed!"}'
10-4. Rollback
Dashboard:
- Deployments → Select previous deployment → Rollback to this deployment CLI:
wrangler pages deployment list --project-name=my-blog
wrangler pages deployment rollback <deployment-id>
Summary
Key Summary
Cloudflare Pages Advantages:
- Unlimited free bandwidth
- 300+ city global CDN
- Workers, D1, R2 integration
- SSR/Edge Functions support Deployment Methods:
- GitHub Integration: Easiest, automatic preview
- Wrangler CLI: Build control, save count Recommended Configuration:
- Local/CI: Build in GitHub Actions + Wrangler upload
- Preview: Cloudflare automatic deployment
- Environment Variables: Separate Production/Preview in dashboard
- Monitoring: Cloudflare Analytics + Sentry
Checklist
Before Deployment:
- Check build command (
npm run build) - Check output directory (
dist,out,.next) - Set environment variables (API keys, DB URL)
- Add
.env,.dev.varsto.gitignoreAfter Deployment: - Check custom domain DNS propagation (up to 24 hours)
- Check SSL certificate issuance
- Test Functions operation
- Check 404 page
Next Steps
Good articles to use with Cloudflare Pages:
- Astro + Cloudflare Pages Stack Analysis — Compare with Vercel, Netlify, WordPress
- Building Tech Blog with Astro References:
- Cloudflare Pages Official Documentation
- Wrangler CLI Documentation
- Cloudflare Workers Documentation
Frequently Asked Questions (FAQ)
Q. Why is my environment variable undefined inside a Pages Function?
A. Pages Functions do not use process.env; runtime variables and secrets are read from context.env, as shown in the Access in Code section. process.env only works during the build step, where the value is baked into the output. Also check that the variable is defined for the right environment (Production vs Preview) in the dashboard, and for local testing put it in .dev.vars before running wrangler pages dev.
Related Articles
- Astro + Cloudflare Pages Blog Stack Analysis
- Building a Tech Blog with Astro
- Linux & Mac Command Cheatsheet for Developers