Static sites
Cloudflare.Website.StaticSite runs a build command, content-hashes the
output directory, and deploys it as a Cloudflare Worker serving static
assets. The build is a plain shell command (a
Command.Build resource under the hood — see
Memoization for what gets hashed and when it
re-runs), so it works for anything that produces a directory of files — a
Zola or Hugo site, a bash script, or the static output of a framework the
Vite resource doesn’t support yet.
If you provide neither main nor script, the site deploys as an
assets-only Worker: no Worker code is uploaded at all. Cloudflare’s
asset layer serves every request itself — static asset requests are free
and never invoke a Worker — and applies your notFoundHandling /
htmlHandling config, exactly like an assets-only wrangler deploy.
Provide main to put a real Worker in front instead (see
below).
If your files need no build step at all, you can skip StaticSite and
give a plain Worker just an assets
directory:
const site = yield* Cloudflare.Worker("Site", { assets: { directory: "./public", htmlHandling: "drop-trailing-slash", }, domain: "static.example.com",});Deploy a Zola site
Section titled “Deploy a Zola site”Point command at the build, outdir at where it writes files, and return
the Worker’s url as a stack output:
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";
export default Alchemy.Stack( "CloudflareStatic", { providers: Cloudflare.providers(), state: Cloudflare.state(), }, Effect.gen(function* () { const site = yield* Cloudflare.Website.StaticSite("Website", { command: "zola build", outdir: "public", dev: { command: "zola serve", }, assets: { notFoundHandling: "404-page", }, });
return { url: site.url }; }),);alchemy deploy runs zola build, hashes public/, and uploads it as the
Worker’s static assets; notFoundHandling: "404-page" serves Zola’s
generated 404 page for unmatched paths. The runnable version lives at
examples/cloudflare-static-site.
The build contract is two required props, plus a handful of optional ones:
command— the shell command that builds the site (e.g."zola build").outdir— the directory the command writes, relative to the working directory. This is what gets deployed.cwd— working directory for the command. Defaults toprocess.cwd().env— extra environment variables passed to the build command on top ofprocess.env, so outputs from other resources (like an API URL) flow into the build.Redactedvalues stay out of logs and state.memo—MemoOptions | boolean. By default every non-gitignored file incwd(plus the nearest lockfile) is hashed to decide whether the build re-runs;memo: { include: [...] }narrows the scope, andmemo: falsedisables memoization so the build runs on every deploy.main— an optional custom Worker entrypoint served in front of the assets, instead of the fallback passthrough.assets— asset routing behavior (notFoundHandling,htmlHandling,runWorkerFirst, …), passed flat.runWorkerFirstacceptstrueto route every request through the Worker ahead of assets, or a glob array like["/api/*"]to intercept only matching paths.
Everything else a Worker accepts —
domain, compatibility, bindings via env — applies too, since a
StaticSite is a Worker underneath.
A custom Worker in front of your assets
Section titled “A custom Worker in front of your assets”The alchemy.run docs site itself — an Astro/Starlight app — deploys this way, with a hand-written Worker at the edge:
// website/alchemy.run.ts — how alchemy.run deploys itselfconst Website = Cloudflare.Website.StaticSite( "Website", Alchemy.Stack.useSync((stack) => ({ command: "bun run build", outdir: "dist", main: "./src/worker.ts", domains: stack.stage === "prod" ? ["alchemy.run"] : undefined, memo: { include: [ "src/**", "astro.config.mjs", "package.json", "plugins/**", "public/**", "scripts/**", "../bun.lock", ], }, assets: { runWorkerFirst: true, }, })),);main points at a Worker that runs before asset serving thanks to
assets: { runWorkerFirst: true } — the docs site uses it to rewrite OG and
canonical tags with HTMLRewriter and serve 301 redirects, then delegates
everything else to env.ASSETS.fetch(request).
Local dev
Section titled “Local dev”During alchemy dev, the dev prop replaces the build with your
framework’s own dev server:
const site = yield* Cloudflare.Website.StaticSite("Website", { command: "zola build", outdir: "public", dev: { command: "zola serve", },});dev.command is spawned as a long-lived sidecar process tied to the stack’s
scope — the build is skipped and the Worker runs in external mode against
the dev server’s URL, detected from the command’s stdout. dev also accepts
cwd and env overrides for the dev process, and dev.url to pin the URL
explicitly when stdout detection fails. The sidecar mechanics — spawn, URL
detection, teardown — are covered in Dev servers.
When to use a framework resource instead
Section titled “When to use a framework resource instead”StaticSite is the general fallback for any build command that produces a
directory of files. Frameworks with dedicated resources have a better path:
Vite for pure-Vite apps,
Astro,
Nuxt,
SvelteKit, and
Waku for their frameworks. Those resources run
the framework’s own programmatic build, deploy server-rendered routes, and
skip the build contract entirely. Reach for StaticSite when there is no
dedicated resource — Zola, Hugo, or any other generator.