Skip to main content
Astro is a server-first framework. It renders HTML on the server, and it ships only the JavaScript a page really needs. The @bunny.net/astro-adapter package runs that server inside an Edge Script. Astro renders pages and API routes per request, on a node near your visitor. The build’s static files live in Bunny Storage, and the script serves them from there. One command does all of it:
The adapter is a lab project. It is on npm as @bunny.net/astro-adapter, at version 0.1.0. Pin the version you install, because its options and its build output can change in a minor release. Do not run it in production yet.
bunny lab holds commands we are still shaping, so the flags and the output can change between CLI releases. There are two, and no more: bunny lab deploy astro and bunny lab undeploy astro.

See it running

A live Astro site on Edge Scripting. Every page demonstrates one adapter capability, and the source is on GitHub.

Deploy your first site

1

Install the CLI and log in

See installation for npm, Bun, and Windows.
2

Run bunny lab deploy astro

From your Astro project:
The command puts the adapter in, builds the site, creates what the site needs, and publishes it:
Open the URL. Reload it, and anything the page renders per request changes each time.The first prompt installs the adapter from npm. To add it yourself, run npx astro add @bunny.net/astro-adapter before you deploy.The app’s name comes from your package.json. Pass --name to choose one.
3

Take it down again

One command deletes the pull zone, the Edge Script, and the storage zone:
It lists the three resources, and asks you to type the app’s name. --keep-storage keeps the files.
Nothing above asks for a password. The CLI creates the storage zone, so it already holds the credentials, and it sets them on the script as secrets.

What the deploy created

All three are named after your app: astro-my-app-k3f9wq. The suffix is there because a storage zone name and a pull zone name are unique across all of bunny.net. The CLI also applies the pull zone settings the adapter asks for, and it says which ones it changed: cookies pass through, so Astro.cookies.set() works, Smart Cache goes off so the adapter’s own cache headers count, and the zone’s cache override goes off so those headers reach a visitor unchanged.

Add your own domain

The two commands do not attach one. Add it to the app’s pull zone in the dashboard, under CDN > your pull zone > Hostnames, and the free certificate is one click from there.

What the deploy needs from you

Three things, and only the first is usually a surprise: Code your project may need to change covers all three, with what each one looks like when it goes wrong.

Moving from another host

A project that already has an adapter keeps everything but that one line. The command names the adapter it found, swaps it in the config, and uninstalls the package:
Uninstalling matters as much as the swap. @astrojs/node@9 requires astro@^5, so leaving it in package.json after an upgrade to Astro 7 makes every later npm install in that project fail, with an error about peer ranges that says nothing about adapters. Adapter options do not carry over, because no two hosts share them. Anything the old adapter was configured with is worth reading again in the options below.

In a monorepo

Run the command at the repository root, and it finds the projects below it:
Or name one: bunny lab deploy astro ./docs. The package manager comes from the workspace, not from the directory, so a pnpm monorepo gets pnpm.

What the adapter does not change

The adapter adds itself to your config, and nothing else. It does not set output. Since Astro 5, a project that says nothing about output prerenders its pages, and a page asks for the edge with export const prerender = false. That default is the right one, and setting output: "server" over it is expensive: on astro.build it turned 4499 prerendered pages into pages that render per request, and took the script from 7.83 MB to 22.30 MB, past the limit. So the build asks Astro what it built, and Astro answers from the routes: The build says which one it chose. A site can also ask for the script outright. That is what a prerendered page holding a server:defer component needs, because Astro reports such a project as a fully prerendered one:
astro.config.mjs

Code your project may need to change

Most Astro projects deploy as they stand. Three things do not, and no deploy command can fix them for you. Both withastro/astro/examples/ssr and render-examples/astro-ssr met at least one.

Astro 7

The adapter’s peer range is astro@^7.0.0. An older project stops before anything is installed:
Run that, read Astro’s upgrade guide, and deploy again. A framework major moves APIs, so the upgrade is yours to take, with your own tests in front of you. render-examples/astro-ssr ships Astro 5, so it needs this step. Without the check, npm install answers with an ERESOLVE about peer ranges that names no version to move to.

