From 2862eca60140acbe201d2caaa001a2ae9d516e54 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Tue, 25 Aug 2026 10:44:42 +0000 Subject: [PATCH 1/5] Add a Frameworks section to Edge Scripting, with an Astro guide Edge Scripting could run a framework's server for a while, and nothing said so. The new section explains the shape: the framework's server becomes a standalone Edge Script, the client build goes to Bunny Storage, and the pull zone's origin is the script. The Astro guide is the first one. It covers the adapter, the two `bunny lab` commands, what one deploy does, the runtime the script gets, and the limits a page has to stay inside. It also names what is not built yet: rollback, and a preview URL per branch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M --- docs.json | 7 + scripting/frameworks/astro.mdx | 755 +++++++++++++++++++++++++++++++++ scripting/frameworks/index.mdx | 88 ++++ scripting/index.mdx | 4 + 4 files changed, 854 insertions(+) create mode 100644 scripting/frameworks/astro.mdx create mode 100644 scripting/frameworks/index.mdx diff --git a/docs.json b/docs.json index 42d9e59f..e427213b 100644 --- a/docs.json +++ b/docs.json @@ -478,6 +478,13 @@ } ] }, + { + "group": "Frameworks", + "pages": [ + "scripting/frameworks/index", + "scripting/frameworks/astro" + ] + }, { "group": "Deployment", "pages": [ diff --git a/scripting/frameworks/astro.mdx b/scripting/frameworks/astro.mdx new file mode 100644 index 00000000..b4ff1f3c --- /dev/null +++ b/scripting/frameworks/astro.mdx @@ -0,0 +1,755 @@ +--- +title: "Deploy an Astro Site on Edge Scripting" +sidebarTitle: "Astro" +description: "Deploy Astro to bunny.net with one command. Server-side rendering runs on Edge Scripting, and the build's files come from Bunny Storage." +--- + +[Astro](https://astro.build/) 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`](https://github.com/BunnyWay/bunny-adapters/tree/main/packages/astro) 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](/storage), and the script serves them from there. + +One command does all of it: + +```bash +bunny lab deploy astro +``` + + + `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`. + + + + A live Astro site on Edge Scripting. Every page demonstrates one adapter + capability, and the [source is on + GitHub](https://github.com/BunnyWay/bunny-adapters/tree/main/examples/astro-showcase). + + +## Deploy your first site + + + + ```bash + curl -fsSL https://cli.bunny.net/install.sh | sh + bunny login + ``` + + See [installation](/cli/installation) for npm, Bun, and Windows. + + + + + From your Astro project: + + ```bash + bunny lab deploy astro + ``` + + The command puts the adapter in, builds the site, creates what the site needs, and publishes it: + + ``` + ? Add @bunny.net/astro-adapter to this project? › (Y/n) + ✓ Added the adapter to astro.config.mjs. + + ℹ Running: npm run build + … + [@bunny.net/astro-adapter] Bundled to dist/index.js (653 kB, limit 10.00 MB). 3 route(s) render per request. + [@bunny.net/astro-adapter] Wrote .bunny/build.json. Deploy it with: bunny lab deploy astro + + ℹ Applied the pull zone settings: cookies on, Smart Cache off, cache override off. + ℹ Set 4 script variable(s): BUNNY_STORAGE_ZONE, BUNNY_STORAGE_HOST, BUNNY_STORAGE_KEY, BUNNY_PULLZONE_ID. + ✓ Deployed a1b2c3d4: 42 files (1.9 MB), script 653 KB. + ℹ https://astro-my-app-k3f9wq.b-cdn.net + storage zone astro-my-app-k3f9wq + edge script 86793 + pull zone 6411574 + ``` + + Open the URL. Reload it, and anything the page renders per request changes each time. + + The app's name comes from your `package.json`. Pass `--name` to choose one. + + + + + One command deletes the pull zone, the Edge Script, and the storage zone: + + ```bash + bunny lab undeploy astro + ``` + + 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](/scripting/secrets). + + +### What the deploy created + +| Resource | What it does | +| ------------------- | ------------------------------------------------------------------- | +| A storage zone | Holds each deploy's files, and your sessions | +| A pull zone | The app's URL and its cache. The Edge Script is its origin | +| An Edge Script | Astro's server, published as one file | +| `.bunny/astro.json` | Links this directory to those three. Keep it out of commits | +| `.bunny/build.json` | What the build produced. The CLI reads it, and it is a build output | + +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](/cdn/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: + +| What | Why | +| ------------------------------------------- | ---------------------------------------------------------------------------- | +| **Astro 7** | The adapter's peer range is `^7.0.0` | +| **No module-level state** | Each request may reach a different node, and a different isolate | +| **A route, not a fetch, for your own data** | A page that fetches its own API route pays for a second trip through the CDN | + +[Code your project may need to change](#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: + +``` +? Replace @astrojs/node with @bunny.net/astro-adapter? › (Y/n) +✓ Replaced @astrojs/node with @bunny.net/astro-adapter in astro.config.mjs. +``` + +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](#adapter-options). + +### In a monorepo + +Run the command at the repository root, and it finds the projects below it: + +``` +ℹ This directory holds no Astro project, and 4 below it do. +? Which one should be deployed? › docs +``` + +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](https://github.com/withastro/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: + +| What Astro built | Which command deploys it | +| -------------------------------- | ---------------------------------------------------------------------- | +| A route that renders per request | `bunny lab deploy astro`: the script, and the files it renders from | +| Every route prerendered | `bunny sites deploy`: the files alone, and nothing invoked per request | + +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: + +```js astro.config.mjs +adapter: bunny({ deploy: "server" }), +``` + +## 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: + +``` +✖ This project uses Astro 5, and @bunny.net/astro-adapter needs Astro 7. +Upgrade Astro first, and run its own migration: + + npx @astrojs/upgrade +``` + +Run that, read [Astro's upgrade +guide](https://docs.astro.build/en/guides/upgrade-to/v7/), 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: + +```js +// src/models/session.ts — this empties between requests on the edge +export const userCartItems = new Map(); +``` + +`withastro/astro/examples/ssr` keeps its shopping cart in exactly that. Deployed +as it stands, the cart accepts every item and shows none: + +```bash +curl -X POST https://your-app.b-cdn.net/api/cart -d '{"id":1,"name":"Cereal"}' +# {"ok":true} +curl https://your-app.b-cdn.net/api/cart +# {"items":[]} +``` + +Nothing errors, which is what makes it worth knowing before you deploy. Use +[`Astro.session`](#sessions) instead. It writes to the app's own storage zone, so +every node reads the same value, and the deploy sets it up: + +```js +export async function POST({ session, request }) { + const item = await request.json(); + const cart = (await session.get("cart")) ?? []; + await session.set("cart", [...cart, item]); + return Response.json({ ok: true }); +} +``` + +A database is the other answer. [Bunny SQL](/database) 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: + +```js +// src/pages/index.astro — one page render, two trips through the CDN +const products = await fetch(new URL("/api/products", Astro.url)).then((r) => + r.json(), +); +``` + +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: + +```js +import { products } from "../models/db"; +``` + +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: + +```bash +curl -X POST https://your-app.b-cdn.net/login \ + -H "Origin: https://your-app.b-cdn.net" \ + -d 'user=x' +``` + +Astro's [`security.checkOrigin`](https://docs.astro.build/en/reference/configuration-reference/#securitycheckorigin) +is where to turn it off, if a machine client really has to POST a form. + +## The everyday loop + +```bash +bunny lab deploy astro # build and publish +bunny lab deploy astro --no-build # publish the build already on disk +bunny lab deploy astro --force # publish again when nothing changed +bunny lab undeploy astro # delete the app and everything it runs on +``` + +A deploy with no changes does nothing, and says so. Every deploy publishes to production; a preview URL per branch is [not built yet](#what-is-not-here-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: + +```bash +git checkout +bunny lab deploy astro +``` + +### 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: + +```bash +bunny lab deploy astro --name my-app +``` + +Build-time variables belong to your own build. Set them in the shell, and the build inherits them: + +```bash +PUBLIC_API=https://api.example.com bunny lab deploy astro +``` + +Runtime variables belong to the script, and `astro:env` reads them: + +```bash +bunny scripts env set STRIPE_KEY sk_live_… --secret +bunny scripts env list +bunny scripts env pull # into .env, for local work +``` + +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: + +```yaml .github/workflows/deploy.yml +- run: npm ci +- run: npm install -g @bunny.net/cli +- run: bunny lab deploy astro --name my-app --yes + env: + BUNNY_API_KEY: ${{ secrets.BUNNY_API_KEY }} +``` + +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](/scripting/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: + +```bash +npm run build +npm run preview +``` + +It needs [Deno](https://deno.com/) 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](#a-site-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 + +``` +browser ──▶ pull zone ──▶ Edge Script (Astro's server) + │ + ├─ a route Astro owns ── rendered now + │ + └─ an asset or a prerendered page ─▶ Bunny Storage +``` + +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: + +| Path | Holds | +| ------------------------------- | ----------------------------------------- | +| `deploys/{id}/` | that deploy's client files | +| `_bunny/deploys/{id}/server.js` | that deploy's server bundle. Never served | + +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 + +| Request | Handled by | +| ------------------------------------------- | --------------------------------- | +| A server-rendered page | Astro, inside the script | +| A route in `src/pages/api/` | Astro, inside the script | +| `src/middleware.ts` | Astro, on every matched route | +| A page with `export const prerender = true` | Bunny Storage, read by the script | +| Hashed assets in `_astro/` | Bunny Storage, read by the script | +| An unknown path | Your prerendered 404 page | + +## The edge context + +Every page gets `Astro.locals.runtime`, which carries what the bunny.net network knows about the request: + +```ts src/middleware.ts +import { defineMiddleware } from "astro:middleware"; + +export const onRequest = defineMiddleware((context, next) => { + // Middleware also runs while Astro prerenders, where there is no request. + if (context.isPrerendered) return next(); + + context.locals.country = context.locals.runtime.country ?? "unknown"; + return next(); +}); +``` + +| Value | What it is | +| --------------- | --------------------------------------------------------------------------------------- | +| `country` | The visitor's country, from `cdn-requestcountrycode` | +| `requestId` | The bunny.net request id. Quote it in a support ticket | +| `clientAddress` | The visitor's IP. Also `Astro.clientAddress` | +| `waitUntil` | Keep working after the response goes out. See [waitUntil](/scripting/runtime#waituntil) | +| `caches` | The edge [Cache API](/scripting/cache) | +| `env` | Read a script environment variable | + +Add the types to your project: + +```ts src/env.d.ts +type BunnyRuntime = import("@bunny.net/astro-adapter").BunnyRuntime; + +declare namespace App { + interface Locals { + runtime: BunnyRuntime; + } +} +``` + +## Caching + +Astro's `routeRules` become the headers the CDN reads: + +```js astro.config.mjs +export default defineConfig({ + adapter: bunny(), + routeRules: { + "/products/[...slug]": { maxAge: 3600, swr: 600, tags: ["products"] }, + }, +}); +``` + +``` +Cache-Control: public, max-age=0, must-revalidate, s-maxage=3600, stale-while-revalidate=600 +CDN-Tag: products,astro-path:/products/socks +``` + +`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](/cdn/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 + +```ts +await Astro.cache.invalidate({ tags: ["products"] }); +await Astro.cache.invalidate({ path: "/products/socks" }); +``` + +That calls the [purge API](/cdn/purge-cache), 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: + +```bash +bunny scripts env set BUNNY_API_KEY --secret +``` + +## 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. + +```astro +--- +const cart = (await Astro.session.get("cart")) ?? []; +--- +``` + + + 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 `` component uses [Bunny Optimizer](/optimizer), which resizes and re-encodes at the edge. There is no build step, and no `sharp`: + +```js astro.config.mjs +bunny({ + imageService: "bunny", + image: { + widths: [360, 720, 1080], // used to build a srcset + quality: 82, + }, +}); +``` + + + **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: + +```bash +bunny api POST /pullzone/ --body '{"EnableCacheSlice": true}' +``` + +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](/cdn/frequently-asked-questions/range-requests) for what the CDN does with them. + +## Supported features + +| Astro feature | Supported | +| ------------------------------------------------------------ | ----------------------------------------------- | +| Server-rendered pages | Yes | +| Server endpoints and dynamic routes | Yes | +| Middleware | Yes | +| `Astro.request`, `Astro.locals`, `Astro.url`, `Astro.params` | Yes | +| `Astro.clientAddress` | Yes, from `x-forwarded-for` | +| `Astro.cookies`, including `set()` | Yes | +| `Astro.session` | Yes, in the site's storage zone | +| Prerendered pages, including `404.astro` and `500.astro` | Yes, served from Storage | +| A site under a path, with `base` | Yes | +| `redirects`, internal and external | Yes, with the status the route asks for | +| Locale routing with `i18n` | Yes | +| Content collections | Yes | +| Framework islands and `server:defer` | Yes | +| Astro actions | Yes | +| Static page headers, such as a CSP | Yes. In the script, or in `_headers` | +| `routeRules` and cache purging | Yes | +| Image transformation | Not yet. See [Images](#images) | +| Range requests on a stored object | Yes, and conditional requests too | +| `node:fs` and the other built-ins Deno provides | Yes. The file system is virtual and per isolate | +| `astro:env` secrets | Yes, from the script's environment variables | +| `astro preview` | Yes. With Deno for a build that has a script | +| The `sharp` image service | No. Native binaries cannot run on the edge | + +## Node built-ins + +Edge Scripting provides most `node:` modules, so a dependency that imports one is usually fine. + +[`node:fs`](/scripting/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. + +```js astro.config.mjs +bunny({ + // Caching + assetCacheControl: "public, max-age=31536000, immutable", + pageCacheControl: "public, max-age=60", + serverCacheControl: "private, no-store", // for a page that sets none itself + + // Features + imageService: "noop", // "bunny" for Optimizer, false to keep your own + sessions: true, // false to configure your own driver + cache: true, // false to configure your own cache provider + + // Build + outfile: "dist/index.js", + bundle: true, // false to run your own bundler + assetManifest: true, // false to look up every path in Storage instead + external: [], // modules esbuild must not bundle + sourcemap: false, +}); +``` + +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. + +```bash +bunny lab deploy astro +``` + +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](#what-is-not-here-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: + +```bash +npm run build +bunny storage files upload dist/client --zone my-site-assets # or your own uploader +bunny scripts deploy dist/index.js +``` + +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: + +``` +[@bunny.net/astro-adapter] Every route is prerendered, so this build deploys no +script: `bunny sites deploy` uploads dist/client, and the site is served as files. +``` + +Files alone cannot answer a 404 with a page, send a redirect, or add a header. The [`bunny sites` router](/cli/commands/sites#what-the-deploy-configures) does all three, for every framework. It learns what to do from three file names, which Cloudflare Pages and Netlify read too: + +| The router reads | Where it comes from | +| ---------------- | ------------------------------------------------------------------------- | +| `404.html` | Your own `404.astro` | +| `_redirects` | Written by the build, from your `redirects` config | +| `_headers` | Written by the build: your CSP, your `routeRules`, and the asset lifetime | + +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 `` 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](/storage/static-site-hosting) covers it. What it loses is those two files: a redirect becomes a `` 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 && 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](/scripting/limits), 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: + + ``` + dist/index.js is 22.30 MB, and Edge Scripting takes 10.00 MB. + + The largest parts of it are: + 6.41 MB shiki + 2.20 MB @astrojs/mdx + 1.12 MB astro + ``` + + 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](/scripting/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`](/scripting/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: + + ```bash + git checkout + bunny lab deploy astro + ``` + + 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](#images). + + diff --git a/scripting/frameworks/index.mdx b/scripting/frameworks/index.mdx new file mode 100644 index 00000000..ee45994b --- /dev/null +++ b/scripting/frameworks/index.mdx @@ -0,0 +1,88 @@ +--- +title: "Frameworks on Edge Scripting" +sidebarTitle: "Overview" +description: "Run a web framework on Bunny Edge Scripting. Render pages per request at the edge, serve the build from Bunny Storage, and deploy it all with one command." +--- + +A framework build has two halves: files that are the same for every visitor, and code that must run per request. Edge Scripting covers both when you pair it with [Bunny Storage](/storage). + +A [standalone script](/scripting/standalone/overview) is the origin of its own pull zone, so it sees every request the cache does not answer. Keep the build assets in a storage zone, and let the script decide what each path needs. + +## Two patterns + + + + The framework's own server runs inside the script. It renders pages and runs + API routes per request, exactly as it does on a Node host. + + + The framework prerenders everything. The script serves those files and adds + a little dynamic behaviour of its own. + + + +Pick SSR when you want the framework's server features: per-request rendering, API routes, middleware, and cookies. Pick the static pattern when the site is nearly all static and you only need a small dynamic edge. + + + If the site has no dynamic part at all, you do not need a script. Point a pull + zone straight at the storage zone. See the [frontend deployment + guides](/storage/static-site-hosting). + + +## How SSR works here + +``` +browser ──▶ pull zone ──▶ Edge Script (the framework's server) + │ │ + │ └─ route the framework owns ── render it now + │ + └─ asset or prerendered page ─▶ Bunny Storage +``` + +A framework's SSR build produces a request handler with the shape `(Request) => Response`. That is the same shape a script needs, so the script calls the handler directly. + +The build's client files have no filesystem at the edge. They go to a storage zone, which the site's pull zone serves. So the script renders, and the CDN delivers everything the build produced. + +## Deploying + +```bash +bunny lab deploy astro # build and publish +bunny lab undeploy astro # delete the app and everything it runs on +``` + +The [CLI](/cli) puts the adapter in, builds the site, and creates what it needs: a storage zone for the files, an Edge Script for the server, and the pull zone the script is the origin of. Later deploys are the same command. See [`bunny lab`](/cli/commands/lab) for the reference. + +`lab` says the interface is still being shaped, so a workflow built on either command should expect to be updated. + +Each deploy's client files go into their own folder in the storage zone, and the published script carries that folder's name. So a release can only read the files it was built against. There is no rollback: one Edge Script publishes one version at a time, so going back means deploying the commit you want. + +A build that prerenders every page needs no server, and [`bunny sites deploy`](/cli/commands/sites) deploys it as a directory of files. + +An adapter tells the CLI what it built, in a small manifest the build writes. The CLI reads the manifest, so it deploys an adapter without knowing anything about the framework, and a new adapter needs no new CLI. [Writing an adapter](https://github.com/BunnyWay/bunny-adapters/blob/main/docs/writing-an-adapter.md) has the contract. + +## What to know first + +**One file.** A deployment is a single JavaScript file, so the framework's server output has to be bundled. An adapter does that for you. A small Astro SSR build lands near 660 kB, well inside the 10 MB [script size limit](/scripting/limits). + +**Astro has an adapter. Other frameworks do not, yet.** Install +[`@bunny.net/astro-adapter`](https://github.com/BunnyWay/bunny-adapters), and +Astro builds straight to a deployable script that `bunny lab deploy astro` +publishes. It needs Astro 7. For any other framework you write the handler +yourself, and deploy it with [`bunny scripts deploy`](/cli/commands/scripts). +That is workable, because all an adapter really does is hand you the app object. + +The adapters live in one repository, +[BunnyWay/bunny-adapters](https://github.com/BunnyWay/bunny-adapters). Ask there +for the framework you need. + +**Node-only dependencies do not travel.** Anything that needs native binaries or the filesystem must go. Image services that use `sharp` are the usual example. Swap them for a no-op or an edge-friendly service. + +**Cache misses only.** By default a script runs when the cache misses. Turn on [before cache execution](/scripting/before-cache) if every request must reach the framework. + +## Guides + + + + Render Astro per request at the edge with the official adapter. + + diff --git a/scripting/index.mdx b/scripting/index.mdx index 39a1ea69..245d073e 100644 --- a/scripting/index.mdx +++ b/scripting/index.mdx @@ -60,4 +60,8 @@ Edge Scripting runs on [Deno](https://deno.com/) and V8, the engine behind Chrom > See practical examples for standalone and middleware scripts. + + Serve a framework build from Bunny Storage and run its dynamic routes at the + edge. + From 065d0d1db603b2d68c0969dbc30234b80e497440 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Tue, 25 Aug 2026 10:44:55 +0000 Subject: [PATCH 2/5] Document bunny sites and bunny lab The CLI reference had a page per command group, and these two were missing. `bunny sites` deploys a directory of files. `bunny lab` holds the commands still being shaped, which today means deploying an Astro project that renders pages per request. Each page says which shape of project it is for, and names the other one, because picking the wrong command is the first mistake to make. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M --- cli/commands/lab.mdx | 159 ++++++++++++++++++++++++ cli/commands/sites.mdx | 266 +++++++++++++++++++++++++++++++++++++++++ docs.json | 2 + 3 files changed, 427 insertions(+) create mode 100644 cli/commands/lab.mdx create mode 100644 cli/commands/sites.mdx diff --git a/cli/commands/lab.mdx b/cli/commands/lab.mdx new file mode 100644 index 00000000..a922af20 --- /dev/null +++ b/cli/commands/lab.mdx @@ -0,0 +1,159 @@ +--- +title: bunny lab +sidebarTitle: lab +description: "Deploy an Astro project that renders pages per request, with one command. Astro's server runs on Edge Scripting, and the client build comes from Bunny Storage." +--- + +`bunny lab` holds commands we are still shaping. The name is the warning: the flags and the output can change between CLI releases, so a workflow built on one should expect to be updated. + +Astro is the first, and it has two commands and no more: + +```bash +bunny lab deploy astro # build this project, then deploy it +bunny lab undeploy astro # delete the app and the resources it runs on +``` + +Server-side rendering only. A build that prerenders every page is a directory of files, and [`bunny sites deploy`](/cli/commands/sites) deploys one; this command refuses it and says which command to run. + + + The full Astro guide is at [Deploy an Astro + site](/scripting/frameworks/astro). This page is the command reference. + + +## Deploy + + + + ```bash + bunny login + ``` + + + + ```bash + bunny lab deploy astro + ``` + + ``` + ? Add @bunny.net/astro-adapter to this project? › (Y/n) + ✓ Added the adapter to astro.config.mjs. + + ℹ Running: npm run build + … + ℹ Applied the pull zone settings: cookies on, Smart Cache off, cache override off. + ℹ Set 4 script variable(s): BUNNY_STORAGE_ZONE, BUNNY_STORAGE_HOST, BUNNY_STORAGE_KEY, BUNNY_PULLZONE_ID. + ✓ Deployed a1b2c3d4: 42 files (1.9 MB), script 653 KB. + ℹ https://astro-my-app-k3f9wq.b-cdn.net + storage zone astro-my-app-k3f9wq + edge script 86793 + pull zone 6411574 + ``` + + + + +Nothing above asks for a password. The CLI creates the storage zone, so it already holds the credentials. It sets them on the script as [secrets](/scripting/secrets). + +## What it creates + +Three resources, all named after your app: + +| Resource | Name | Holds | +| -------------- | ----------------------------- | --------------------------------------- | +| A storage zone | `astro--` | The client build, one folder per deploy | +| An Edge Script | `astro---server` | Astro's server, published as one file | +| A pull zone | `astro--` | The app's URL. The script is its origin | + +The suffix is there because a storage zone name and a pull zone name are unique across all of bunny.net. The app name comes from `--name`, then `.bunny/astro.json`, then your `package.json`. + +The script is the pull zone's **origin**, so nothing sits between a request and the code. That is the difference from `bunny sites`, where the script is a router in front of a storage origin. + +`.bunny/astro.json` links this directory to those three resources, and the command adds `.bunny/` to your `.gitignore`. It is a pointer, not a source of truth: `--name` finds the same resources by name, which is what a fresh clone or a CI runner does. + +## What one deploy does + +1. **Finds the project.** A monorepo root is not one, so it offers the projects below it. +2. **Checks the Astro version.** The adapter needs Astro 7. An older project stops here, and the message names `npx @astrojs/upgrade`. +3. **Puts the adapter in.** It installs `@bunny.net/astro-adapter` and sets it in the Astro config. Another host's adapter is replaced, and its package uninstalled. +4. **Builds**, unless `--no-build`. +5. **Reads `.bunny/build.json`.** It has to say `kind: "ssr"`. +6. **Reads the bundle.** Over 10 MB the platform refuses it, so this refuses first, before any resource exists. +7. **Finds or creates the three resources.** Each is looked up before it is made, so a half-finished create re-runs cleanly. +8. **Applies the pull zone settings.** What the manifest asks for, and the zone's cache override off. +9. **Sets the script's variables.** +10. **Uploads the client build, then publishes the code.** In that order, always. +11. **Purges, waits, purges.** +12. **Asks the site for a page**, and for a path it does not hold. +13. **Deletes every deploy folder** but this one and the one before it. + +A deploy that changes nothing does nothing, and says so. `--force` publishes it again. + + + A green line above a URL that answers `400` is the worst thing this command can + do, so a script that does not start is reported instead: + +``` +⚠ The site answered 400, so the script is not serving. + The script is 8.2 MB, and a script has 500 ms to start. Every byte is parsed first. +``` + +A script has 500 ms to start, and every byte of it is parsed first. Measured in +August 2026: the same code served every request at 7.4 MB, and none at 7.8 MB, +well under the documented 10 MB limit. + + + +## Undeploy + +```bash +bunny lab undeploy astro # this directory's app +bunny lab undeploy astro --name my-app # by name, with no state file +bunny lab undeploy astro --keep-storage # keep the files +``` + +It lists the three resources first, then asks you to type the app's name: + +``` +ℹ "my-app" is made of: + pull zone 6411574 + edge script 86793 + storage zone astro-my-app-k3f9wq + +? Delete "my-app" (its pull zone, Edge Script, and ALL uploaded files)? This cannot be undone. › (y/N) +``` + +The pull zone goes first, because it is the only public thing. A resource the API already reports as missing counts as deleted, so a run that failed halfway can simply be repeated. + +## Unattended runs + +```bash +bunny lab deploy astro --name my-app --yes --output json +bunny lab undeploy astro --name my-app --force +``` + +`--yes` is what lets the command install the adapter and edit the Astro config without asking. Without it, an unattended run prints the two changes and stops: a deploy command that silently rewrites your source in CI is worse than one that refuses. + +`--output json` carries the deploy's ID, its URL, its sizes, whether the site is serving, and every variable the CLI could not set. + +## Every command + +```bash +bunny lab deploy astro [dir] +bunny lab deploy astro --name my-app # the app's name +bunny lab deploy astro --region NY # storage region for a new app (default: DE) +bunny lab deploy astro --no-build # deploy the build already on disk +bunny lab deploy astro --yes # add the adapter without asking +bunny lab deploy astro --force # deploy again when nothing changed + +bunny lab undeploy astro [dir] +bunny lab undeploy astro --name my-app +bunny lab undeploy astro --keep-storage +bunny lab undeploy astro --force +``` + +## What is not here yet + +- **Rollback.** One Edge Script publishes one version at a time. Deploy the commit you want instead. +- **A preview URL per branch.** Every deploy publishes to production. Deploy a second app for staging: `--name my-app-staging`. +- **A custom domain from the command line.** Add it to the app's pull zone in the dashboard, under **CDN** > your pull zone > **Hostnames**. +- **Any framework but Astro.** SvelteKit and Next.js are planned. diff --git a/cli/commands/sites.mdx b/cli/commands/sites.mdx new file mode 100644 index 00000000..222f73b1 --- /dev/null +++ b/cli/commands/sites.mdx @@ -0,0 +1,266 @@ +--- +title: bunny sites +sidebarTitle: sites +description: "Deploy a directory of files to bunny.net with one command. Every deploy is immutable, and publishing or rolling back is instant." +--- + +`bunny sites` hosts a site on bunny.net. Each site is three resources, provisioned and wired together for you: + +| Resource | What it does | +| -------------- | ------------------------------------------ | +| A storage zone | Holds every deploy's files | +| A pull zone | The site's URL, its cache, and its domains | +| An Edge Script | The site's router, which this CLI writes | + +Zones are named `sites--`. The prefix groups them in the dashboard. The suffix is there because zone names are global across bunny.net. The commands take the clean site name. + +The [Edge Script](/scripting) is the router this CLI writes. It maps each request to the deploy that answers it, and it serves the deploy's own `404.html`, `_redirects` and `_headers`. + +```bash +bunny sites deploy ./dist # deploy a directory, and publish it as the live site +bunny sites deploy --build # run the project's own build first +``` + + + A build that renders pages per request is a different shape, and this command + does not deploy one. [`bunny lab deploy astro`](/cli/commands/lab) deploys an + Astro project as an Edge Script, with its client build in Bunny Storage. + + +## Deploy this project + + + + ```bash + bunny login + ``` + + + + ```bash + bunny sites deploy --build + ``` + + With no site linked yet, the command offers to create one: + + ``` + ? Site name: › my-site + ✓ Created site "my-site". + ✓ Deployed a1b2c3d4: 42 files (1.9 MB). + ℹ Preview https://sites-dpl-a1b2c3d4-x9k2mq.b-cdn.net + ? Publish this deploy as the live site? › (Y/n) + ``` + + + + + The site's first deploy offers one. To add it later: + + ```bash + bunny sites domains add www.example.com + ``` + + + + +Nothing above asks for a password. The CLI creates the storage zone, so it already holds the credentials. It sets them on the script as [secrets](/scripting/secrets). + +Every deploy goes to its own directory in the storage zone. So publishing an earlier one restores its pages, and the files those pages name, together. + +## Deploy a directory + + + + ```bash + bunny sites deploy ./dist + ``` + + The files go up, and the deploy becomes the live site. A site with no linked directory is created first, or picked. + + + + + ```bash + bunny sites deployments publish --previous + ``` + + Publishing points the router at another deploy and purges the cache. Nothing moves, so going live and rolling back are both instant. + + + + +### Immutable deploys + +Every deploy uploads to its own directory in the storage zone, and stays there. Publishing only moves the router's pointer, so any past deploy goes live again with nothing re-uploaded. + +A deploy serves from the site's root, not under a path prefix. So a client-side router and a root-absolute asset work with no configuration. + +The deploy ID is the git short SHA when the working tree is clean, and a content hash otherwise. So re-deploying identical content is a no-op. Use `--force` to override that. + +```bash +bunny sites deployments list # ● Live / ○ Previous, created, source, files, size +bunny sites deployments publish a1b2c3d4 # promote a past deploy +bunny sites deployments publish --previous # instant rollback +bunny sites deployments prune --keep 10 # delete old deploys (never the live or previous one) +``` + +## What the deploy configures + +The site's router reads three file names out of the deploy it is serving. Cloudflare Pages and Netlify read the same three. So a build that already writes them works here unchanged, and the router knows no framework. + +| File in the deploy | What the router does with it | +| ------------------ | ----------------------------------------------------------------- | +| `404.html` | Answers a path the deploy does not hold, at status 404 | +| `_redirects` | Sends a redirect with a real status | +| `_headers` | Adds response headers, which Bunny Storage cannot hold on its own | + + + Without a `404.html` in the deploy, a missing path answers with bunny.net's + own error page instead of your site's. So `bunny sites deploy` asks the + published site for a path it cannot hold. It warns you when the answer is not + your page. + + +### `_redirects` + +One rule per line: a path, a target, and an optional status. Lines starting with `#` are comments. + +``` +# dist/_redirects +/old /about +/gone /about 302 +/blog/* /news/:splat 301 +/moved /about 301! +``` + +- The default status is `301`. `302`, `303`, `307` and `308` are read too. A rewrite (`200`) is not supported. +- A trailing `*` in the path is captured, and `:splat` in the target is replaced with it. +- A rule applies only where the deploy holds no file at that path, so a real file always wins. `!` after the status forces the rule ahead of the file. +- A path matches with or without its trailing slash, so `/about` and `/about/` are one rule. + +### `_headers` + +A line starting with `/` opens a block, and the indented `Name: value` lines below it belong to that block. Where two blocks match one path, the later one wins that header name. + +``` +# dist/_headers +/* + X-Frame-Options: SAMEORIGIN +/assets/* + Cache-Control: public, max-age=31536000, immutable +/admin/ + X-Robots-Tag: noindex +``` + +### Caching + +The router sets `Cache-Control` on every response, because Bunny Storage sends none for HTML: + +| What | How long | +| ------------------------- | ------------------------- | +| A page, and any document | `public, max-age=60` | +| Anything else | `public, max-age=2592000` | +| A missing page | `no-cache` | +| Anything `_headers` names | Whatever `_headers` says | + +Publishing purges the whole zone, so a promoted deploy answers at once. A purge does not reach a browser, which is why a page keeps only the one minute. A file whose name carries a content hash never changes. Name that directory in `_headers`, and the browser keeps it for a year. + + + A site created by an earlier CLI has an earlier router. `bunny sites deploy` + republishes it when it lags, and `bunny sites upgrade-router` does it on + demand. + + +## Custom domains + +A custom domain is the site's production URL. Previews never depend on one. + +```bash +bunny sites domains add shop.example.com # prints the DNS record to create +bunny sites domains add shop.example.com --wait # add, wait for DNS, then issue SSL +bunny sites domains list +bunny sites domains remove shop.example.com --force +``` + +## Every command + +```bash +# Provision +bunny sites create # prompts for a name +bunny sites create my-site --region NY # store the files in New York (default: DE) +bunny sites create my-site --domain example.com + +# Deploy +bunny sites deploy ./dist # deploy a directory of files +bunny sites deploy --build # run the configured or detected build first +bunny sites deploy --name my-site --region NY # create the site this deploy needs, unattended + +# Inspect +bunny sites list +bunny sites show # resources, domains, SSL state, current deploy +bunny sites open --print + +# Maintain +bunny sites ci init # GitHub Actions: deploy on push to main +bunny sites link my-site +bunny sites unlink +bunny sites upgrade-router +bunny sites delete my-site --keep-storage +``` + +## Which site a command acts on + +Most commands take the site as an optional positional. `deploy`, `ci init`, and `deployments publish` use `--site`. Either accepts the site name or its storage zone ID. + +With none given, the site resolves in this order: + +1. The directory's linked site, in `.bunny/site.json` +2. `sites.name` in `bunny.jsonc` +3. An interactive picker, which offers to link the directory + +A run that cannot prompt reports an error instead. That is `--output json`, no TTY, or `--force` on a destructive command. A `--name` is an instruction, so an unattended run with one creates the site and needs nothing else. + +Preconfigure the `sites` block in `bunny.jsonc` and a deploy needs no arguments: + +```jsonc +{ + "sites": { + "name": "my-site", + "build": "npm run build", + "dir": "dist", + }, +} +``` + +```bash +bunny sites deploy --build +``` + +A `dir` here is an instruction: deploy these files. The command then offers no adapter, and it deploys the directory whatever the project holds. + +Site state lives at `_bunny/site.json` inside the storage zone, which the router never serves. `.bunny/site.json` is only a local pointer. So a fresh clone can `bunny sites link`, and go on where the last machine stopped. + + + In a monorepo, run `bunny sites deploy` at the repository root, and it finds + the framework projects below it. Or run it in a project's own directory to + deploy that one. The package manager comes from the workspace, so a `pnpm` + monorepo gets `pnpm`. + + +## Flags + +| Flag | Commands | Description | +| -------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `--region`, `--domain` | `create` | Storage region (default `DE`); custom production domain to attach | +| `--name`, `--region` | `deploy` | Site name and storage region, for a site this deploy creates | +| `--site` | `deploy`, `ci init`, `deployments publish` | Site name or storage zone ID | +| `--build [cmd]`, `--env`, `--env-file` | `deploy` | Build before deploying; build-time environment overrides | +| `--force` | `deploy` | Deploy even when the content is unchanged | +| `--previous` | `deployments publish` | Publish the previous deploy | +| `--keep` | `deployments prune` | Recent deploys to keep (default 5; live and previous are always kept) | +| `--ssl`, `--wait`, `--force-ssl` | `domains add` | Issue SSL now; wait for DNS then issue it; `--no-force-ssl` keeps HTTP working | +| `--framework` | `ci init` | Framework preset for the workflow's build steps | +| `--print` | `open` | Print the URL instead of opening a browser | +| `--link` | `create`, `deploy`, `show`, `ci init`, `deployments` | Link the directory to the site; `--no-link` never links | +| `--keep-storage` | `delete` | Delete the pull zone and router, and keep the files | +| `--force`, `-f` | `deployments publish`, `prune`, `domains remove`, `delete` | Skip the confirmation prompt | diff --git a/docs.json b/docs.json index e427213b..3ba746fe 100644 --- a/docs.json +++ b/docs.json @@ -1060,6 +1060,8 @@ "group": "Commands", "pages": [ "cli/commands/auth", + "cli/commands/sites", + "cli/commands/lab", "cli/commands/config", "cli/commands/db", "cli/commands/scripts", From ae4c43ead24775841f03a96ff995ff4e77373917 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Tue, 25 Aug 2026 10:45:05 +0000 Subject: [PATCH 3/5] Say what a storage zone cannot do, on the three pages that imply it can Each of these pages describes a way to host files, and each left a reader to find the limit themselves. - The custom 404 page is read only at the root of the zone, it answers with status 404, and one page serves every hostname the zone has. A copy in a sub-folder is never read. - Static site hosting: a storage zone holds objects, and nothing else. It cannot answer a missing path with your page, send a redirect, or add a header. The page now names the four ways to get those. - Bunny Storage sends no `Cache-Control`, so one pull zone expiration covers every file in the zone. HTML and a hashed asset want opposite ends of that. The scripting limits page now says the two size limits work together: a script well below 10 MB can still miss the 500 ms startup budget, because every byte is parsed before it answers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M --- cdn/custom-404-page.mdx | 29 +++++++++++++++++ scripting/limits.mdx | 2 ++ storage/static-site-hosting/index.mdx | 46 +++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/cdn/custom-404-page.mdx b/cdn/custom-404-page.mdx index e1f38a74..257da972 100644 --- a/cdn/custom-404-page.mdx +++ b/cdn/custom-404-page.mdx @@ -30,7 +30,36 @@ When using a Storage Zone as your Pull Zone origin, you can configure a custom 4 └── 404.html ``` +The folder is only read at the root of the storage zone. A copy inside a sub-folder is ignored, even when your requests are rewritten into that sub-folder: + +``` +/your-storage-zone/ + ├── bunnycdn_errors/ + │ └── 404.html ✓ answers every missing path + └── releases/v2/ + └── bunnycdn_errors/ + └── 404.html ✗ never read +``` + +## What a visitor gets + +- The response status is `404`, not `200`. Your page is the body of a real 404. +- The page carries `Cache-Control: no-cache`, so the CDN caches no missing path, and every one of them reaches the origin. +- While the zone ignores query strings, which is its default, `?v=2` is not a way to re-test a path. Request a new path instead. + +One error page belongs to one zone, so it answers every hostname the zone serves. A pull zone you use as a staging URL shows the production page. + This feature only works when your Pull Zone origin is a Bunny Storage Zone. For external origins, configure 404 handling on your origin server. + + + An [Edge Script](/scripting) on the zone changes nothing here. The uploaded + page still answers a missing path. A script that answers the path itself wins, + because the request never reaches storage. So a script is how a site serves a + **different** error page per deploy, per hostname, or per environment. One + folder in one zone cannot do that. [`bunny + sites`](/cli/commands/sites#what-the-deploy-configures) does it with the + `404.html` from your build. + diff --git a/scripting/limits.mdx b/scripting/limits.mdx index e5d42467..708a2c9a 100644 --- a/scripting/limits.mdx +++ b/scripting/limits.mdx @@ -13,6 +13,8 @@ description: "Execution limits for Edge Scripts, covering CPU time, memory, subr | Environment variables per Script | 128 | Maximum number of environment variables per script. | | Environment variable size | 2KB | Maximum size of environment variable value. | +The two size limits work together. A script well below 10 MB can still miss the 500ms startup budget, because every byte of it is parsed and evaluated before it answers its first request. A script that does not start in time makes the edge answer `400` with an empty body. Measured in August 2026 on a standalone script in DE, a bundled framework server served every request at 7.44 MB and none at 7.83 MB, so aim well below 10 MB for code that does real work as it loads. + These limits cover typical use under normal operating conditions, and they keep one script from taking resources away from everyone else's. Occasional spikes above a limit are tolerated. A script that consistently hits or exceeds one will be throttled or terminated. diff --git a/storage/static-site-hosting/index.mdx b/storage/static-site-hosting/index.mdx index 5cc150d9..36eab51d 100644 --- a/storage/static-site-hosting/index.mdx +++ b/storage/static-site-hosting/index.mdx @@ -6,6 +6,18 @@ description: Deploy static sites and single-page applications to Bunny Storage w Deploy your frontend applications to Bunny Storage and serve them globally through a Pull Zone. This guide covers the essentials for static sites and single-page applications (SPAs). +## One command, or by hand + +The [Bunny CLI](/cli) does all of the below for you, and adds what a storage zone cannot do on its own: + +```bash +bunny sites deploy ./dist +``` + +That creates the storage zone and the pull zone. It uploads each deploy to a directory of its own and publishes it instantly, so rolling back to an earlier one moves no files. It also reads three files out of your build: a `404.html` that answers a missing path, a `_redirects` file, and a `_headers` file. See [`bunny sites`](/cli/commands/sites). + +The rest of this page is the same deployment done by hand. + ## How It Works Bunny Storage combined with a Pull Zone provides a simple, fast way to host static frontends: @@ -70,6 +82,36 @@ This serves your `index.html` for all missing paths, and your client-side router + + Does your site also need API routes or per-visitor responses? Put an [Edge + Script in front of the storage zone](/scripting/frameworks) instead of a plain + pull zone. + + +## Redirects and Headers + +A storage zone holds objects, and nothing else. It cannot answer a missing path with your own page, send a redirect, or add a response header. Three ways to get those: + + + + Deploy with the CLI, and its router reads `404.html`, `_redirects`, and + `_headers` out of your build. The same file names Cloudflare Pages and + Netlify read + + + Redirect, rewrite, and set headers on the pull zone itself, one rule at a + time, in the dashboard or the API + + + Upload `bunnycdn_errors/404.html` to the root of the storage zone. One page + per zone, and it needs no script + + + Write the routing yourself, when your site needs a rule no list of rules can + express + + + ## Common Configuration ### Custom Domains @@ -96,3 +138,7 @@ Static frontends benefit from aggressive caching. Consider these settings in you Modern build tools like Vite add content hashes to filenames (e.g., `main.a1b2c3.js`), making it safe to cache these files indefinitely. + +Bunny Storage sends no `Cache-Control` header of its own. So the pull zone's cache expiration decides how long a file is kept, at the edge and in the browser. That one expiration covers every file in the zone. HTML therefore wants the short end of the table above, and a hashed file wants the long one. + +To give each of the two its own lifetime, the response has to carry the header. `bunny sites` does that per file: a page gets a minute, and a directory named in `_headers` gets a year. By hand, there are two ways. Write an [Edge Rule](/cdn/edge-rules) per path, or turn the zone's cache override off and set the header from an [Edge Script](/scripting). While the override is on, it replaces whatever the origin or the script returns. From d0df10331741531be4fa993237f5d5b29e007c98 Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Tue, 25 Aug 2026 10:47:04 +0000 Subject: [PATCH 4/5] Warn that the Astro adapter is not on npm yet The guide tells a reader to run one command, and the command installs a package that does not exist. The adapter is a lab project: it is built from the repository and added by path until it is published. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M --- scripting/frameworks/astro.mdx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripting/frameworks/astro.mdx b/scripting/frameworks/astro.mdx index b4ff1f3c..340d8d54 100644 --- a/scripting/frameworks/astro.mdx +++ b/scripting/frameworks/astro.mdx @@ -14,6 +14,14 @@ One command does all of it: bunny lab deploy astro ``` + + The adapter is a lab project, and it is not on npm yet. `npm install + @bunny.net/astro-adapter` does not work: build it from + [BunnyWay/bunny-adapters](https://github.com/BunnyWay/bunny-adapters) and add + it to your project by path. Its options and its build output can change + without notice, so 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 From 062c0a072cbc2aedbbf587207f6be19e3d9187af Mon Sep 17 00:00:00 2001 From: bogdan-at-bunny Date: Tue, 25 Aug 2026 11:56:14 +0000 Subject: [PATCH 5/5] Say that the Astro adapter is on npm `@bunny.net/astro-adapter@0.1.0` published today, so the warning that npm holds nothing is now wrong. It told the reader to build the adapter from the repository and add it by path, and that is no longer the way in. The warning keeps what stays true: this is a lab project, a minor release can change the options and the build output, and production can wait. It adds one instruction, which is to pin the version. The deploy step now names the command that adds the adapter by hand. `bunny lab deploy astro` offers to do it, and a reader who declines that prompt needs `npx astro add @bunny.net/astro-adapter`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M --- scripting/frameworks/astro.mdx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripting/frameworks/astro.mdx b/scripting/frameworks/astro.mdx index 340d8d54..b779d843 100644 --- a/scripting/frameworks/astro.mdx +++ b/scripting/frameworks/astro.mdx @@ -15,11 +15,10 @@ bunny lab deploy astro ``` - The adapter is a lab project, and it is not on npm yet. `npm install - @bunny.net/astro-adapter` does not work: build it from - [BunnyWay/bunny-adapters](https://github.com/BunnyWay/bunny-adapters) and add - it to your project by path. Its options and its build output can change - without notice, so do not run it in production yet. + The adapter is a lab project. It is on npm as + [`@bunny.net/astro-adapter`](https://www.npmjs.com/package/@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. @@ -80,6 +79,9 @@ bunny lab deploy astro 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.