Deploy Astro to Cloudflare Pages: Static and SSR Setup
TL;DR: Astro on Cloudflare Pages splits into two very different deployments depending on whether you ship static output or server-rendered output. A static build produces a folder of files that Pages serves from the edge with no adapter and no runtime, which is the right answer for most content sites. Server rendering needs the Cloudflare adapter, runs your routes on Cloudflare's serverless runtime, and brings a set of constraints that will surprise you if you assume Node: no filesystem, a restricted set of Node built-ins, and bindings instead of ambient globals. Connect the repository, set the build command and output directory, add environment variables scoped correctly, and every push produces a deployment with its own preview URL. The failures that cost time are output directory mismatches, build-time versus runtime variable confusion, and code that quietly depends on a Node API the edge runtime does not provide.
Static or SSR: which should you actually pick?
Answer this before touching config, because it determines everything else.
Choose static if your pages can be generated at build time. Blogs, documentation, marketing sites, portfolios. The output is HTML, CSS, JS and assets, served from Cloudflare's network with no compute in the request path. It is faster, cheaper, harder to break, and has no cold starts. Astro's default output mode does this without an adapter at all.
Choose server rendering if you need per-request logic: authenticated pages, personalised content, form handling, or anything that reads a request header and changes the response. This needs the Cloudflare adapter and runs on Cloudflare's serverless runtime.
Choose hybrid if most pages are static and a handful need a server. Astro supports rendering most routes at build time and opting specific ones into on-demand rendering. This is usually the correct answer for real sites, and it keeps the serverless surface small.
The general Pages workflow is the same in all three cases and is covered from the plain-static angle in deploy-static-site-cloudflare-pages. This guide focuses on what is specific to Astro.
How do I deploy a static Astro site?
This is the short path.
- Push your Astro project to a Git repository. Cloudflare Pages connects to GitHub and GitLab; if you have not wired GitHub up yet, connect-github-to-claude covers the token side of that relationship.
- In the Cloudflare dashboard, create a new Pages project and connect it to your repository.
- Set the framework preset to Astro if offered, which fills in the build settings for you. Otherwise set them manually.
- Set the build command to your package manager's build script.
- Set the build output directory to
dist. This is Astro's default and it is the field people most often get wrong. - Set the Node version, because the default may not match what your project expects.
- Deploy.
The build settings that matter:
Build command: npm run build
Build output directory: dist
Root directory: / (or your subfolder in a monorepo)
Pin the Node version rather than accepting whatever the platform defaults to. The reliable way is an environment variable that Cloudflare's build system reads:
NODE_VERSION = 20
An .nvmrc file in your repository also works and has the advantage of matching your local environment. Use one or the other consistently rather than both with different values.
If the build succeeds and you get a 404 on every page, your output directory is wrong. Check the build log for where Astro actually wrote its output and make the setting match.
What changes for server-rendered Astro?
Server rendering requires the Cloudflare adapter and a change to your Astro config.
# Add the adapter with Astro's own command, which wires up the config for you
npx astro add cloudflare
Your config then looks roughly like this:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
output: 'server',
adapter: cloudflare(),
});
For hybrid rendering, keep most pages static and opt individual routes in. In current Astro, static is the default and you mark a route as server-rendered by exporting prerender = false from it:
// src/pages/dashboard.astro
export const prerender = false;
// This route now renders per request; the rest of the site stays static.
The adapter changes the build output. Instead of a folder of files, you get a folder of files plus a worker script that Pages runs for the routes that need it. The output directory setting stays dist.
What will break on the edge runtime?
This is where Astro on Cloudflare diverges from Astro on a Node host, and where most of the frustrating debugging happens.
There is no filesystem. Code that reads a file at request time fails. This is easy to hit indirectly: a markdown parser that loads a config file, an image library that writes to a temp directory, a dependency that reads its own package metadata at runtime. Move everything filesystem-dependent to build time.
Node built-ins are limited. Cloudflare provides a subset through Node compatibility, and you have to enable it. Without it, an import of node:buffer or similar fails at build or at runtime. With it, a subset works, but not all of it. If you depend on a library that assumes full Node, check before you commit to it.
Globals are not ambient. Environment variables and service bindings are not on process.env at request time in the way you expect from Node. They arrive through the request context. Reading process.env.MY_KEY inside a server-rendered route may return undefined even though the variable is set in the dashboard, because the value is injected at build time for static contexts and through the runtime context for server contexts. This trips up almost everyone once.
Long-running work is not welcome. The runtime is built for short request handling. A route that does heavy computation or waits on a slow upstream will hit limits.
The practical approach: do as much as possible at build time, keep server-rendered routes thin, and check dependencies for Node assumptions before adopting them.
How do environment variables work here?
Cloudflare Pages splits variables by environment: Production and Preview. That split matters and it is not the same thing as the build-time versus runtime split, which is a separate axis. Get both axes clear in your head.
Production versus Preview. Production variables apply to deployments from your production branch. Preview variables apply to every other branch's deployments. Set them separately. If you set only Production, your preview deployments get nothing and fail in confusing ways.
Build time versus runtime. A variable read during astro build is baked into the output. A variable read inside a server-rendered route at request time comes from the runtime context. A static site only has build-time variables, because there is no runtime.
Public versus secret. Astro exposes variables prefixed with PUBLIC_ to client-side code. Everything with that prefix ends up in your JavaScript bundle, readable by anyone. Everything without it stays server-side. This is the most consequential rule on this page.
# Safe to expose: ends up in the browser bundle, and that is fine
PUBLIC_SITE_URL=https://your-site.example.com
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
# NEVER prefix these with PUBLIC_
SUPABASE_SERVICE_ROLE_KEY=REPLACE_ME
STRIPE_SECRET_KEY=sk-REPLACE_ME
Use Cloudflare's secret variable type for anything sensitive, which encrypts the value and stops it being read back from the dashboard. Never commit .env: add it to .gitignore before you create it. If a secret has ever been committed or has appeared in a build log, rotate it. Removing it from the repository does not un-leak it, and build logs are retained.
The same hygiene reasoning is set out in skill-security-best-practices, and it applies identically to deployment configuration.
How do redirects and headers work?
Cloudflare Pages reads two special files from your build output.
_redirects handles redirects and rewrites, one rule per line:
# Old paths to new, permanent
/old-post /technical-documentation/new-post 301
# Splat redirect for a whole section
/blog/* /technical-documentation/:splat 301
_headers sets response headers by path pattern:
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
/assets/*
Cache-Control: public, max-age=31536000, immutable
Both files must end up in the build output directory, not just in your source tree. In Astro, put them in public/ and they are copied to dist/ verbatim during the build. Putting them at the project root is a common mistake and silently does nothing.
Check the deployment log after your first deploy with these files. Pages reports how many rules it parsed, and a rule count of zero when you expected five tells you the file did not make it into the output.
What about preview deployments and rollbacks?
Every push to a non-production branch produces a preview deployment with its own URL. This is the feature that makes Pages worth using: you can look at the change before it is live, and so can anyone you share the link with.
Two things to configure deliberately:
Preview environment variables. Set them. A preview deployment with production credentials is a bad idea, and a preview deployment with no credentials is a broken preview. Point previews at a staging backend.
Access control on previews. Preview URLs are public by default and are indexable if something links to them. If your previews contain anything you would not put on the open web, restrict access to them.
Rollback is straightforward: every deployment is retained and you can promote a previous one back to production from the dashboard. This is the fastest incident response you have. Know where the button is before you need it, and remember that rolling back the deployment does not roll back a database migration.
How do I make sure search engines see the new deployment?
For a content site, deploying is only half the job. The sitemap needs regenerating and the search engines need telling.
Astro's sitemap integration generates a sitemap at build time from your routes, which means every deployment produces a current one automatically as long as you have set the site URL in your config. Without site set, the sitemap integration cannot build absolute URLs and will not produce useful output.
Beyond that, pinging IndexNow on deploy shortens the time between publishing and indexing considerably. The automation pattern for that is set out in indexnow-sitemap-automation, and it fits naturally into a post-build step.
If you are weighing Pages against other hosts for a JavaScript-heavy application rather than a content site, the trade-offs are laid out in deploy-react-app-vercel. The short version: for static content Cloudflare's network is hard to beat; for heavy server rendering the runtime constraints are real and worth measuring against your actual dependency tree.
Frequently Asked Questions
Why does my Astro build succeed but every page returns 404?
Almost always the build output directory setting. Astro writes to dist by default, and if your Pages project is pointed at build, out, public or the repository root, Cloudflare finds nothing to serve and returns 404 for everything. Open the build log, find the line where Astro reports where it wrote the output, and set the output directory to match exactly. A monorepo adds a second possibility: the root directory setting may also need adjusting.
Do I need the Cloudflare adapter for a static Astro site?
No. If every page can be generated at build time, Astro produces a folder of static files and Pages serves them directly with no adapter, no worker and no runtime. Adding the adapter to a fully static site gains you nothing and introduces edge runtime constraints you do not need. Add it only when you have a route that must render per request, and then consider keeping the rest of the site static rather than switching the whole project to server output.
Why is my environment variable undefined at runtime?
Two likely reasons. First, variables set only for Production do not exist in Preview deployments, so a branch build sees nothing. Second, Cloudflare's runtime does not populate process.env the way a Node host does; server-rendered routes receive environment values and bindings through the request context instead. A variable that works during the build and is undefined during a request is this exact problem. Check which environment the variable is scoped to, and check whether you are reading it at build time or request time.
Which variables end up visible in the browser?
Every variable whose name starts with PUBLIC_. Astro exposes those to client-side code by design, which means they are compiled into your JavaScript bundle and readable by anyone who opens dev tools. Never use that prefix for an API secret, a service role key, or anything else you would not print on the homepage. Use Cloudflare's secret variable type for sensitive values, and if one has ever been exposed in a bundle or a build log, rotate it rather than just renaming it.
Where do _redirects and _headers files go in an Astro project?
In the public/ directory. Astro copies the contents of public/ into dist/ unchanged during the build, which is how the files reach the build output where Cloudflare Pages looks for them. Placing them at the project root leaves them out of the output and they are silently ignored. After deploying, check the build log: Pages reports how many rules it parsed from each file, so a count of zero tells you immediately that the file did not arrive.