Tutorial

Astro Deep Dive: Deployment, CI/CD, and Lighthouse 100/100

Your Astro site builds locally but fails on deploy — Mermaid needs Playwright, edge functions reference process.env, and Lighthouse scores crater. This post configures Cloudflare Pages, GitHub Actions, and Lighthouse CI so deploy day is boring.

Tin Dang avatar
Tin Dang
A deployment pipeline flowing from code through automated checks to a global CDN edge network

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 Workers
export const prerender = false;

Blogcraft’s route map:

RouteRuntimeWhy
/ 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:

astro.config.ts
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:

wrangler.toml
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:

src/pages/api/ai/chat.ts
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:

Terminal window
wrangler pages secret put OPENAI_API_KEY --project-name=blogcraft
wrangler pages secret put WRITE_PASSWORD --project-name=blogcraft

Required secrets for Blogcraft:

VariableRequiredPurpose
CLOUDFLARE_API_TOKENYes (CI only)GitHub Actions deploys via wrangler
CLOUDFLARE_ACCOUNT_IDYes (CI only)Identifies your Cloudflare account
OPENAI_API_KEYFor /write AIAI writing assistant proxy
WRITE_PASSWORDFor /write authSimple auth gate for the editor
PUBLIC_UMAMI_WEBSITE_IDFor analyticsUmami tracking (public, not secret)

The GitHub Actions Workflow

Here is the complete CI/CD pipeline. Every line earns its place:

.github/workflows/deploy.yml
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)

scripts/check-no-client-load.sh
#!/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 1
fi

As 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):

scripts/generate-og.mjs
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 Satori

Lighthouse 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.

astro.config.ts — image service
adapter: cloudflare({
imageService: 'compile', // Sharp runs at build time, not on Workers
}),

Self-hosted fonts eliminate the external font service round-trip:

src/styles/global.css
@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 heroAlt field in the schema is required (z.string(), not z.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.css are 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-visible outlines 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.userAgent string parsing
  • Security headers: Content-Security-Policy, X-Content-Type-Options, X-Frame-Options configured in Cloudflare Pages headers or _headers file
  • 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 (BlogPosting schema) with author, date, description
  • Sitemap: Generated by @astrojs/sitemap, filtered to exclude /write and /keystatic
  • robots.txt: Allow all crawlers, link to sitemap
  • RSS feed: @astrojs/rss generates a valid Atom/RSS feed
src/pages/rss.xml.ts
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:

lighthouserc.cjs
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

FeatureCloudflare PagesVercelNetlify
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.toml exists with compatibility_date and nodejs_compat_v2
  • @astrojs/cloudflare adapter with imageService: 'compile'
  • expressiveCode() before mdx() in integrations
  • All edge routes have export const prerender = false
  • Edge routes use Astro.locals.runtime.env, not process.env
  • CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in 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.txt and sitemap configured
  • client:load banned 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.

0