| 1 | --- |
| 2 | title: Static sites |
| 3 | description: Deploy any build command's output directory as Cloudflare Worker static assets with Cloudflare.Website.StaticSite — custom edge Workers, framework-native local dev, and memoized rebuilds. |
| 4 | sidebar: |
| 5 | order: 3 |
| 6 | --- |
| 7 | |
| 8 | `Cloudflare.Website.StaticSite` runs a build command, content-hashes the |
| 9 | output directory, and deploys it as a Cloudflare Worker serving static |
| 10 | assets. The build is a plain shell command (a |
| 11 | [`Command.Build`](/providers/command/build) resource under the hood — see |
| 12 | [Memoization](/command/memoization) for what gets hashed and when it |
| 13 | re-runs), so it works for anything that produces a directory of files — a |
| 14 | Zola or Hugo site, a bash script, or the static output of a framework the |
| 15 | [Vite resource](/cloudflare/frontend/vite) doesn't support yet. |
| 16 | |
| 17 | If you provide neither `main` nor `script`, the site deploys as an |
| 18 | **assets-only Worker**: no Worker code is uploaded at all. Cloudflare's |
| 19 | asset layer serves every request itself — static asset requests are free |
| 20 | and never invoke a Worker — and applies your `notFoundHandling` / |
| 21 | `htmlHandling` config, exactly like an assets-only `wrangler deploy`. |
| 22 | Provide `main` to put a real Worker in front instead (see |
| 23 | [below](#a-custom-worker-in-front-of-your-assets)). |
| 24 | |
| 25 | If your files need no build step at all, you can skip `StaticSite` and |
| 26 | give a plain [Worker](/cloudflare/compute/workers) just an assets |
| 27 | directory: |
| 28 | |
| 29 | ```typescript |
| 30 | const site = yield* Cloudflare.Worker("Site", { |
| 31 | assets: { |
| 32 | directory: "./public", |
| 33 | htmlHandling: "drop-trailing-slash", |
| 34 | }, |
| 35 | domain: "static.example.com", |
| 36 | }); |
| 37 | ``` |
| 38 | |
| 39 | ## Deploy a Zola site |
| 40 | |
| 41 | Point `command` at the build, `outdir` at where it writes files, and return |
| 42 | the Worker's `url` as a stack output: |
| 43 | |
| 44 | ```typescript |
| 45 | // alchemy.run.ts |
| 46 | import * as Alchemy from "alchemy"; |
| 47 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 48 | import * as Effect from "effect/Effect"; |
| 49 | |
| 50 | export default Alchemy.Stack( |
| 51 | "CloudflareStatic", |
| 52 | { |
| 53 | providers: Cloudflare.providers(), |
| 54 | state: Cloudflare.state(), |
| 55 | }, |
| 56 | Effect.gen(function* () { |
| 57 | const site = yield* Cloudflare.Website.StaticSite("Website", { |
| 58 | command: "zola build", |
| 59 | outdir: "public", |
| 60 | dev: { |
| 61 | command: "zola serve", |
| 62 | }, |
| 63 | assets: { |
| 64 | notFoundHandling: "404-page", |
| 65 | }, |
| 66 | }); |
| 67 | |
| 68 | return { url: site.url }; |
| 69 | }), |
| 70 | ); |
| 71 | ``` |
| 72 | |
| 73 | `alchemy deploy` runs `zola build`, hashes `public/`, and uploads it as the |
| 74 | Worker's static assets; `notFoundHandling: "404-page"` serves Zola's |
| 75 | generated 404 page for unmatched paths. The runnable version lives at |
| 76 | [`examples/cloudflare-static-site`](https://github.com/alchemy-run/alchemy/tree/main/examples/cloudflare-static-site). |
| 77 | |
| 78 | ## Props |
| 79 | |
| 80 | The build contract is two required props, plus a handful of optional ones: |
| 81 | |
| 82 | - `command` — the shell command that builds the site (e.g. `"zola build"`). |
| 83 | - `outdir` — the directory the command writes, relative to the working |
| 84 | directory. This is what gets deployed. |
| 85 | - `cwd` — working directory for the command. Defaults to `process.cwd()`. |
| 86 | - `env` — extra environment variables passed to the build command on top of |
| 87 | `process.env`, so outputs from other resources (like an API URL) flow into |
| 88 | the build. `Redacted` values stay out of logs and state. |
| 89 | - `memo` — `MemoOptions | boolean`. By default every non-gitignored file in |
| 90 | `cwd` (plus the nearest lockfile) is hashed to decide whether the build |
| 91 | re-runs; `memo: { include: [...] }` narrows the scope, and `memo: false` |
| 92 | disables memoization so the build runs on every deploy. |
| 93 | - `main` — an optional custom Worker entrypoint served in front of the |
| 94 | assets, instead of the fallback passthrough. |
| 95 | - `assets` — asset routing behavior (`notFoundHandling`, `htmlHandling`, |
| 96 | `runWorkerFirst`, ...), passed flat. `runWorkerFirst` accepts `true` to |
| 97 | route every request through the Worker ahead of assets, or a glob array |
| 98 | like `["/api/*"]` to intercept only matching paths. |
| 99 | |
| 100 | Everything else a [Worker](/cloudflare/compute/workers) accepts — |
| 101 | `domain`, `compatibility`, bindings via `env` — applies too, since a |
| 102 | `StaticSite` is a Worker underneath. |
| 103 | |
| 104 | ## A custom Worker in front of your assets |
| 105 | |
| 106 | The alchemy.run docs site itself — an Astro/Starlight app — deploys this |
| 107 | way, with a hand-written Worker at the edge: |
| 108 | |
| 109 | ```typescript |
| 110 | // website/alchemy.run.ts — how alchemy.run deploys itself |
| 111 | const Website = Cloudflare.Website.StaticSite( |
| 112 | "Website", |
| 113 | Alchemy.Stack.useSync((stack) => ({ |
| 114 | command: "bun run build", |
| 115 | outdir: "dist", |
| 116 | main: "./src/worker.ts", |
| 117 | domains: stack.stage === "prod" ? ["alchemy.run"] : undefined, |
| 118 | memo: { |
| 119 | include: [ |
| 120 | "src/**", |
| 121 | "astro.config.mjs", |
| 122 | "package.json", |
| 123 | "plugins/**", |
| 124 | "public/**", |
| 125 | "scripts/**", |
| 126 | "../bun.lock", |
| 127 | ], |
| 128 | }, |
| 129 | assets: { |
| 130 | runWorkerFirst: true, |
| 131 | }, |
| 132 | })), |
| 133 | ); |
| 134 | ``` |
| 135 | |
| 136 | `main` points at a Worker that runs *before* asset serving thanks to |
| 137 | `assets: { runWorkerFirst: true }` — the docs site uses it to rewrite OG and |
| 138 | canonical tags with `HTMLRewriter` and serve 301 redirects, then delegates |
| 139 | everything else to `env.ASSETS.fetch(request)`. |
| 140 | |
| 141 | ## Local dev |
| 142 | |
| 143 | During `alchemy dev`, the `dev` prop replaces the build with your |
| 144 | framework's own dev server: |
| 145 | |
| 146 | ```typescript |
| 147 | const site = yield* Cloudflare.Website.StaticSite("Website", { |
| 148 | command: "zola build", |
| 149 | outdir: "public", |
| 150 | dev: { |
| 151 | command: "zola serve", |
| 152 | }, |
| 153 | }); |
| 154 | ``` |
| 155 | |
| 156 | `dev.command` is spawned as a long-lived sidecar process tied to the stack's |
| 157 | scope — the build is skipped and the Worker runs in external mode against |
| 158 | the dev server's URL, detected from the command's stdout. `dev` also accepts |
| 159 | `cwd` and `env` overrides for the dev process, and `dev.url` to pin the URL |
| 160 | explicitly when stdout detection fails. The sidecar mechanics — spawn, URL |
| 161 | detection, teardown — are covered in [Dev servers](/command/dev-servers). |
| 162 | |
| 163 | ## When to use a framework resource instead |
| 164 | |
| 165 | `StaticSite` is the general fallback for any build command that produces a |
| 166 | directory of files. Frameworks with dedicated resources have a better path: |
| 167 | [Vite](/cloudflare/frontend/vite) for pure-Vite apps, |
| 168 | [Astro](/cloudflare/frontend/astro), |
| 169 | [Nuxt](/cloudflare/frontend/nuxt), |
| 170 | [SvelteKit](/cloudflare/frontend/sveltekit), and |
| 171 | [Waku](/cloudflare/frontend/waku) for their frameworks. Those resources run |
| 172 | the framework's own programmatic build, deploy server-rendered routes, and |
| 173 | skip the build contract entirely. Reach for `StaticSite` when there is no |
| 174 | dedicated resource — Zola, Hugo, or any other generator. |