If every page in your Astro site prerenders, deployment is a directory upload
and you need no adapter. If any route runs at request time, you need
@astrojs/cloudflare and a Worker entry point.
Both paths end with wrangler deploy. Start by deciding which one you are on.
bun run build
ls dist/_worker.js 2>/dev/null && echo "server output" || echo "static output"Requirements
- An Astro project that already builds. Fix build errors locally first; the Workers runtime will not tell you anything more useful than your own terminal.
- Wrangler 4. It comes from the project, not a global install:
bunx wrangler --version. - A Cloudflare account.
wrangler loginopens a browser and stores the token.
Deploy a static build
For output: 'static' — the default — there is no Worker code at all. Point
Wrangler at the build output and deploy.
{
"name": "my-astro-site",
"compatibility_date": "2026-07-01",
"assets": {
"directory": "./dist"
}
}bun run build
bunx wrangler deployTotal Upload: 412 assets
Uploaded my-astro-site (3.42 sec)
Deployed my-astro-site triggers (0.61 sec)
https://my-astro-site.example.workers.dev
Current Version ID: 7c1f0a2e-33b1-4a0e-9c3e-2f8e5b1d9a44That URL is live immediately. Everything below is only needed if you have server-rendered routes.
Add the adapter for server rendering
bun astro add cloudflare pnpm astro add cloudflare npm astro add cloudflare yarn astro add cloudflare The integration command edits astro.config.ts for you. Confirm the result
looks like this:
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';
export default defineConfig({
site: 'https://example.com',
output: 'server',
adapter: cloudflare({
platformProxy: { enabled: true },
}),
});platformProxy makes astro dev emulate Workers bindings locally through
Miniflare, so KV, R2 and D1 calls work in development instead of failing at
deploy time.
If most of your site is static and only a few routes need the server, keep
output: 'server' and mark the static pages with export const prerender = true. You get the same edge-cached HTML for those routes without splitting the
project.
Configure wrangler.jsonc for the Worker
Server output changes the shape of the config. Wrangler now needs an entry point as well as the assets directory:
{
"name": "my-astro-site",
"compatibility_date": "2026-07-01",
"compatibility_flags": ["nodejs_compat"],
"main": "./dist/_worker.js/index.js",
"assets": {
"directory": "./dist",
"binding": "ASSETS"
},
"observability": {
"enabled": true
}
}Four things matter here:
nodejs_compat gives you node: built-ins in the Workers runtime. Without it,
any dependency that imports node:buffer, node:path or node:crypto fails at
deploy or at first request.
main points inside the build output. Astro writes the Worker to
dist/_worker.js/, which does not exist until you run astro build — so build
before you deploy, always.
binding: "ASSETS" lets the Worker serve static files for routes it does not
handle. Astro’s adapter expects this name.
observability turns on Workers Logs, so wrangler tail and the dashboard
show real request logs rather than nothing.
Environment variables and secrets
Non-sensitive values go in the config file. Secrets never do.
{
"vars": {
"PUBLIC_SITE_URL": "https://example.com",
"ENVIRONMENT": "production"
}
} # Local development only. Add to .gitignore.
API_TOKEN=local-development-token
DATABASE_URL=postgres://localhost:5432/dev Push real secrets with Wrangler, which stores them encrypted on Cloudflare:
bunx wrangler secret put API_TOKEN
bunx wrangler secret listRead them at request time through the adapter’s runtime object:
import type { APIRoute } from 'astro';
export const GET: APIRoute = ({ locals }) => {
const token = locals.runtime.env.API_TOKEN;
return new Response(JSON.stringify({ configured: Boolean(token) }), {
headers: { 'content-type': 'application/json' },
});
};How requests reach your code
Worth understanding before you debug a routing problem.
An incoming request is checked against the static assets first. If a file matches the path, Cloudflare serves it directly from the assets store and your Worker code never runs — no CPU time, no invocation. If nothing matches, the request falls through to the Worker.
That ordering has two consequences. A server route whose path collides with a generated file will never execute, because the file wins. And a static site with no dynamic routes costs nothing per request beyond bandwidth, because there is no Worker to invoke.
The not_found_handling setting decides what happens when neither matches. Set
it to 404-page and Cloudflare serves your built 404.html; leave it default
and you get a bare response that does not look like your site.
Bindings for persistent state
A Worker has no filesystem and no memory between requests. Persistent state
comes from bindings declared in the config and read from
Astro.locals.runtime.env:
{
"kv_namespaces": [
{ "binding": "CACHE", "id": "your-namespace-id" }
],
"r2_buckets": [
{ "binding": "UPLOADS", "bucket_name": "site-uploads" }
]
}KV is eventually consistent and suits read-heavy caching. R2 is object storage with no egress fee. D1 is SQLite at the edge, and Durable Objects are the answer when you need strong consistency or coordination.
With platformProxy enabled, astro dev emulates all of these locally through
Miniflare, so binding code runs in development instead of failing on the first
production request.
Custom domain and cache headers
Attach a route in wrangler.jsonc rather than clicking through the dashboard,
so the mapping lives in the repo:
{
"routes": [
{ "pattern": "example.com", "custom_domain": true },
{ "pattern": "www.example.com", "custom_domain": true }
]
}The zone must already be on your Cloudflare account. Wrangler creates the DNS record and the certificate on first deploy.
Astro’s hashed asset filenames are immutable, so give them a long cache and let HTML revalidate:
{
"assets": {
"directory": "./dist",
"binding": "ASSETS",
"html_handling": "auto-trailing-slash",
"not_found_handling": "404-page"
}
}Set html_handling to match your trailingSlash setting in astro.config.ts.
Mismatched trailing-slash behaviour between Astro and Workers produces
redirect loops on exactly the pages you did not test.
Common errors
Error: No such module "node:buffer" — nodejs_compat is missing from
compatibility_flags, or compatibility_date predates the flag’s support.
Add both.
The entry-point file at "dist/_worker.js/index.js" was not found — you
deployed without building, or your Astro output is static and the config still
has a main. Run bun run build first, or remove main.
Your Worker exceeded the size limit — the bundled Worker is over the plan
limit (3 MiB compressed on the free plan). Usually one heavy dependency pulled
into a server route. Find it with bunx wrangler deploy --dry-run --outdir=out
and inspect out/.
ReferenceError: process is not defined at runtime — a dependency assumes
Node. nodejs_compat covers a lot of this, but not process.env reads in
library code that runs at module scope. Replace the dependency or shim it.
Every route returns the 404 page. The assets.binding is not named
ASSETS, so the Worker cannot fall through to static files.
Local wrangler dev works, production 500s. Almost always a missing
secret. wrangler secret list shows what production actually has;
.dev.vars is local-only and is never uploaded.
Production notes
Deploy previews before production. wrangler versions upload creates a version
with its own preview URL without shifting traffic, which is what you want from
CI on a pull request:
bunx wrangler versions upload # preview, no traffic
bunx wrangler deploy # promote to 100%
bunx wrangler deployments list
bunx wrangler rollback --message "bad release"Watch live logs during a release with bunx wrangler tail --format pretty.
Workers have a CPU time limit per request. Static asset responses do not touch
it, but a server-rendered route doing several sequential fetch calls can.
Measure with the request duration in Workers Logs before assuming it is fine.
Finally, keep compatibility_date pinned and bump it deliberately. It is the
switch that changes runtime behaviour underneath you, and moving it should be
its own commit with its own deploy.