· 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 -v to 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.

Modeoutput valueAdapter requiredWhen to use
Fully static'static' (default)NoContent site, no server logic
All-SSR'server'YesDynamic routes, auth, DB access
Mixed'hybrid'YesMostly 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

  1. Go to dash.cloudflare.comWorkers & PagesCreate applicationPagesConnect to Git
  2. Authorize GitHub and select your repository
  3. Configure the build settings:
SettingSingle-app repopnpm monorepo
Root directory(leave blank)apps/site
Build commandpnpm run buildpnpm run build
Build output directorydistdist
Node.js version2222

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.

  1. 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 → SettingsEnvironment 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 via import.meta.env.PUBLIC_*. Everything else is server-only.
  • process.env is not available in the Workers runtime. Use cloudflare:workers instead.
  • 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 domainsSet 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: SettingsEnvironment 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

ErrorCauseFix
No adapter installedoutput: 'server' without adapterRun npx astro add cloudflare@12
Cannot find module 'node:crypto'Missing nodejs_compat flagAdd to compatibility_flags in wrangler.jsonc
env.MY_VAR is undefinedUsing process.env in Workers runtimeSwitch to import { env } from 'cloudflare:workers'
Secret undefined at build timeSecrets only available at runtimeMove secret reads to server-rendered pages, not prerendered ones
404 on deploymentMonorepo root directory not setSet Root directory to apps/site in Advanced settings
SSR pages return [object Object]compatibility_date ≥ 2025-09-15 + nodejs_compat conflictAdd "disable_nodejs_process_v2" to compatibility_flags

Free tier limits (summary)

ResourceFree limit
Static bandwidthUnlimited
Builds500/month, 1 concurrent, 20-min timeout
Functions requests100,000/day
Custom domains100 per project
D1 reads5M/day
D1 writes100K/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.