Static sites
Hetzner.Website.StaticSite runs a build command, packs the output
directory into the unit archive, and deploys a tiny static-file
server (GET assets, /health, optional SPA / 404-page) as a
Service on a
Server. Use it when the site has its own
build step — Hugo, Zola, Eleventy, or any custom pipeline.
For Vite-based projects, prefer
Hetzner.Website.Vite.
Declare the Website
Section titled “Declare the Website”command and outdir are required. Declare the site as a
module-level const:
import * as Hetzner from "alchemy/Hetzner";
export const Website = Hetzner.Website.StaticSite("Website", { command: "hugo --minify", outdir: "public",});The build is a Command.Build —
memoized, so unchanged inputs skip the rebuild
(details). By default every non-gitignored
file in cwd (plus the nearest lockfile) is hashed. Narrow it with
memo: { include: [...] }, or set memo: false to rebuild on every
deploy.
Add it to the Stack
Section titled “Add it to the Stack”Yield the site from your Stack and return its URL:
import * as Alchemy from "alchemy";import * as Effect from "effect/Effect";
export default Alchemy.Stack( "MyStaticSite", { providers: Hetzner.providers(), state: Alchemy.localState(), }, Effect.gen(function* () { const site = yield* Website; return { url: site.url }; }),);One StaticSite expands into a Server (default cpx12 /
ubuntu-24.04 / fsn1 if you omit server) and a systemd unit on
port 3000. Several sites or APIs can share one Server. Live url is
http://{ipv4}:3000 (or http://{domain}:3000 when you pass
domain). There is no TLS on the Service. server and service
are undefined under alchemy dev.
Pass server to colocate the unit with other Services:
export const Box = Hetzner.Server("Box", { serverType: "cpx12", image: "ubuntu-24.04", location: "fsn1",});
export const Website = Hetzner.Website.StaticSite("Website", { command: "hugo --minify", outdir: "public", server: Box,});Building from a subdirectory
Section titled “Building from a subdirectory”cwd is the working directory for command. outdir is relative
to cwd (default: process cwd):
export const Website = Hetzner.Website.StaticSite("Web", { cwd: "apps/web", command: "npm run build", outdir: "dist",});SPAs and 404 pages
Section titled “SPAs and 404 pages”A miss — a request that matches no output file — can be answered two ways, and they are mutually exclusive (passing both fails the deploy):
// Single-page app: misses serve index.html with a 200 so the// client-side router takes over.export const App = Hetzner.Website.StaticSite("App", { command: "npm run build", outdir: "dist", spa: true,});
// Static site: misses return a real 404 status with your error page.export const Docs = Hetzner.Website.StaticSite("Docs", { command: "hugo --minify", outdir: "public", errorPage: "404.html",});Add environment variables
Section titled “Add environment variables”Top-level env is copied onto process.env for the build and onto
the systemd unit at deploy. Wrap secrets with Redacted. It is not
Worker bindings and not AWS server.environment:
export const Website = Hetzner.Website.StaticSite("Website", { command: "hugo --minify", outdir: "public", env: { HUGO_ENV: "production", },});Generators that inline at build time pick these up. The hosted process is the generated static-file server — there is no framework SSR runtime.
Local development
Section titled “Local development”During alchemy dev, dev.command replaces the build with that
process as a long-lived child. No Server or Service is created, and
site.url is the local address detected from stdout (dev.url
pins it when detection fails). dev also accepts cwd and env
overrides:
export const Website = Hetzner.Website.StaticSite("Website", { command: "hugo --minify", outdir: "public", dev: { command: "hugo server" },});Without dev.command, the site still builds and a local static
server serves outdir — still no cloud resources. Wrap the site in
Alchemy.remote() to deploy the live path even during dev:
export const Website = Hetzner.Website.StaticSite("Website", { command: "hugo --minify", outdir: "public",}).pipe(Alchemy.remote());Custom domain
Section titled “Custom domain”domain requires zone — a
Hetzner.Zone you pass in. The site
does not create one. Alchemy creates an A RecordSet pointing at
the Server’s public IPv4, and url becomes http://{domain}:3000.
The Service still has no TLS:
export const Dns = Hetzner.Zone("Dns", { name: "example.com",});
export const Website = Hetzner.Website.StaticSite("Website", { command: "hugo --minify", outdir: "public", domain: "blog.example.com", zone: Dns,});Passing domain without zone fails the deploy.
When to use a framework resource instead
Section titled “When to use a framework resource instead”StaticSite is the general fallback for any shell command that
produces a directory of files. Frameworks with dedicated resources
have a better path: Astro,
Foldkit,
Next.js, Nuxt,
Octane,
React Router,
SolidStart,
SvelteKit,
TanStack Start,
Vite, Vocs, and
Waku. Those resources run the framework’s
own programmatic build and skip the command / outdir contract.
Reach for StaticSite when there is no dedicated resource — Zola,
Hugo, or any other generator.
Where next
Section titled “Where next”- Websites on Hetzner — the full websites surface, including Node SSR frameworks
- StaticSite reference — every prop and attribute
- Hetzner on Alchemy — the Hetzner provider hub