State in a module does not survive a request

Each request may reach a different edge node, and a different isolate. So a value held at module scope is gone by the next request:
withastro/astro/examples/ssr keeps its shopping cart in exactly that. Deployed as it stands, the cart accepts every item and shows none:
Nothing errors, which is what makes it worth knowing before you deploy. Use Astro.session instead. It writes to the app’s own storage zone, so every node reads the same value, and the deploy sets it up:
A database is the other answer. Bunny SQL is one, and any HTTP-reachable database is another. This is not a bunny.net rule. Any host that runs your server in more than one place has it, and a single long-lived Node process is what hid it.

Do not fetch your own site to reach your own route

A page that calls its own API route over HTTP works, and costs more than it looks:
The request leaves the script, goes out through the CDN, and comes back into the same script. withastro/astro/examples/ssr does this on every page. Import the data instead, and the page renders in one pass:
Keep the API route for the browser to call. The page does not need it.

A form POST answers 403

That is Astro, not the platform. security.checkOrigin is on by default for output: "server", and it rejects a form POST whose Origin header does not match the site. A browser always sends the header; curl has to be told to:
Astro’s security.checkOrigin is where to turn it off, if a machine client really has to POST a form.

The everyday loop

A deploy with no changes does nothing, and says so. Every deploy publishes to production; a preview URL per branch is not built yet. Each deploy goes into its own folder in the storage zone, and the folder’s name is written into the top of the bundle. So a published release can only read the files it was built against, and the files go up before the script is published: a script published first would render pages naming files that are not there yet. There is no rollback. One Edge Script publishes one version at a time, and the two commands do not keep the older versions to go back to — every deploy folder but the current one and the one before it is deleted after a publish. To go back, deploy the commit you want:

Configure the app

.bunny/astro.json is all a repeat deploy needs, and the command adds .bunny/ to your .gitignore. A deploy from a fresh clone, or from CI, names the app instead:
Build-time variables belong to your own build. Set them in the shell, and the build inherits them:
Runtime variables belong to the script, and astro:env reads them:
Those need the script’s ID, which the deploy prints and .bunny/astro.json keeps.

Continuous integration

A deploy needs an API key and nothing else, so a workflow is three lines:
.github/workflows/deploy.yml
Two flags earn their place there. --name says which app to deploy to, because .bunny/ is git-ignored and a runner has no state file. --yes lets the command install the adapter and edit the Astro config without asking: without it, an unattended run prints the two changes and stops, rather than rewriting your source in silence. Commit the adapter and the config change once, from your own machine, and the workflow then needs neither flag’s permission — only --name. Add --output json when a later step reads the result. See GitHub Actions for the details.

Run it locally

Use npm run dev for everyday work on the site. It is Astro’s dev server, and the adapter stays out of the way. npm run preview runs the file you are about to deploy. A local storage zone stands in for Bunny Storage, so assets, prerendered pages, and sessions all work with no account and no network:
It needs Deno 2, because Deno is the Edge Scripting runtime. The cdn- request headers only exist on the bunny.net network, so anything your code reads from them is absent locally. A build with no script has no file to run, so npm run preview serves dist/client from Astro’s own static server. That needs no Deno.

How it works

The script sees every request the cache does not answer. It renders the routes Astro owns, and reads everything the build produced out of Bunny Storage. The adapter inlines the list of built files into the script, so a request for a path the build never produced costs no lookup at all. Each deploy is immutable, and it has two halves in the same storage zone: The CLI writes the deploy’s own folder name into the top of the bundle it publishes. So a published version can only read the files it was built with, which is what makes a rollback restore a page and its assets together. An Astro server bundle names the hashed CSS file it renders, so the two really are one unit.

What runs where

The edge context

Every page gets Astro.locals.runtime, which carries what the bunny.net network knows about the request:
src/middleware.ts
Add the types to your project:
src/env.d.ts

