· astro / cloudflare / cloudflare-pages
How to Deploy an Astro Site on Cloudflare Pages: Setup Guide
Cloudflare Pages deploys Astro for free with unlimited bandwidth. Step-by-step: adapter install, monorepo root directory fix, env vars, and custom domain.
By Ethan
1,485 words · 8 min read
Cloudflare Pages is the shortest path from an Astro project to a globally deployed site. Static builds are free with unlimited bandwidth. SSR runs on the Workers runtime at the edge, with 100,000 function invocations per day on the free tier. The setup takes under twenty minutes — if you know the one gotcha that trips up every monorepo user (covered in step 3).
This guide uses @astrojs/cloudflare v12 (the last version with official Cloudflare Pages support — v13, released March 2026, dropped Pages in favor of Cloudflare Workers), Astro 6.3.1, and Wrangler ^4.90.0.
Who this is for
Developers with a local Astro project, basic Git and CLI skills, and a Cloudflare account. If you have not decided on a deployment platform yet, read Best deployment platforms for static sites first. If you are weighing Cloudflare against AWS, see Cloudflare Workers vs AWS Lambda.
Prerequisites
Before you start:
- Node.js ≥18 — the Workers runtime requires it; ≥22 is recommended (
node -vto check) - pnpm, npm, or yarn — pnpm for monorepos
- A Cloudflare account — free tier is fine
- Your Astro project committed to a GitHub repository
- @astrojs/cloudflare adapter — only if you use SSR or hybrid rendering; static-only sites skip this
If you are on a static output: 'static' (the Astro default), jump to Step 3. The adapter is only required for server-rendered routes.
Step 1: Choose your rendering mode
Cloudflare Pages supports all three Astro output modes. Pick the wrong one and you get a silent failure, not a clear error.
| Mode | output value | Adapter required | When to use |
|---|---|---|---|
| Fully static | 'static' (default) | No | Content site, no server logic |
| All-SSR | 'server' | Yes | Dynamic routes, auth, DB access |
| Mixed | 'hybrid' | Yes | Mostly static with some on-demand routes |
What breaks if you pick wrong:
output: 'server'without an adapter → build fails:No adapter installed.output: 'static'with API endpoints → endpoints are silently dropped at build time.
Step 2: Install the adapter and update astro.config.mjs
Run the integration installer — it patches your config automatically:
npx astro add cloudflare@12
# or with pnpm:
pnpm dlx astro add cloudflare@12
Your config should look like this afterward:
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server', // or 'hybrid'
adapter: cloudflare({
platformProxy: { enabled: true }, // enables CF bindings in local dev
}),
});
The platformProxy option makes Cloudflare bindings (KV, D1, etc.) available during astro dev. Without it, any binding access throws at dev time.
Add wrangler.jsonc for SSR
If you are running SSR, create wrangler.jsonc at the project root (or monorepo app root):
{
"$schema": "https://json.schemastore.org/wrangler.json",
"name": "my-astro-app",
"compatibility_date": "2026-05-01",
"compatibility_flags": ["nodejs_compat"],
"assets": {
"binding": "ASSETS",
"directory": "./dist"
},
"observability": { "enabled": true }
}
The nodejs_compat flag is not optional. Without it, any Node.js built-in (node:crypto, node:path, etc.) throws at runtime with no useful error message — the request returns a 500.
Step 3: Connect Pages to GitHub
- Go to dash.cloudflare.com → Workers & Pages → Create application → Pages → Connect to Git
- Authorize GitHub and select your repository
- Configure the build settings:
| Setting | Single-app repo | pnpm monorepo |
|---|---|---|
| Root directory | (leave blank) | apps/site |
| Build command | pnpm run build | pnpm run build |
| Build output directory | dist | dist |
| Node.js version | 22 | 22 |
The monorepo gotcha
Root directory is hidden under “Advanced settings” — expand that accordion before clicking Save. If you miss it, Pages builds from the repository root. The build runs, exits 0, deploys successfully, and serves a 404 or an empty site because it deployed the wrong dist/.
In toolchew’s setup (Astro 6.3.1 + pnpm + Turbo), Turbo builds apps/site/dist/ correctly. But Pages still needs Root directory = apps/site to serve from the right folder. This is the most common first-deploy failure for monorepo users, and it produces no error message — a blank deployment.
Cloudflare’s monorepo docs acknowledge the option exists but show no pnpm + Turbo + Astro example. The real fix lives in community threads and that one collapsed accordion.
- Click Save and Deploy. First build takes 45–90 seconds (package install ~30s, Astro build ~20–40s).
Your site is now live at <project>.pages.dev. Every push to main triggers a new production build.
Step 4: Environment variables
Local development: .dev.vars
Wrangler reads a .dev.vars file (gitignored) during wrangler dev or astro dev:
DB_PASSWORD=myPassword
API_SECRET=dev-secret-key
Production: Pages dashboard
Go to your Pages project → Settings → Environment variables. Set variables per environment — Production or Preview. Mark secrets as Encrypt (write-only after save; Cloudflare cannot display them again).
Accessing variables at runtime
Adapter v12 (this guide):
const { env } = Astro.locals.runtime;
const dbUrl = env.DATABASE_URL;
Or type-safe via astro:env (stable since Astro 5.0.0; the @astrojs/cloudflare v12.2.0 release stabilized secrets-handling support for this core API):
import { DATABASE_URL } from 'astro:env/server';
Three rules to avoid surprises
PUBLIC_*variables are exposed to the browser at build time viaimport.meta.env.PUBLIC_*. Everything else is server-only.process.envis not available in the Workers runtime. Usecloudflare:workersinstead.- Secrets set in the dashboard are only available at runtime — not during prerendering. If a prerendered page reads a secret, it gets
undefined.
Step 5: Custom domain
Go to your Pages project → Custom domains → Set up a custom domain.
Two paths depending on where your DNS lives:
Cloudflare DNS (recommended): one-click CNAME, DNS and SSL resolve in about two minutes.
External DNS: add a CNAME pointing to <project>.pages.dev. SSL is provisioned via Let’s Encrypt — allow up to 24 hours. ERR_SSL_PROTOCOL_ERROR during the provisioning window is expected; it is not a misconfiguration.
Step 6: Preview deployments
Every branch push generates a preview URL at https://<branch>.<project>.pages.dev. This works out of the box — no configuration needed.
To scope environment variables to previews: Settings → Environment variables → toggle Preview. Secrets set under Production do not leak into Preview builds.
Preview builds are free and do not count against your 500 builds/month.
Step 7: Going further — D1, KV, and Workers
Adding a D1 database
In wrangler.jsonc:
{
"d1_databases": [{
"binding": "DB",
"database_name": "my-db",
"database_id": "<uuid>"
}]
}
Then in a server-rendered page or API route:
import { env } from 'cloudflare:workers';
const posts = await env.DB.prepare('SELECT * FROM posts').all();
Free D1 tier: 5 million reads and 100,000 writes per day. 5 GB total storage. Production-grade for most indie projects.
When to graduate to Workers
Pages Functions handles most needs. Move to a full Workers deployment when you need:
- Cron triggers — Pages has no native cron
- Durable Objects — available on both Workers Free and Workers Paid; free tier includes 100K requests/day and 5 GB storage (SQLite backend only)
- Service bindings — calling one Worker from another
- Consistent CPU time over 10ms per request (Pages Functions free tier: 10ms/invocation; Workers Paid: 30M CPU-ms/month)
The Astro site stays identical — only the deploy mechanism changes from Pages Git integration to wrangler deploy in CI. See Cloudflare Workers vs AWS Lambda for a full breakdown of when the paid plan pays off.
Common errors
| Error | Cause | Fix |
|---|---|---|
No adapter installed | output: 'server' without adapter | Run npx astro add cloudflare@12 |
Cannot find module 'node:crypto' | Missing nodejs_compat flag | Add to compatibility_flags in wrangler.jsonc |
env.MY_VAR is undefined | Using process.env in Workers runtime | Switch to import { env } from 'cloudflare:workers' |
Secret undefined at build time | Secrets only available at runtime | Move secret reads to server-rendered pages, not prerendered ones |
| 404 on deployment | Monorepo root directory not set | Set Root directory to apps/site in Advanced settings |
SSR pages return [object Object] | compatibility_date ≥ 2025-09-15 + nodejs_compat conflict | Add "disable_nodejs_process_v2" to compatibility_flags |
Free tier limits (summary)
| Resource | Free limit |
|---|---|
| Static bandwidth | Unlimited |
| Builds | 500/month, 1 concurrent, 20-min timeout |
| Functions requests | 100,000/day |
| Custom domains | 100 per project |
| D1 reads | 5M/day |
| D1 writes | 100K/day |
| KV reads (production) | 100K reads/day, 1K writes/day, 1 GB storage — free plan |
Related: Best deployment platforms for static sites compares Pages against Vercel, Netlify, and Render on price and DX. If you are choosing a framework, Astro vs Hugo covers the build-speed tradeoffs and Next.js vs Astro covers when to use a React-based framework instead.