Skip to content

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",
});

Point command at the build, outdir at where it writes files, and return the Worker’s url as a stack output:

alchemy.run.ts
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 to process.cwd().
  • env — extra environment variables passed to the build command on top of process.env, so outputs from other resources (like an API URL) flow into the build. Redacted values stay out of logs and state.
  • memoMemoOptions | boolean. By default every non-gitignored file in cwd (plus the nearest lockfile) is hashed to decide whether the build re-runs; memo: { include: [...] } narrows the scope, and memo: false disables 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. runWorkerFirst accepts true to 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.

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 itself
const 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).

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.

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.