Caching

Astro’s routeRules become the headers the CDN reads:
astro.config.mjs
s-maxage is for the CDN and max-age is for the browser. Splitting them is what makes a purge take effect straight away. A page that sets no Cache-Control gets private, no-store. That matters: a pull zone applies its own expiration to a response that carries no directive, and a page rendered for one visitor must never reach another. A route with a routeRules entry keeps what the rule gave it.
Smart Cache only caches known static file extensions, and HTML is not one of them, so a routeRules entry does nothing while it is on. bunny lab deploy astro turns it off for you when your project sets routeRules, and reports the change.

Purging

That calls the purge API, which is an account operation. The CLI sets the pull zone id for you. The key is yours to add, because it is account-wide:

Sessions

Astro.session works with no setup. Each session is one object in the site’s storage zone, so every edge node reads the same value, and nothing serves those objects to the web.
Bunny Storage does not expire an object, so session.ttl controls the cookie and not the stored object. Delete old objects yourself if the zone grows.
Pass sessions: false to configure your own driver.

Images

Set imageService: "bunny" and Astro’s <Image> component uses Bunny Optimizer, which resizes and re-encodes at the edge. There is no build step, and no sharp:
astro.config.mjs
Image transformation on a script-backed site is not ready yet. Turn Optimizer on today and an image request that misses the cache can answer 523 Origin Connection Failed. We are fixing it. Until then, leave imageService at its default: the parameters are ignored, and the original image is served.
Optimizer only works on files your own pull zone serves, so an image on another host passes through untouched.

Large files

A stored object can be fetched in pieces. The script answers a Range request with 206 and a Content-Range, and it says Accept-Ranges: bytes on everything it serves out of storage. That header is the part that matters. A pull zone will not answer a range from its cache, and will not slice an object, unless the origin says it accepts ranges. Without it a video is only seekable once it is fully cached. For a large file that nobody has requested yet, turn on Optimize for large object delivery in the pull zone’s caching settings, and the zone fetches the object in chunks so the first request is seekable too:
The script also passes If-None-Match and If-Modified-Since through, so a browser and the pull zone both revalidate with a 304 instead of downloading the object again. See range requests for what the CDN does with them.

Supported features

Node built-ins

Edge Scripting provides most node: modules, so a dependency that imports one is usually fine. node:fs works over a virtual file system. It starts empty on every cold start, one isolate cannot see what another wrote, and what it holds counts against the script’s memory. So it is a scratch pad for one request, and never a store. Anything that has to outlive a request belongs in Bunny Storage, which is where the adapter keeps files and sessions. A package with a native binary never works. sharp is the usual one, and the adapter already replaces it.

Adapter options

Every option has a sensible default, so bunny() on its own is usually right.
astro.config.mjs
No credential is ever an option. The CLI sets each one on the script, so nothing reaches the bundle or your configuration file.

Moving from bunny-astro

Earlier versions of the adapter shipped a bunny-astro command. bunny lab deploy astro replaces it, and adds provisioning and teardown.
That creates a new app: its own storage zone, script, and pull zone, and its own URL. Adopting the zone and script you already have is not built yet, so until it is, either move your domain to the new pull zone in the dashboard, or keep deploying the old pair by hand:
A deploy you run yourself puts the files at the zone root, which is where the script looks when nothing sets BUNNY_ASSET_PREFIX.

A site with no script

A site whose routes are all prerendered needs no script, and no adapter. bunny sites deploy uploads the build and publishes it, every deploy stays immutable under its own ID, and nothing is invoked per request. The build says so:
Files alone cannot answer a 404 with a page, send a redirect, or add a header. The bunny sites router does all three, for every framework. It learns what to do from three file names, which Cloudflare Pages and Netlify read too: So keeping the adapter on a prerendered site is worth it for those two files. It costs nothing at run time. Astro also writes its own <meta http-equiv="refresh"> page for each redirect. A deploy that never reaches the router therefore still sends the visitor on. The rule in _redirects carries !, and that is what makes the router’s real 301 win over the page. npm run preview on such a build serves dist/client from Astro’s own static server, and needs no Deno. A site with no adapter at all works too, and the static site hosting guide covers it. What it loses is those two files: a redirect becomes a <meta> refresh, and a header cannot be set.

