Skip to content
Blume
Esc
↑↓navigate↵open⌘Jpreview
On this page

Deployment

Deploy static docs to any host with zero configuration, or name a host adapter from blume/deploy to switch to server rendering when you need dynamic behavior.

Deploy anywhere (static)

blume build compiles your docs to plain HTML, CSS, and a local search index in dist/. There’s no server to run — point any static host at the folder.

Setting Value
Build command blume build
Output directory dist
Node version 22.19 or newer

These settings work on Vercel, Netlify, Cloudflare Pages, GitHub Pages, or any bucket or CDN. Make sure blume is a dependency so the host can run the build.

Amazon S3 behind CloudFront, with a private bucket read through origin access control, needs two more pieces, because the S3 REST endpoint looks up each key exactly and serves each file with the type it was uploaded with:

  • A CloudFront Function on viewer requests that maps each page URL to the index.html Blume writes for it: /quickstart to /quickstart/index.html, and / (or any path ending in /) to its index.html. Leave a path whose last segment has a dot (/quickstart.md, /_astro/app.js) unchanged, and keep the function off /.well-known/*, whose api-catalog has no extension. Without it, S3 answers /quickstart with a 403 or 404.
  • Uploads that set the types a guess from the file extension gets wrong or leaves without a charset: text/markdown; charset=utf-8 for .md and .mdx, text/plain; charset=utf-8 for .txt, and application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727" for .well-known/api-catalog. dist/_headers lists every rule your build needs.

The S3 and CloudFront guide has a CloudFormation template with the function and a deploy script that uploads with these types.

On Cloudflare Workers, which serves a static build as static assets, set "html_handling": "drop-trailing-slash" under assets in your Wrangler config. Blume writes each page as <route>/index.html and links to it without a trailing slash, and Cloudflare’s default (auto-trailing-slash) answers /quickstart with a 307 to /quickstart/. A cloudflare() server build routes every page through its Worker, which serves /quickstart under either setting, so there it only matters when the build warns Could not wire Accept: text/markdown negotiation and the static layer serves the pages itself.

A static build includes:

  • every docs and custom page as static HTML
  • a local search index (Orama by default, Pagefind opt-in)
  • a sitemap.xml when the site URL is known
  • a robots.txt, which points to the sitemap when the site URL is known
  • llms.txt and llms-full.txt for AI tools
  • redirect pages
  • prerendered Open Graph images when seo.og.enabled is on

Set your site URL

Sitemaps, canonical tags, RSS, and Open Graph images need an absolute origin. On Vercel, Netlify, and Cloudflare Pages, Blume detects it from the platform’s environment at build time — no config required.

Set site, an absolute http:// or https:// URL, to override the detected value, or to provide one on hosts that don’t expose it (GitHub Pages, S3, a custom CDN). For a static build that’s the plain deployment object:

deployment: {
  site: "https://docs.example.com",
}

On Vercel and Netlify, automatic detection prefers your stable production domain over per-deploy preview URLs, so the canonical origin stays put across deploys. Cloudflare Pages exposes only the current deployment’s URL (CF_PAGES_URL), which changes with every deploy, so set site there. Cloudflare Workers exposes none, so set site on Workers too.

During blume dev, the site URL falls back to your local dev server (e.g. http://localhost:4321) when none is set, so site-gated features — Open Graph images, canonicals, the sitemap — work out of the box. Builds never use this fallback, so production output is never pointed at localhost.

Monorepos

Deploy the docs workspace as its own project, with the host’s root directory set to it (Vercel’s Root Directory, Netlify’s Base directory) and the settings above. On Vercel, leave Include source files outside of the Root Directory in the Build Step on, which is the default, whenever the docs read files outside their workspace: component examples or a workspace package they import, an @source path in theme.css, or a spec elsewhere in the repository. With it off, the build can’t reach those files. The monorepo guide walks through a pnpm and Turborepo setup.

Preview locally

Before you ship, preview the production build exactly as a static host would serve it:

blume build
blume preview

blume preview serves static builds and node() and cloudflare() server builds. The Vercel and Netlify adapters have no local preview server, so after a vercel() or netlify() server build it stops with an error instead. Try the site with blume dev, or deploy a preview with vercel deploy or netlify deploy.

On a static build, blume preview also answers what the host would, beyond the files in dist/. It applies the redirects, patterns included, with their configured status, as the host files the build writes do. It also handles trailing slashes the way static hosts do:

  • A page’s URL with a trailing slash (/guide/) redirects to the page (/guide), keeping the query string.
  • A folder of HTML you ship in public/ (public/demo/index.html) is served at /demo/, and /demo redirects there, so the relative links inside it resolve.

blume dev handles trailing slashes the same way.

Subpath deploys

Serving docs under a path like example.com/docs? Set base — common for GitHub Pages project sites. The whole site, root included, moves under the base, and internal links and assets are rewritten to include it, along with a root-relative href or src in raw HTML (<a href="/guide">, <img src="/logo.png">).

deployment: {
  base: "/docs",
}

base (and site) are options of every host adapter too: vercel({ base: "/docs" }). @astrojs/vercel ignores the base, so a vercel() server build moves the static files to .vercel/output/static/docs/ and the routes and redirects in .vercel/output/config.json under /docs itself.

Every page keeps its own path under the base, even in a content folder that shares the base’s name: with base: "/docs", docs/setup.md is served at /docs/docs/setup, and its canonical URL, sidebar link, and llms.txt entry point there. A root-relative link you write that already starts with the base is taken to include it and left as written, so link to that page with a relative link (./docs/setup) or its full path (/docs/docs/setup).

A different base per deploy

blume.config.ts is a module, so base can come from an environment variable. That lets CI build each pull request’s preview under its own path without editing the config:

deployment: {
  base: process.env.DOCS_BASE,
}
DOCS_BASE=/pr-1234 blume build

With DOCS_BASE unset, the site builds at the root, so production needs nothing extra.

Mount the docs under a path

basePath mounts every generated route under a segment (/docs/getting-started) while leaving the sidebar untouched — the top level is your sections, not a wrapper group. Use it when the docs live at /docs/* but the site root stays yours (like Docusaurus routeBasePath or Fumadocs baseUrl).

basePath: "/docs",

Write links as if mounted at root (/getting-started); Blume rewrites them, along with redirects, the sitemap, canonical URLs, Open Graph images, llms.txt, and the search index. Public assets (images, files under public/) stay at the site root, or under base when you set one.

This is a distinct concept from the two paths above:

  • A per-source prefix namespaces one source and does add a sidebar group.
  • deployment.base is the host subdirectory the whole app is served from. The two compose — with both set, a page lands at {deployment.base}/{basePath}/page.

Server rendering

Static output covers most docs. Switch to server output when you need request-time features — most notably the assistant endpoint and the MCP server. You do that by naming the host: deployment takes an adapter imported from blume/deploy.

import { defineConfig } from "blume";
import { vercel } from "blume/deploy";

export default defineConfig({
  deployment: vercel(),
});

Every adapter takes the same named options — site, base, and output — and forwards anything else verbatim to the underlying @astrojs/* adapter, so an option Blume doesn’t name still reaches it:

deployment: vercel({
  site: "https://docs.example.com",
  // Passed straight to @astrojs/vercel.
  isr: { expiration: 60 },
}),
Adapter Package Use for
vercel() @astrojs/vercel Vercel — the most polished path
netlify() @astrojs/netlify Netlify Functions
node() @astrojs/node Self-hosted Node servers, containers
cloudflare() @astrojs/cloudflare Cloudflare Workers

vercel() and node() ship with Blume — choosing one just works. netlify() and cloudflare() need their package installed in your project (e.g. bun add -d @astrojs/netlify). If it’s missing, blume build stops with the install command for your package manager, blume dev warns, and blume doctor reports it. With pnpm, also approve the build scripts those packages bring under allowBuilds in pnpm-workspace.yaml, beside the esbuild entry blume init writes: sharp for Netlify, whose adapter pulls in its own copy, and workerd for Cloudflare. pnpm 11 and later stop the install with ERR_PNPM_IGNORED_BUILDS until you approve them; pnpm 10 skips the scripts with a warning. allowBuilds needs pnpm 10.26 or later.

Naming an adapter switches the build to server output. To keep a static build on that host — with its site detection and the platform files it reads — pass output: "static":

deployment: netlify({ output: "static" }),

A server build includes everything a static build does, plus any Astro endpoints or middleware you add. The node() adapter produces a standalone server you can run directly with node dist/server/entry.mjs. It listens on localhost:4321 unless you set the HOST and PORT environment variables when you start it (HOST=0.0.0.0 PORT=8080 node dist/server/entry.mjs); host and port passed to node() have no effect, because @astrojs/node replaces them with Astro’s own server settings. Behind a reverse proxy, set allowedDomains so the rate limit counts each reader apart. The server resolves its packages through links into your project’s node_modules, so deploy the project with its installed dependencies, not dist/ alone. When the site publishes discovery files, serves images a content source downloaded, or configures redirects, Blume puts a small wrapper in front of Astro’s entry (moved to astro-entry.mjs beside it). It sends the .well-known discovery files with their media types and CORS headers and downloaded SVGs sandboxed, which the standalone server’s static handler can’t do on its own, and answers each redirect with its configured status.

A vercel() server build checks the function bundle the adapter traced for packages it imports but didn’t copy, which would crash the function on Vercel. A missing import of your own is a warning. A missing Blume dependency stops the build, and the build removes .vercel/output/config.json, so vercel deploy --prebuilt can’t ship the output without the routes Blume adds to it.

A cloudflare() server build runs on Cloudflare Workers only, since @astrojs/cloudflare dropped Cloudflare Pages in version 13; a static build doesn’t load the package, so its dist/ still deploys to Pages. The server build deploys with npx wrangler deploy from the project root after blume build: Blume writes the redirected Wrangler config to .wrangler/deploy/ (and adds .wrangler/ to .gitignore), and names the Worker after your project — the package.json name, else the site’s hostname, else the folder — unless a wrangler.jsonc of your own at the project root sets name.

On Vercel and Cloudflare, a server build also turns on Accept: text/markdown content negotiation, so an agent requesting any content page with that header receives its raw-Markdown mirror at the same URL. On Vercel, Blume splices header-conditional rewrites into the deploy’s routing config; on Cloudflare, it generates a small Worker in front of the Astro one and points assets.run_worker_first at every path except the fingerprinted build assets and the raw .md, .txt, .json, and .well-known files, since the platform would otherwise serve the prerendered pages — and its 404.html for a URL no page backs — before any server code runs; those exempted files keep their zero-Worker fast path. That Worker also answers the prerendered per-page JSON documents (/api/docs/pages/{route}.json) from the asset binding when a request for one reaches it, because Astro would otherwise route it to the /api/ catch-all.

Private docs

Blume has no sign-in of its own. A static build is plain files, and anyone who can reach the host can fetch anything in dist/. To keep a site private, turn on your host’s access protection, which checks every request before a file is served:

  • Vercel: Deployment Protection with the All Deployments scope, so production is covered and not only previews. Vercel Authentication lets in members of your Vercel team; Password Protection lets in anyone with a shared password, on paid plans.
  • Netlify: password protection on all deploys, with a shared password, or with team login on Enterprise plans.
  • Cloudflare: Cloudflare Access on the Worker that serves the site (Workers & Pages, then your Worker, then Access), which covers every domain it answers on. Choose who can sign in by email address, email domain, or Cloudflare account membership.
  • GitHub Pages: publish the site privately from an organization on GitHub Enterprise Cloud, and only people with read access to the repository can view it.
  • Your own server: put authentication in front of dist/, such as nginx’s auth_basic or an identity-aware proxy.

Host protection works the same for server builds. It covers the whole site: to keep some pages public, publish them as a separate site.

Everything Blume writes to dist/ sits behind the protection, including pages, the search index, the .md copies, llms.txt, and Open Graph images. A few features reach outside it:

  • A hosted search provider (Algolia, Orama Cloud, Typesense, or Mixedbread) keeps its own copy of your content on that service. The built-in Orama, FlexSearch, and Pagefind indexes are files in dist/, so they stay private.
  • Services outside the protection can’t read your pages. Open in chat hands ChatGPT or Claude a link it can’t open, link previews in Slack or X can’t show your Open Graph cards, and agents can’t read llms.txt or the MCP server. Set ai.openInChat: false to hide the chat actions.

Redirects

Map old URLs to new ones in blume.config.ts:

redirects: [{ from: "/old", to: "/new", status: 301 }],

status accepts 301, 302, 307, or 308 (default 301). Server builds answer redirects at request time with the configured status, and so do blume dev and blume preview. Static builds emit redirect pages and the platform files your host reads, so it issues a real HTTP redirect: _redirects for netlify() and cloudflare(), vercel.json for vercel(), and — when no host is named — both of those plus blume-redirects.json, a structured manifest for anything else (nginx/Apache rules, an edge worker). A _redirects or vercel.json you ship in public/ is left untouched.

A redirect that moves one page to another moves the page’s raw Markdown with it: /old.md and /old.mdx redirect to /new.md and /new.mdx with the same status, in blume dev and every build. A redirect from an .html URL, like /old.html from a site you migrated, doesn’t, since that URL never had a Markdown copy. Nor does one whose Markdown URL already serves a copy: /index.md is the home page’s, so a redirect from /index to / leaves it alone.

A redirect from a page’s own index.html URL, like /guide/index.html to /guide, gets no redirect page in a static build, because the page itself is written to that file. Hosts that read the redirect files answer it with the redirect. Any other host serves the page at that URL.

Blume adds one kind of redirect itself: a date-named page that an earlier version published under a shorter URL, like changelog/12-05-2022.mdx at /changelog/05-2022, redirects from that URL to its own (see ordering with numeric prefixes). It adds none where another page lives at the old URL or a redirect you configured already starts there.

Two hosts need more than the file:

  • Netlify serves a file that exists ahead of a redirect rule unless the rule is forced, and a static build has a redirect page at every from. The _redirects for netlify() forces its rules (/old /new 301!). Cloudflare rejects that flag, so the file a build for no named host writes leaves it off, and on Netlify that build answers with the redirect page instead; name the host with netlify({ output: "static" }) to get the HTTP redirect.
  • Vercel reads vercel.json from the project’s root directory, never from the output directory, so the copy in dist/ applies only when you deploy that folder itself with the Vercel CLI (vercel deploy dist). A Git-connected project never reads it: it serves the redirect pages and none of the headers from Content types. Copy the redirects and headers from dist/vercel.json into the vercel.json in your project’s root directory, or use vercel() for a server build, whose routing config carries the redirects and the discovery headers.

Pattern redirects

A from can cover many paths at once, written the way Mintlify and vercel.json write them:

redirects: [
  { from: "/beta/:slug*", to: "/v2/:slug*" },
  { from: "/blog/:slug", to: "/articles/:slug" },
  { from: "/articles/concepts-*", to: "/concepts" },
  { from: "/old/article-*", to: "/new/article-*" },
],
  • :name matches one whole segment: /blog/:slug covers /blog/hello, not /blog/a/b.
  • :name* as the last segment matches the rest of the path, any number of segments: /beta/:slug* sends /beta/guides/setup to /v2/guides/setup, and /beta itself to /v2. A last segment of * does the same. Every host gets that bare path as a rule of its own (/beta /v2 in _redirects), so all of them send it to the same place. blume audit checks where it lands, and reports /beta going to a /v2 the build doesn’t serve as a broken redirect.
  • * at the end of a segment matches the rest of the path from there: /articles/concepts-* covers /articles/concepts-overview.

to reads a capture by name, as a whole segment (/:slug*, /:slug), or a * for what the * in from matched (:splat, the way _redirects spells it, reads it too). A to that reads no capture sends every path the pattern covers to one page. An exact redirect wins over a pattern that covers the same path, and patterns are tried in the order you list them.

Each host gets the patterns in its own syntax: splats in _redirects, path-to-regexp sources in vercel.json, and the routing config of a vercel() or netlify() server build. The node() and cloudflare() servers match them themselves, and so do blume dev and blume preview. A static build writes no redirect page for a pattern, since it would need one for every path the pattern covers, so a host that reads none of these files (GitHub Pages, say) applies only the exact redirects; blume-redirects.json lists patterns as you wrote them.

blume validate checks where each redirect leads. An exact to must be a page, a file in public/ or one Blume generates, or another redirect’s from; for a pattern, something must be served under the literal part of to before its first capture (/v2/ in /v2/:slug*). One that leads nowhere warns BLUME_BROKEN_REDIRECT at its to. blume audit follows every redirect through the built site.

A pattern can’t also match a page: hosts disagree on which of the two answers, so the build stops with BLUME_REDIRECT_MATCHES_PAGE, naming the page. Narrow the pattern to the paths that moved. An exact redirect from a page’s own URL answers the same way on every host, taking the URL over so the page never publishes, so the same code is a warning there: delete the page if it moved, or drop the redirect. So is one from a page’s Markdown copy, like /guide.md or the home page’s /index.md, which takes that copy away from agents: drop the redirect.

Content types

Where the host reads one, a build also emits a _headers file that pins charset=utf-8 onto the raw AI-ready endpoints — /<route>.md, /<route>.mdx, and the .txt files (llms.txt, llms-full.txt). Those responses are valid UTF-8, but many static hosts serve them as text/markdown / text/plain with no charset, and browsers then fall back to Windows-1252 — so non-ASCII docs (Japanese, accented Latin, …) render as mojibake when the raw URL is opened directly. HTML pages are unaffected because they carry <meta charset>. Netlify reads _headers on a static deploy and Cloudflare (Pages, and Workers static assets) on both static and server builds, so those adapters and an unnamed static host get the file; Vercel (whose headers ride the routing config) and Node (whose server ignores the file) don’t. A Netlify server build writes the same rules into the headers of its Frameworks API config (.netlify/v1/config.json) instead. A static Vercel build writes the same rules into dist/vercel.json instead, which a Git-connected project doesn’t read (see Redirects). A _headers you ship in public/ is left untouched.

Powered-by header

Blume names itself in an X-Powered-By: Blume response header, so HTTP inspectors and technology detectors can tell what built the site. Set poweredBy to false to leave it out:

poweredBy: false,

blume dev sends it on every response. A server build sends it wherever the host takes its headers from: the node() server and the cloudflare() Worker set it themselves, a vercel() build carries it in its routing config, and a netlify() build in the header rules of its Frameworks API config, with Blume’s middleware adding it to the pages rendered on demand. A static build lists it in _headers and dist/vercel.json beside the content types, so it reaches the hosts those files reach: not a Git-connected Vercel project, and not a host that sends no custom headers, such as GitHub Pages.

In an ejected app, the switch is the poweredBy option of blumeIntegration() in astro.config.mjs. Its build sends the header on server-rendered pages and from _headers; the server-entry wrapper and routing configs that carry it elsewhere are blume build steps.

Environment variables

When a feature needs a secret, Blume warns at blume dev/build if it’s missing — so the problem surfaces early instead of at the first request:

Feature Variable
Assistant (AI Gateway) AI_GATEWAY_API_KEY (or Vercel OIDC)
Assistant (other adapters) the adapter’s default key env var (OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY, XAI_API_KEY, OPENROUTER_API_KEY, LLMGATEWAY_API_KEY, INKEEP_API_KEY), or the apiKeyEnv you passed it
Assistant bot protection TURNSTILE_SECRET_KEY (turnstile()), HCAPTCHA_SECRET_KEY (hcaptcha())
Rate limiting (upstash()) UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN
Rate limiting (unkey()) UNKEY_ROOT_KEY, or the rootKeyEnv you passed it
Mixedbread search MIXEDBREAD_API_KEY
Narration (generated voices) AI_GATEWAY_API_KEY (or Vercel OIDC) for gateway(), OPENAI_API_KEY for openai() (none with a baseUrl, unless you name one), or the apiKeyEnv you passed to either
Content sources NOTION_TOKEN (notion()), SANITY_TOKEN (sanity()), CONTENTFUL_ACCESS_TOKEN (contentful(), plus CONTENTFUL_PREVIEW_TOKEN for --preview, which fails without it), PAYLOAD_API_KEY (payload()), STRAPI_API_TOKEN (strapi()), GITHUB_TOKEN (githubReleases(), and mdxRemote() with github)

Set them in .env.local for local dev and in your host’s environment for production. A content source reads its token when it fetches, during blume dev and blume build, and narration reads its key during blume build, so set those where your site builds. Build-time secrets for search-index sync (ALGOLIA_ADMIN_API_KEY, ORAMA_PRIVATE_API_KEY, TYPESENSE_ADMIN_API_KEY) are checked by the sync step itself: when one is unset, the build warns and skips the sync, and when it’s set, a failed sync fails the build. The GitHub info card also sends GITHUB_TOKEN, when it’s set, to lift GitHub’s API rate limit.

Build cache

Blume keeps two caches a build can reuse. Astro’s and Vite’s caches live under .blume/.cache/ (the content store and image transforms among them). Rendered OG cards live in node_modules/.cache/blume/og, so a rebuild renders only the cards whose title, description, or branding changed. Whether a platform keeps that directory between deploys varies:

  • Vercel restores node_modules/** from its build cache, so cards carry over (the cache is 1 GB, retained for a month, and keyed per branch — a new branch starts from the production cache).
  • Netlify restores node_modules, so cards carry over.
  • Cloudflare Workers Builds persists only package-manager caches and, for a detected Astro project, node_modules/.astro — never node_modules/.cache — so every deploy renders every card there.
  • GitHub Actions and other runners you manage keep nothing unless you cache the directory yourself:
- uses: actions/cache@v6
  with:
    path: node_modules/.cache/blume/og
    key: blume-og-${{ runner.os }}-${{ hashFiles('**/bun.lock', '**/package-lock.json', '**/pnpm-lock.yaml') }}
    restore-keys: blume-og-${{ runner.os }}-

Cards are keyed by content, so an imprecise key is fine: a restored cache only ever saves renders, never serves a wrong card.

One install command discards the cache on every platform: npm ci deletes node_modules before installing. Keep npm install, bun install, or pnpm install as the install command to get the reuse.

Build summary

Every build prints a summary — output mode, adapter, resolved site URL, search provider, redirect count, sitemap and llms.txt status, and any enabled server features — so you can confirm what shipped (including anything auto-detected) before you deploy.

Last updated on

Was this page helpful?