Cloudflare Deployment guide Intermediate

How to Deploy an Astro Site to Cloudflare Workers

Ship a static or server-rendered Astro site to Cloudflare Workers with Wrangler — adapter setup, wrangler.jsonc, nodejs_compat, secrets, custom domains and the errors you hit on the first deploy.

An Astro build output being uploaded to the Cloudflare Workers edge

Tested with

Astro
7.1
Adapter
@astrojs/cloudflare, Astro 7 compatible release
Wrangler
4.x
Node
22.12.0 LTS
PackageManager
bun 1.3

Before you start

  • An Astro project that builds locally with a working `astro build`
  • A Cloudflare account (the free plan is enough)
  • Node.js 22 or Bun 1.2+
On this page

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.

bash
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 login opens 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.

wrangler.jsonc
{
  "name": "my-astro-site",
  "compatibility_date": "2026-07-01",
  "assets": {
    "directory": "./dist"
  }
}
bash
bun run build
bunx wrangler deploy
Expected output
bash
Total 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-2f8e5b1d9a44

That 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:

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

wrangler.jsonc
{
  "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:

bash
bunx wrangler secret put API_TOKEN
bunx wrangler secret list

Read them at request time through the adapter’s runtime object:

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

wrangler.jsonc
{
  "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:

wrangler.jsonc
{
  "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:

wrangler.jsonc
{
  "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:

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

Frequently asked questions

Do I need the Cloudflare adapter for a fully static Astro site?

No. If every route prerenders, `dist` is just files and you can serve them as Workers static assets with no Worker script at all. Add the adapter only when you need server-rendered routes, endpoints or middleware at request time.

Workers or Pages for a new Astro project?

Workers. Cloudflare now positions Workers with static assets as the default for new projects, and it is where new platform features land. Existing Pages deployments keep working, so there is no urgency to move a site that is already live.

Why does my build fail with "No such module node:buffer"?

The Workers runtime only exposes Node built-ins when the `nodejs_compat` compatibility flag is set and the compatibility date is recent enough. Add the flag in `wrangler.jsonc` and redeploy.

How do I read environment variables inside an Astro route on Workers?

Through `Astro.locals.runtime.env`, which the Cloudflare adapter populates with your vars, secrets and bindings. `import.meta.env` still works for values inlined at build time, but secrets are not available there.

Can I roll back a bad deploy?

Yes. `wrangler deployments list` shows recent versions and `wrangler rollback` restores a previous one. Note the version id printed by each deploy so you know what you are rolling back to.

Sources

  1. Deploy your Astro site to Cloudflare Astro Primary source Accessed 14 Jul 2026
  2. Cloudflare adapter reference Astro Primary source Accessed 14 Jul 2026
  3. Workers static assets Cloudflare Primary source Accessed 14 Jul 2026
  4. Wrangler configuration Cloudflare Primary source Accessed 14 Jul 2026
  5. Node.js compatibility for Workers Cloudflare Primary source Accessed 14 Jul 2026
4 min read

Part of a learning path

  • Cloudflare beginner

    How to Point a Cloudflare Domain at Your VPS

    Create the A record, pick the right proxy status, set the SSL mode to Full (strict) and lock the origin so only Cloudflare can reach it — plus what errors 521, 522, 525 and 526 actually mean.

    4 min
  • DevOps intermediate

    How to Build a GitHub Actions Deployment Pipeline

    Test, build a container image, push it to GHCR and deploy it to a VPS over SSH — with the concurrency guard, environment approval and token scoping that keep the pipeline from becoming the weak point.

    4 min AI-assisted
  • Cloudflare advanced

    How to Run Cloudflare Tunnel in Front of Traefik

    Expose Docker services through Cloudflare with no inbound ports open — cloudflared plus Traefik, the forwarded-header setting that trips people up, and how to verify the firewall is closed.

    4 min