Skip to content

Astro

Hetzner.Website.Astro deploys an Astro project to a Hetzner Cloud Server as a systemd unit. Server-rendered pages run on a Hetzner.Service (port 3000); static files are served first, then the Astro handler. Omit server and Alchemy creates a cpx12 Ubuntu 24.04 Server in fsn1. The live URL is http://{ipv4}:3000 — there is no TLS on the Service. Your astro.config.* loads natively — don’t declare an adapter.

The build integration is not bundled with alchemy. Install @alchemy.run/frontend-frameworks; the resource loads its /astro and /astro/node exports from your project at deploy time. It is only used at build time, so a dev dependency is enough:

Terminal window
bun add -d @alchemy.run/frontend-frameworks

Your astro.config.* file loads natively — integrations, Vite plugins, and any other non-serializable options work exactly as they do outside Alchemy. Alchemy merges a programmatic config over it: the Node adapter is injected for you (don’t declare an adapter in your config — that fails the build with an actionable error), and options passed via the astro prop override the file’s — see Astro configuration below for the details.

Declare the site as a module-level const (rather than inline in the Stack):

alchemy.run.ts
import * as Hetzner from "alchemy/Hetzner";
export const Website = Hetzner.Website.Astro("Website");

Pass rootDir if package.json is not at .. Pass server to run the unit on a Server you already declared — several sites and APIs can share one VM. Otherwise the site creates a cpx12 / ubuntu-24.04 Server in fsn1.

export const Box = Hetzner.Server("Box", {
serverType: "cpx12",
image: "ubuntu-24.04",
location: "fsn1",
});
export const Website = Hetzner.Website.Astro("Website", {
server: Box,
});

Yield the site from your Stack and return its URL:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyAstroSite",
{
providers: Hetzner.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const site = yield* Website;
return { url: site.url };
}),
);

Pages are server-rendered by default. Pages that export const prerender = true are prerendered at build time and served as static files.

On deploy site.url is http://{ipv4}:3000 (or http://{domain}:3000 when you set domain). site.server and site.service are undefined under alchemy dev.

Process environment is the top-level env prop — plain strings, or Redacted values. They are copied onto process.env before build and alchemy dev, and onto the systemd unit at deploy:

alchemy.run.ts
export const Website = Hetzner.Website.Astro("Website", {
env: {
GREETING: "Hello from Alchemy!",
API_BASE: "https://api.example.com",
},
});

This is the hosted Node process, not Worker bindings.

The Astro server runs in Node on the Server, so server-rendered pages and endpoints read the environment from process.env:

src/pages/index.astro
---
const greeting = process.env.GREETING ?? "hello";
---
<h1>{greeting}</h1>

Astro inlines PUBLIC_* keys at build time. Because env is applied before the build, those keys behave the same as in a local astro build.

With astro: { output: "static" } every page is prerendered at build time and the deploy is assets-only — no Astro handler, just a static-file server as the systemd unit:

export const Website = Hetzner.Website.Astro("Website", {
astro: { output: "static" },
assets: { notFoundHandling: "404-page" },
});

assets.notFoundHandling applies only to output: "static". "404-page" serves the built 404.html for unmatched routes; "single-page-application" answers misses with the index page (200) instead.

Your astro.config.* is the primary place to configure Astro — integrations, Vite plugins, and every other option (serializable or not) work as usual:

astro.config.ts
import { defineConfig } from "astro/config";
import react from "@astrojs/react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
site: "https://blog.example.com",
integrations: [react()],
vite: {
plugins: [tailwindcss()],
},
});

The astro prop on the resource exposes common serializable options for deploy-specific overrides; Astro merges them over the config file (scalars override, arrays like integrations and vite.plugins concatenate after the file’s):

alchemy.run.ts
export const Website = Hetzner.Website.Astro("Website", {
astro: {
site: "https://preview.example.com",
},
});

Two options are managed for you regardless of the config file:

  • adapter — Alchemy injects the Node adapter. Declaring an adapter in astro.config.* fails the build with an actionable error.
  • output — Alchemy defaults it to "server" (superseding a file-level output), so pages render on demand on the Server. Opt into a fully prerendered site with astro: { output: "static" } on the resource.

site, base, srcDir, publicDir, outDir, and trailingSlash are the other serializable keys on astro.

alchemy dev runs Astro’s own dev server (native HMR) instead of deploying; site.url is the local address and no Hetzner resources are created. Wrap the site in Alchemy.remote() to deploy the real Server and Service even during dev.

domain requires an existing Hetzner.Zone — the Website does not create a zone. It adds an A RecordSet pointing at the Server’s public IPv4, and url becomes http://{domain}:3000. There is no TLS on the Service:

export const Dns = Hetzner.Zone("Dns", {
name: "example.com",
});
export const Website = Hetzner.Website.Astro("Website", {
domain: "app.example.com",
zone: Dns,
});

Passing domain without zone fails.