1---
2title: Vocs
3description: Deploy a Vocs docs site to Hetzner with Hetzner.Website.Vocs — prerendered HTML on a systemd unit, extensionless routes, and Vocs' own dev server under alchemy dev.
4sidebar:
5 order: 10
6---
7
8import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
9
10export const devDeps = "@alchemy.run/frontend-frameworks";
11
12`Hetzner.Website.Vocs` deploys a [Vocs](https://vocs.dev)
13documentation project to a Hetzner Cloud
14[Server](/hetzner/compute/servers) as a systemd
15[Service](/hetzner/compute/services) on port 3000. Vocs prerenders
16static HTML; unmatched paths serve extensionless pages (`/about` →
17`about/index.html`). The live URL is `http://{ipv4}:3000` — there is
18no TLS on the Service. Your `vocs.config.*` loads natively — there
19is no adapter to install.
20
21## Install
22
23The build integration is not bundled with alchemy. Install
24`@alchemy.run/frontend-frameworks`; the resource loads
25`/vocs/node` from your project at deploy time. It is only used at
26build 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
43Your Vocs project already needs `vocs` installed.
44
45## Configure Vocs
46
47Your `vocs.config.*` file loads natively — title, sidebar, and any
48other Vocs options work exactly as they do outside Alchemy:
49
50```typescript
51// vocs.config.ts
52import { defineConfig } from "vocs/config";
53
54export default defineConfig({
55 title: "Docs",
56 sidebar: [
57 { text: "Home", link: "/" },
58 { text: "Guide", link: "/guide" },
59 ],
60});
61```
62
63If `vocs.config.*` sets `outDir`, pass the same value on the
64resource (default `"dist"`) so generated output stays outside the
65rebuild hash:
66
67```typescript
68export const Website = Hetzner.Website.Vocs("Website", {
69 rootDir: "./docs",
70 outDir: "build",
71});
72```
73
74## Declare the Website
75
76Declare the site as a module-level const (rather than inline in the
77Stack):
78
79```typescript
80// alchemy.run.ts
81import * as Hetzner from "alchemy/Hetzner";
82
83export const Website = Hetzner.Website.Vocs("Website", {
84 rootDir: "./docs",
85});
86```
87
88Omit `server` to create a `cpx12` / `ubuntu-24.04` box in `fsn1`.
89Pass an existing `Hetzner.Server` when several sites or APIs should
90share one VM:
91
92```typescript
93export const Box = Hetzner.Server("box", {
94 serverType: "cpx12",
95 image: "ubuntu-24.04",
96 location: "fsn1",
97});
98
99export const Website = Hetzner.Website.Vocs("Website", {
100 rootDir: "./docs",
101 server: Box,
102});
103```
104
105## Add it to the Stack
106
107Yield the site from your Stack and return its URL:
108
109```typescript
110// alchemy.run.ts
111import * as Alchemy from "alchemy";
112import * as Effect from "effect/Effect";
113
114export default Alchemy.Stack(
115 "MyVocsSite",
116 {
117 providers: Hetzner.providers(),
118 state: Alchemy.localState(),
119 },
120 Effect.gen(function* () {
121 const site = yield* Website;
122 return { url: site.url };
123 }),
124);
125```
126
127The live URL is `http://{ipv4}:3000`. The unit listens on port 3000
128and answers `GET /health` (`deployUnit` curls that path whenever
129`PORT` is set). `site.server` and `site.service` are set on deploy.
130
131## Add environment variables
132
133Top-level `env` is copied onto `process.env` before build and
134`alchemy dev`, and onto the systemd unit at deploy:
135
136```diff lang="typescript"
137// alchemy.run.ts
138export const Website = Hetzner.Website.Vocs("Website", {
139 rootDir: "./docs",
140+ env: {
141+ DOCS_TITLE: "Hello from Alchemy!",
142+ },
143});
144```
145
146These are process environment variables, not Worker bindings.
147
148## Read the environment
149
150Vocs prerenders at build time, so page content that reads
151`process.env` is baked into the HTML. The same values are set on
152the hosted Node process; there is no request-time Vocs server.
153
154```typescript
155const title = process.env.DOCS_TITLE ?? "Docs";
156```
157
158## Local development
159
160`alchemy dev` runs Vocs' own dev server (native HMR) instead of
161deploying; `site.url` is the local address and no Hetzner resources
162are created (`server` and `service` are `undefined`). Wrap the site
163in `Alchemy.remote()` to deploy the live Server and Service even
164during dev:
165
166```typescript
167export const Website = Hetzner.Website.Vocs("Website", {
168 rootDir: "./docs",
169}).pipe(Alchemy.remote());
170```
171
172## Custom domain
173
174`domain` requires an existing Hetzner DNS [Zone](/hetzner/networking/dns)
175(`zone`). Alchemy creates an A [RecordSet](/hetzner/networking/dns)
176pointing at the Server's public IPv4; `url` becomes
177`http://{domain}:3000`. v1 does not provision a Zone — omitting
178`zone` when `domain` is set fails:
179
180```typescript
181export const Dns = Hetzner.Zone("dns", {
182 name: "example.com",
183});
184
185export const Website = Hetzner.Website.Vocs("Website", {
186 rootDir: "./docs",
187 domain: "docs.example.com",
188 zone: Dns,
189});
190```
191
192There is no TLS on the Service.
193
194## Where next
195
196- [`Vocs` reference](/providers/hetzner/website/vocs) — every prop
197 and attribute
198- [Servers](/hetzner/compute/servers) and
199 [Services](/hetzner/compute/services) — the VM and systemd unit
200- [Zones & records](/hetzner/networking/dns) — the Zone `domain`
201 requires
202- [Hetzner](/hetzner) — the Hetzner provider hub