Deploying an Astro Site to Cloudflare Pages
From git push to a site on the edge — connecting the repo, Workers-with-static-assets vs classic Pages, wrangler.toml as the source of truth, env vars, and caching with a _headers file.
Deploying an Astro Site to Cloudflare PagesA static Astro site is a folder of HTML, CSS, and hashed assets. Hosting that should be boring — and on Cloudflare Pages, it is. You connect a repo, and every push runs a build and ships the output to ~300 edge locations. The deploy command is git push. No server, no container, free tier covers a personal site comfortably.
This is the deploy half of my Astro portfolio walkthrough, expanded — including the part most quickstarts skip: wrangler.toml, env vars, and headers.
Connect the repo#
In the Cloudflare dashboard: Workers & Pages → Create → Pages → Connect to Git. Pick the repo, then the build settings:
- Framework preset: Astro (or “None” — it just pre-fills the next two)
- Build command:
npm run build - Build output directory:
dist - Node version: 20 or newer — set a
NODE_VERSIONenvironment variable, or a.nvmrcin the repo
That’s it. Push to your production branch → Cloudflare runs the build on its CI → the contents of dist/ go live. Push to any other branch → you get a preview deployment at a unique URL, which is genuinely useful for reviewing a change before it hits production.
One thing worth knowing about the build environment: it runs npm install (or pnpm/yarn if it detects the lockfile) and then your build command. Your prebuild script — if you have one, like a Satori OG-card generator — runs automatically as part of npm run build. Nothing special needed.
Workers-with-static-assets vs classic Pages#
There are now two ways Cloudflare serves a static site, and the distinction trips people up:
- Classic Pages — the “Connect to Git” flow above. Configured in the dashboard. Deployed (if you ever do it manually) with
wrangler pages deploy. This is the simplest path and totally fine. - Workers with static assets — a Worker that has an
[assets]binding pointing at your build output. Configured inwrangler.toml. Deployed withwrangler deploy. You’d choose this if you want a bit of code at the edge alongside the static files (an API route, a redirect rule, an auth check) — the Worker runs first, falls through to the static asset if it doesn’t handle the request.
For a pure static site, classic Pages is enough. If you reach for the Worker path, the wrangler.toml looks like this:
# wrangler.toml
name = "your-project" # must match the dashboard project name
account_id = "your-account-id"
compatibility_date = "2026-04-01"
[assets]
directory = "./dist" # Astro's build output
not_found_handling = "404-page" # serve dist/404.html for unmatched routes
not_found_handling = "404-page" is the one to get right — it makes Cloudflare return your dist/404.html for any route that doesn’t match a built file, which matches how a static Astro site is supposed to behave.
wrangler.toml as the source of truth#
Here’s the gotcha that catches people: when wrangler.toml is present, it overrides the dashboard. Set an environment variable in the Cloudflare UI, then deploy with a wrangler.toml that doesn’t mention it, and your value gets wiped. Pick one source of truth — for anything committable, that’s the file:
# Public vars — inlined into the client bundle at build time, so they're
# visible in the browser regardless. Safe to commit.
[vars]
PUBLIC_SITE_NAME = "Your Name"
PUBLIC_ANALYTICS_DOMAIN = "yoursite.com"
PUBLIC_CONTACT_ENDPOINT = "https://your-worker.workers.dev"
Note the PUBLIC_ prefix. In Astro (and Vite generally), only PUBLIC_-prefixed env vars are exposed to client-side code — and when they are, they’re inlined into the JavaScript bundle. That means anyone can read them in the network tab. So the rule is absolute: PUBLIC_ vars are not secret. Ever. API keys, tokens, anything sensitive — those are secrets, set outside the file:
npx wrangler pages secret put STRIPE_SECRET_KEY --project-name your-project
# or, on the Workers-with-assets path:
npx wrangler secret put STRIPE_SECRET_KEY
Secrets are encrypted, never in git, and only available to server-side code (a Worker, an Astro endpoint running on the server). If your site is fully static there’s no server to read them — which is itself a feature: nothing to leak.
Want different values on preview branches? Per-environment overrides:
[env.preview.vars]
PUBLIC_ANALYTICS_DOMAIN = "" # don't count preview traffic
Headers and caching with a _headers file#
Drop a file literally named _headers in /public/ and Cloudflare reads it on every deploy, applying the rules at the edge — no config, no Worker. The pattern:
# public/_headers
# Astro hashes filenames in /_astro/ — the content is the version,
# so cache forever.
/_astro/*
Cache-Control: public, max-age=31536000, immutable
# Build-generated images (OG cards, etc.) — also content-versioned by path.
/og/*
Cache-Control: public, max-age=31536000, immutable
# Feeds and sitemaps — short cache so new posts surface fast.
/rss.xml
Cache-Control: public, max-age=3600, must-revalidate
/sitemap-*.xml
Cache-Control: public, max-age=3600, must-revalidate
# HTML — don't cache hard (you want edits live fast), and attach
# security headers while we're here.
/*
Cache-Control: public, max-age=0, must-revalidate
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Frame-Options: DENY
Cross-Origin-Opener-Policy: same-origin
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Two things to flag:
immutableon hashed assets is the single biggest perf win here. Astro fingerprints/_astro/*.jsand*.cssfilenames with a content hash — change the file, the name changes — so the browser can cache the old name forever and never re-check.max-age=31536000, immutabletells it to do exactly that. Repeat visits load instantly from disk.preloadon the HSTS header is hard to undo. It opts your domain into browser preload lists. Only keep it if you’re certain you’ll never serve any subdomain over plain HTTP. If unsure, drop the; preloadtoken — you still get HTTPS-only enforcement, just without the irreversible commitment.
(Lighthouse’s “Best Practices” and “Trust & Safety” panels check for most of those security headers — adding them is part of how a static site scores 100 there with zero effort.)
Custom domain#
In the dashboard: your project → Custom domains → Set up a domain. If the domain’s DNS is already on Cloudflare, it wires the records for you; if not, you point your registrar’s nameservers at Cloudflare first. HTTPS is automatic — a certificate is provisioned and renewed without you touching anything. Within a few minutes yoursite.com serves your dist/ from the edge.
Honest caveats#
- Preview deployments are public at their generated URLs. Fine for most sites; if you’re previewing something sensitive, gate it (Cloudflare Access can put auth in front of preview branches).
- Build minutes and bandwidth have free-tier limits — generous, but they exist. A personal site won’t come close; a high-traffic one should check the numbers.
wrangler.tomloverriding the dashboard bears repeating because it’s the #1 “why did my env var disappear” cause. One source of truth. Commit it.
What you end up with#
A repo connected to Cloudflare Pages where git push is the deploy: build runs on their CI, output ships to the edge, preview URLs for every branch, automatic HTTPS, a _headers file handling caching and security headers, and wrangler.toml (if you use it) as the committed source of truth for configuration. Hosting cost: $0/month for a personal site.
The best deploy pipeline is the one you stop thinking about.
git push, and it’s live, everywhere.
This is the last leg of a longer build — layout, content collections, SEO, OG cards, fonts — all of it in How to Build a Portfolio Website with Astro.