What is not here yet

Three things this guide’s commands do not cover:
  • Rollback. One Edge Script publishes one version at a time, and the two commands keep no older version to return to. Deploy the commit you want instead: git checkout <commit> && bunny lab deploy astro.
  • A preview URL per branch. Every deploy publishes to production. A preview has to be its own Edge Script, because a page and the files it names are one unit. Until then, deploy a second app for staging: bunny lab deploy astro --name my-app-staging from a checkout of the branch.
  • A custom domain from the command line. Add it to the app’s pull zone in the dashboard, under CDN > your pull zone > Hostnames.
  • Adopting an app you built by hand. The command creates its own storage zone, script, and pull zone. There is nothing yet that takes over a pair you made yourself.
  • Logs from the command line. Read them in the dashboard, under Scripting > your script > Logs.

Troubleshooting

The script did not start, so nothing answered the request. A large script is the usual reason, and 10 MB is not the size to aim at: a script has 500ms to start, and every byte of it is parsed and evaluated first.Measured in August 2026, on a standalone script in DE: the same code served every request at 7.44 MB, and answered 400 to all of them at 7.83 MB. Around 7.5 MB the first request failed and later ones worked. How much code fits depends on what it does as it loads, so treat 7.5 MB as a warning and not a line.The build warns above 7.5 MB, and bunny lab deploy astro asks the site for a page before it reports success, so neither is silent about it. What to do is the same as for a script that is too large, below. Deploy the last commit that worked to put the site back while you work on it.
Edge Scripting takes one file of up to 10 MB, so a build that produced more than that fails, and names the packages that filled it:
Two things usually help. Prerender the routes that do not need a server, so that the pages which pulled a package in stop being part of the script. And keep a heavy dependency out of a page, and out of anything a page imports: a package that only runs at build time does not belong in the script.A small Astro site lands near 660 kB. Aim below 7.5 MB, not below 10 MB: see the 400 above.
A script has 500 ms to start. Move work out of module scope and into the request handler, and drop dependencies you do not need. See the limits.
A prerendered route has no server component to rewrite to. Return new Response(null, { status: 404 }) from the page instead, and your prerendered 404 page is served out of Bunny Storage.An endpoint in src/pages/api/ keeps its own 404, so a client asking for JSON is never handed a web page.
Smart Cache is on, and it does not cache HTML. bunny lab deploy astro turns it off and says so, so run the deploy again and read what it reports.
The pull zone is stripping the header. bunny lab deploy astro turns Disable cookies off for you. Run it again, and check the settings it reports.
Something removed the Cache-Control the adapter sets. Check that no edge rule overrides it, and that the page is not setting a public directive itself.
Astro checks the request origin for server output, and rejects a form POST whose Origin header does not match the site. This is Astro’s CSRF protection, not an Edge Scripting error. Send the request from the site itself, or change security.checkOrigin in astro.config.mjs.
Most node: built-ins work here, including node:fs, so check which module it is. The adapter rewrites a bare fs to node:fs for you, because the runtime only answers to the prefixed name.A package with a native binary never works. Replace that dependency, or keep it out of the server build.
The request never reached the script, so Astro’s own 404.astro never rendered. Read the script’s logs in the dashboard, under Scripting > your script > Logs: a script that fails as it starts answers this way.A deploy checks this for you: it asks the live site for a path the build cannot hold, and warns when the answer came from bunny.net rather than from Astro.
Deploy the last commit that worked, and compare the two builds:
A route that renders per request answers from the script, so check the script’s logs first. A prerendered page answers from the storage zone, so check that the build still writes it.
Optimizer cannot transform images for a script-backed site yet. Leave imageService at its default. See Images.
Last modified on August 25, 2026