1---
2title: Astro
3description: Deploy an Astro app to Hetzner with Hetzner.Website.Astro — Node SSR as a systemd unit on a Server, static output as a file server, and Astro's own dev server under alchemy dev.
4sidebar:
5 order: 11
6---
7
8import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
9
10export const devDeps = "@alchemy.run/frontend-frameworks";
11
12`Hetzner.Website.Astro` deploys an [Astro](https://astro.build)
13project to a Hetzner Cloud Server as a systemd unit.
14Server-rendered pages run on a
15[`Hetzner.Service`](/hetzner/compute/services) (port 3000); static
16files are served first, then the Astro handler. Omit `server` and
17Alchemy creates a `cpx12` Ubuntu 24.04 Server in `fsn1`. The live
18URL is `http://{ipv4}:3000` — there is no TLS on the Service. Your
19`astro.config.*` loads natively — don't declare an `adapter`.
20
21## Install
22
23The build integration is not bundled with alchemy. Install
24`@alchemy.run/frontend-frameworks`; the resource loads its `/astro`
25and `/astro/node` exports from your project at deploy time. It is
26only used at build time, so a dev dependency is enough:
27
28<Tabs syncKey="pkgManager">
29 <TabItem label="bun" icon="bun">
30 <Code code={`bun add -d ${devDeps}`} lang="sh" />
31 </TabItem>
32 <TabItem label="npm" icon="npm">
33 <Code code={`npm install -D ${devDeps}`} lang="sh" />
34 </TabItem>
35 <TabItem label="pnpm" icon="pnpm">
36 <Code code={`pnpm add -D ${devDeps}`} lang="sh" />
37 </TabItem>
38 <TabItem label="yarn" icon="seti:yarn">
39 <Code code={`yarn add -D ${devDeps}`} lang="sh" />
40 </TabItem>
41</Tabs>
42
43## Configure Astro
44
45Your `astro.config.*` file loads natively — integrations, Vite
46plugins, and any other non-serializable options work exactly as they
47do outside Alchemy. Alchemy merges a programmatic config *over* it:
48the Node adapter is injected for you (don't declare an `adapter` in
49your config — that fails the build with an actionable error), and
50options passed via the `astro` prop override the file's — see
51[Astro configuration](#astro-configuration) below for the details.
52
53## Declare the Website
54
55Declare the site as a module-level const (rather than inline in the
56Stack):
57
58```typescript
59// alchemy.run.ts
60import * as Hetzner from "alchemy/Hetzner";
61
62export const Website = Hetzner.Website.Astro("Website");
63```
64
65Pass `rootDir` if `package.json` is not at `.`. Pass `server` to
66run the unit on a Server you already declared — several sites and
67APIs can share one VM. Otherwise the site creates a `cpx12` /
68`ubuntu-24.04` Server in `fsn1`.
69
70```diff lang="typescript"
71+export const Box = Hetzner.Server("Box", {
72+ serverType: "cpx12",
73+ image: "ubuntu-24.04",
74+ location: "fsn1",
75+});
76+
77 export const Website = Hetzner.Website.Astro("Website", {
78+ server: Box,
79 });
80```
81
82## Add it to the Stack
83
84Yield the site from your Stack and return its URL:
85
86```typescript
87// alchemy.run.ts
88import * as Alchemy from "alchemy";
89import * as Effect from "effect/Effect";
90
91export default Alchemy.Stack(
92 "MyAstroSite",
93 {
94 providers: Hetzner.providers(),
95 state: Alchemy.localState(),
96 },
97 Effect.gen(function* () {
98 const site = yield* Website;
99 return { url: site.url };
100 }),
101);
102```
103
104Pages are server-rendered by default. Pages that
105`export const prerender = true` are prerendered at build time and
106served as static files.
107
108On deploy `site.url` is `http://{ipv4}:3000` (or
109`http://{domain}:3000` when you set `domain`). `site.server` and
110`site.service` are undefined under `alchemy dev`.
111
112## Add environment variables
113
114Process environment is the top-level `env` prop — plain strings, or
115`Redacted` values. They are copied onto `process.env` before build
116and `alchemy dev`, and onto the systemd unit at deploy:
117
118```diff lang="typescript"
119// alchemy.run.ts
120export const Website = Hetzner.Website.Astro("Website", {
121+ env: {
122+ GREETING: "Hello from Alchemy!",
123+ API_BASE: "https://api.example.com",
124+ },
125});
126```
127
128This is the hosted Node process, not Worker bindings.
129
130## Read the environment in server code
131
132The Astro server runs in Node on the Server, so server-rendered
133pages and endpoints read the environment from `process.env`:
134
135```astro
136---
137// src/pages/index.astro
138const greeting = process.env.GREETING ?? "hello";
139---
140
141<h1>{greeting}</h1>
142```
143
144Astro inlines `PUBLIC_*` keys at build time. Because `env` is
145applied before the build, those keys behave the same as in a local
146`astro build`.
147
148## Fully static sites
149
150With `astro: { output: "static" }` every page is prerendered at
151build time and the deploy is assets-only — no Astro handler, just a
152static-file server as the systemd unit:
153
154```typescript
155export const Website = Hetzner.Website.Astro("Website", {
156 astro: { output: "static" },
157 assets: { notFoundHandling: "404-page" },
158});
159```
160
161`assets.notFoundHandling` applies only to `output: "static"`.
162`"404-page"` serves the built `404.html` for unmatched routes;
163`"single-page-application"` answers misses with the index page
164(200) instead.
165
166## Astro configuration
167
168Your `astro.config.*` is the primary place to configure Astro —
169integrations, Vite plugins, and every other option (serializable or
170not) work as usual:
171
172```typescript
173// astro.config.ts
174import { defineConfig } from "astro/config";
175import react from "@astrojs/react";
176import tailwindcss from "@tailwindcss/vite";
177
178export default defineConfig({
179 site: "https://blog.example.com",
180 integrations: [react()],
181 vite: {
182 plugins: [tailwindcss()],
183 },
184});
185```
186
187The `astro` prop on the resource exposes common serializable options
188for deploy-specific overrides; Astro merges them *over* the config
189file (scalars override, arrays like `integrations` and
190`vite.plugins` concatenate after the file's):
191
192```typescript
193// alchemy.run.ts
194export const Website = Hetzner.Website.Astro("Website", {
195 astro: {
196 site: "https://preview.example.com",
197 },
198});
199```
200
201Two options are managed for you regardless of the config file:
202
203- `adapter` — Alchemy injects the Node adapter. Declaring an
204 adapter in `astro.config.*` fails the build with an actionable
205 error.
206- `output` — Alchemy defaults it to `"server"` (superseding a
207 file-level `output`), so pages render on demand on the Server.
208 Opt into a fully prerendered site with `astro: { output: "static" }`
209 on the resource.
210
211`site`, `base`, `srcDir`, `publicDir`, `outDir`, and
212`trailingSlash` are the other serializable keys on `astro`.
213
214## Local dev
215
216`alchemy dev` runs Astro's own dev server (native HMR) instead of
217deploying; `site.url` is the local address and no Hetzner resources
218are created. Wrap the site in `Alchemy.remote()` to deploy the real
219Server and Service even during dev.
220
221## Custom domain
222
223`domain` requires an existing [`Hetzner.Zone`](/hetzner/networking/dns)
224— the Website does not create a zone. It adds an A RecordSet
225pointing at the Server's public IPv4, and `url` becomes
226`http://{domain}:3000`. There is no TLS on the Service:
227
228```typescript
229export const Dns = Hetzner.Zone("Dns", {
230 name: "example.com",
231});
232
233export const Website = Hetzner.Website.Astro("Website", {
234 domain: "app.example.com",
235 zone: Dns,
236});
237```
238
239Passing `domain` without `zone` fails.
240
241## Where next
242
243- [`Hetzner.Website.Astro` reference](/providers/hetzner/website/astro)
244 — every prop and attribute.
245- [Services](/hetzner/compute/services) and
246 [Servers](/hetzner/compute/servers) — the unit and VM the site
247 creates.
248- [Zones & records](/hetzner/networking/dns) — the Zone `domain`
249 requires.
250- [Setup](/hetzner/setup) — project, API token, and alchemy login.