Your Astro site builds locally. You push to GitHub. The deploy fails because Mermaid diagrams need Playwright’s headless Chromium, which is not installed in CI. You fix that, push again. The build succeeds but the edge functions crash because you wrote process.env.OPENAI_API_KEY instead of Astro.locals.runtime.env.OPENAI_API_KEY. You fix that too. The site deploys. Then someone runs Lighthouse and the performance score is 72 because nobody configured the image service for production.
This post makes sure deploy day is boring. We configure Cloudflare Pages, write a complete GitHub Actions workflow, enforce Lighthouse 100/100/100/100, and handle every sharp edge between “works on my machine” and “works in production.”
This is Part 4 of the Astro Deep Dive series. Part 1 scaffolded the project. Part 2 wired up islands architecture. Part 3 built the content layer. Now we ship it.
Static vs Edge: Know Your Routes
Before configuring anything, understand what runs where. Astro 5 makes this explicit with prerender:
// Default: static (prerendered at build time)// Every page is static unless you opt out
// Edge route — runs on Cloudflare Workersexport const prerender = false;Blogcraft’s route map:
| Route | Runtime | Why |
|---|---|---|
| / | Static (CDN) | Landing page, changes only on deploy |
| /blog/* | Static (CDN) | Content pages, built from MDX at build time |
| /tags/* | Static (CDN) | Tag listing pages, known at build time |
| /about | Static (CDN) | Static content |
| /rss.xml | Static (CDN) | Generated at build time |
| /sitemap-*.xml | Static (CDN) | Generated by @astrojs/sitemap |
| /search.json | Static (CDN) | Pre-built search index for Fuse.js |
| /api/ai/* | Edge (Workers) | AI proxy routes — needs secrets, streaming |
| /write | Edge (Workers) | Editor — needs auth gate, can't be static |
| /keystatic/* | Edge (Workers) | CMS admin — injected by @keystatic/astro |
The rule is simple: if a route needs secrets, authentication, or dynamic responses, it runs on the edge. Everything else is static HTML on a CDN. Static pages cost nothing to serve, load instantly from edge caches worldwide, and ship zero JavaScript by default (as we established in Part 2).
Cloudflare Pages Configuration
The Adapter
The @astrojs/cloudflare adapter bridges Astro’s build output to Cloudflare’s runtime. The key configuration:
import cloudflare from '@astrojs/cloudflare';
adapter: cloudflare({ imageService: 'compile',}),The imageService: 'compile' setting is critical. It tells Astro to run Sharp image optimization at build time on the Node.js CI runner, not at request time on Cloudflare Workers. Sharp uses native bindings that do not run on workerd (Cloudflare’s JavaScript runtime). Without this setting, your image optimization silently falls back to passthrough — serving unoptimized images that tank your Lighthouse performance score.
wrangler.toml
Wrangler is Cloudflare’s CLI for Pages and Workers. The configuration:
name = "blogcraft"compatibility_date = "2025-09-01"compatibility_flags = ["nodejs_compat_v2"]
[site]bucket = "./dist"Two fields matter here:
compatibility_date pins the Workers runtime behavior to a specific date. Cloudflare ships breaking changes behind compatibility dates — setting this to a recent date ensures you get modern APIs without surprise regressions when they update the runtime.
nodejs_compat_v2 enables the Node.js compatibility layer in workerd. React 19’s SSR code uses Node APIs that workerd does not natively provide. Without this flag, your edge-rendered pages crash with ReferenceError: process is not defined or similar errors.
Environment Variables
Here is the correct way to access secrets in an Astro edge route on Cloudflare:
export const prerender = false;
export async function POST({ locals, request }: APIContext) { // Correct: Cloudflare runtime bindings const apiKey = locals.runtime.env.OPENAI_API_KEY;
// WRONG: process.env is empty on Workers // const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) { return new Response('API key not configured', { status: 500 }); }
// ... use the key}Set these variables in the Cloudflare Pages dashboard under Settings > Environment variables, or through wrangler pages secret put:
wrangler pages secret put OPENAI_API_KEY --project-name=blogcraftwrangler pages secret put WRITE_PASSWORD --project-name=blogcraftRequired secrets for Blogcraft:
| Variable | Required | Purpose |
|---|---|---|
CLOUDFLARE_API_TOKEN | Yes (CI only) | GitHub Actions deploys via wrangler |
CLOUDFLARE_ACCOUNT_ID | Yes (CI only) | Identifies your Cloudflare account |
OPENAI_API_KEY | For /write AI | AI writing assistant proxy |
WRITE_PASSWORD | For /write auth | Simple auth gate for the editor |
PUBLIC_UMAMI_WEBSITE_ID | For analytics | Umami tracking (public, not secret) |
The GitHub Actions Workflow
Here is the complete CI/CD pipeline. Every line earns its place:
name: Deploy to Cloudflare Pages
on: push: branches: [main] pull_request: branches: [main]
jobs: build-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- uses: pnpm/action-setup@v4 with: version: 9
- uses: actions/setup-node@v4 with: node-version: 22 cache: 'pnpm'
# Cache Playwright browsers (Mermaid needs Chromium) - name: Cache Playwright browsers uses: actions/cache@v4 with: path: ~/.cache/ms-playwright key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
- name: Install dependencies run: pnpm install --frozen-lockfile
- name: Install Playwright Chromium run: npx playwright install --with-deps chromium
# Build (prebuild: lint:islands + check:schema) # Build (postbuild: OG image generation) - name: Build run: pnpm build
# Lighthouse CI (PRs only) - name: Lighthouse CI if: github.event_name == 'pull_request' run: | pnpm exec lhci autorun env: LHCI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Broken link check - name: Check for broken links uses: lycheeverse/lychee-action@v2 with: args: './dist --no-progress' fail: true
# Deploy (main branch only) - name: Deploy to Cloudflare Pages if: github.ref == 'refs/heads/main' run: pnpm exec wrangler pages deploy dist --project-name=blogcraft env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}Let me walk through the non-obvious decisions.
Playwright Chromium for Mermaid
The rehype-mermaid plugin renders Mermaid diagrams to SVG at build time (as covered in the Mermaid tutorial). It launches a headless Chromium browser via Playwright to do the rendering. Without npx playwright install --with-deps chromium, the build fails with a cryptic “browser not found” error.
The --with-deps flag installs system-level dependencies (libgbm, libnss3, etc.) that Chromium requires on Ubuntu. The cache step avoids re-downloading the ~400MB browser binary on every run.
Prebuild Checks
The pnpm build command triggers prebuild hooks that run two critical checks:
1. Island hydration audit (lint:islands)
#!/bin/bash# Fail if any component uses client:load (except ThemeToggle)VIOLATIONS=$(grep -rn 'client:load' src/ --include='*.astro' \ | grep -v 'ThemeToggle')
if [ -n "$VIOLATIONS" ]; then echo "ERROR: client:load usage detected (use client:visible or client:idle):" echo "$VIOLATIONS" exit 1fiAs established in Part 2, client:load blocks the main thread immediately on page load. The only exception is ThemeToggle, which must hydrate early to prevent a flash of wrong theme. Everything else uses client:visible or client:idle.
2. Schema parity check (check:schema)
Verifies that the Keystatic CMS field definitions match the Zod schemas in content.config.ts. If someone adds a field to one without updating the other, the build fails with a clear diff.
OG Image Generation
The postbuild step generates Open Graph images using Satori (JSX to SVG) and resvg-js (SVG to PNG):
import satori from 'satori';import { Resvg } from '@resvg/resvg-js';import { readFileSync, writeFileSync, mkdirSync } from 'fs';
// For each blog post, generate a 1200x630 PNG// with the title, author, and date rendered via SatoriLighthouse 100/100/100/100
A Lighthouse score is four independent audits. Each one measures different things and breaks for different reasons. Here is what each score requires and what tanks it.
Performance (Target: 100)
The performance score is dominated by four Core Web Vitals:
First Contentful Paint (FCP) under 0.8s — The browser renders the first piece of content. Static HTML from a CDN with inlined critical CSS achieves this trivially. What breaks it: render-blocking JavaScript, unoptimized fonts loading via external font services, large CSS files loaded synchronously.
Largest Contentful Paint (LCP) under 2.5s — The largest visible element finishes rendering. For blog posts, this is usually the hero image. What breaks it: unoptimized images (no AVIF/WebP, no responsive srcset, no width/height attributes causing layout recalculation). The fix: Astro’s <Picture> component with the image() schema helper from Part 3.
Cumulative Layout Shift (CLS) = 0 — Nothing moves after initial render. What breaks it: images without dimensions, fonts that swap with different metrics, dynamically injected content. The fix: image() provides dimensions at build time, self-hosted fonts with font-display: swap and proper size-adjust fallbacks.
Total Blocking Time (TBT) = 0ms — No long tasks block the main thread. This is where Astro’s zero-JS default is decisive. A content page with no islands ships no JavaScript. TBT is literally zero. What breaks it: adding client:load to components, shipping Mermaid.js client-side (1.1MB), or importing a charting library on a page that does not need it.
adapter: cloudflare({ imageService: 'compile', // Sharp runs at build time, not on Workers}),Self-hosted fonts eliminate the external font service round-trip:
@font-face { font-family: 'Source Serif 4 Variable'; src: url('/fonts/source-serif-4-variable.woff2') format('woff2'); font-display: swap; font-weight: 200 900;}Accessibility (Target: 100)
Lighthouse’s accessibility audit checks a subset of WCAG 2.2. The high-impact items:
- Semantic HTML:
<main>,<article>,<nav>,<header>,<footer>— not<div>for everything - Alt text on every image: The
heroAltfield in the schema is required (z.string(), notz.string().optional()). Missing alt text fails the build, not just the audit - Color contrast: Text against background must meet WCAG AA ratios (4.5:1 for normal text, 3:1 for large text). CSS variables in
global.cssare tuned for both light and dark themes - Heading hierarchy: No skipping levels.
<h1>then<h2>then<h3>, never<h1>to<h3> - Focus indicators: Visible
:focus-visibleoutlines on all interactive elements - Skip navigation link: Hidden link at the top of the page that jumps to
<main>for keyboard users
Best Practices (Target: 100)
- HTTPS everywhere: Cloudflare Pages provides this automatically with free certificates
- No deprecated APIs: Avoid legacy DOM injection methods, synchronous XHR, and
navigator.userAgentstring parsing - Security headers:
Content-Security-Policy,X-Content-Type-Options,X-Frame-Optionsconfigured in Cloudflare Pages headers or_headersfile - No browser errors: No console errors, no failed resource loads, no mixed content
SEO (Target: 100)
- Meta tags:
<title>,<meta name="description">,<meta property="og:*">on every page - Canonical URLs:
<link rel="canonical">preventing duplicate content penalties - Structured data: JSON-LD for blog posts (
BlogPostingschema) with author, date, description - Sitemap: Generated by
@astrojs/sitemap, filtered to exclude/writeand/keystatic - robots.txt: Allow all crawlers, link to sitemap
- RSS feed:
@astrojs/rssgenerates a valid Atom/RSS feed
import rss from '@astrojs/rss';import { getCollection } from 'astro:content';
export async function GET(context: APIContext) { const posts = (await getCollection('blog', ({ data }) => !data.draft)) .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
return rss({ title: 'Blogcraft Blog', description: 'A technical blog with paper-first aesthetics', site: context.site!, items: posts.map((post) => ({ title: post.data.title, pubDate: post.data.pubDate, description: post.data.description, link: `/blog/${post.id}/`, })), });}Lighthouse CI Configuration
The lighthouserc.cjs file enforces scores on every pull request:
module.exports = { ci: { collect: { staticDistDir: './dist', url: [ '/', '/blog/astro-deep-dive-01-scaffolding/', '/tags/', ], }, assert: { assertions: { 'categories:performance': ['error', { minScore: 0.95 }], 'categories:accessibility': ['error', { minScore: 0.95 }], 'categories:best-practices': ['error', { minScore: 0.95 }], 'categories:seo': ['error', { minScore: 0.95 }], }, }, upload: { target: 'temporary-public-storage', }, },};The 0.95 threshold (not 1.0) accounts for minor Lighthouse scoring variance between runs. In practice, a site that consistently scores 95+ on CI will score 100 in manual audits. Setting the threshold to 1.0 causes flaky CI failures from Lighthouse’s own measurement jitter.
The Full Pipeline
Here is the complete flow from git push to live site:
Every step that can fail, fails fast. Schema violations fail in prebuild. Missing images fail in the Astro build. Broken links fail in the post-build check. Lighthouse regressions fail on the PR before reaching main. By the time wrangler pages deploy runs, the site has been validated six ways.
Cloudflare Pages vs Alternatives
| Feature | Cloudflare Pages | Vercel | Netlify |
|---|---|---|---|
| Edge runtime | workerd (V8 isolates) | Edge Runtime (V8) | Edge Functions (Deno) |
| Static hosting | Free, unlimited bandwidth | Free tier, 100GB bandwidth | Free tier, 100GB bandwidth |
| Build minutes | 500/month free | 6000/month free | 300/month free |
| Cold start | ~0ms (V8 isolates) | ~0ms (Edge Runtime) | ~50-250ms (Deno) |
| Native image optimization | Cloudflare Images (paid) | Built-in (free tier) | Netlify Image CDN |
| KV/storage | Workers KV, R2, D1 | Vercel KV, Blob, Postgres | Netlify Blobs |
| Astro adapter | @astrojs/cloudflare | @astrojs/vercel | @astrojs/netlify |
| Custom domains | Free, automatic SSL | Free, automatic SSL | Free, automatic SSL |
| Preview deploys | Yes (branch-based) | Yes (commit-based) | Yes (branch-based) |
| Node.js compat | nodejs_compat_v2 flag | Native Node.js | Limited |
Cloudflare Pages wins on cost (free bandwidth, generous free tier) and cold starts (V8 isolates start in under a millisecond). The tradeoff is the nodejs_compat_v2 requirement and the inability to run native Node bindings (like resvg-js) on Workers. For a blog that is 95% static with a few edge routes, this tradeoff is heavily favorable.
Common Deploy Failures and Fixes
After shipping dozens of Astro sites to Cloudflare Pages, these are the failures I see repeatedly:
Build fails: Browser not found (rehype-mermaid)
The fix: add npx playwright install --with-deps chromium to your CI pipeline before the build step. Cache ~/.cache/ms-playwright to avoid downloading 400MB of Chromium on every build. If you do not use Mermaid diagrams, remove rehype-mermaid from your config — do not pay the Playwright tax for nothing.
Edge route crashes: process.env is undefined
On Cloudflare Workers, process.env is empty even with nodejs_compat_v2. Access environment variables through Astro.locals.runtime.env.YOUR_VAR in Astro routes. Set variables in the Cloudflare dashboard or via wrangler pages secret put.
Images not optimized: Lighthouse performance drops
Set imageService: 'compile' in the Cloudflare adapter config. This runs Sharp at build time. Without it, Astro falls back to passthrough — serving original uncompressed images. Verify by checking the build output for .avif and .webp files in dist/_astro/.
Expressive Code blocks render as plain pre tags
Move expressiveCode() BEFORE mdx() in the integrations array. Expressive Code must register its rehype transforms before MDX processes code fences. This is the integration order issue from Part 3.
Build succeeds but pages 404 on Cloudflare
Check that wrangler.toml has bucket = './dist' pointing to Astro’s output directory. If you changed outDir in astro.config.ts, update the bucket path to match. Also verify the wrangler pages deploy command uses the correct directory argument.
Fonts cause layout shift (CLS greater than 0)
Self-host fonts as WOFF2 files instead of loading from external font services. Use font-display: swap and add size-adjust fallback metrics to prevent layout shift during font loading. Preload your primary body font in the head element with rel="preload", as="font", type="font/woff2", and crossorigin.
The Deploy Checklist
Before pushing your first deploy, verify every item:
-
wrangler.tomlexists withcompatibility_dateandnodejs_compat_v2 -
@astrojs/cloudflareadapter withimageService: 'compile' -
expressiveCode()beforemdx()in integrations - All edge routes have
export const prerender = false - Edge routes use
Astro.locals.runtime.env, notprocess.env -
CLOUDFLARE_API_TOKENandCLOUDFLARE_ACCOUNT_IDin GitHub secrets - Playwright Chromium installed in CI (if using Mermaid)
- OG images generated at build time, not on Workers
- Self-hosted fonts with
font-display: swap - All images use
image()schema and<Picture>component -
robots.txtand sitemap configured -
client:loadbanned except ThemeToggle - Lighthouse CI configured with 95%+ thresholds
Series Recap
Over four posts, we built a production Astro 5 site from nothing to deployed:
Part 1: Scaffolding — Project structure, Astro config, Tailwind 4 integration, design tokens, base layouts.
Part 2: Islands Architecture — Zero-JS by default, hydration directives (client:visible, client:idle, client:only), the client:load ban, islands for search, TOC, and theme toggle.
Part 3: Content Collections — Content Layer API, Zod schemas, image() validation, getCollection/render, dynamic routes, remark/rehype pipeline, integration order.
Part 4 (this post) — Cloudflare Pages, GitHub Actions, Playwright for Mermaid, environment variables, Lighthouse 100/100/100/100, OG images, deploy checklist.
The result is a blog that scores perfect Lighthouse numbers, ships zero JavaScript on content pages, validates every piece of content at build time, and deploys automatically on every push to main. The architecture decisions from Parts 1-3 compound here — zero-JS defaults give you TBT 0ms, image() schemas give you optimized images without runtime cost, and build-time Mermaid means no 1.1MB client bundle.
Deploy day should be boring. If it is not, something in the pipeline is missing a check.