Skip to content

Vocs

Hetzner.Website.Vocs deploys a Vocs documentation project to a Hetzner Cloud Server as a systemd Service on port 3000. Vocs prerenders static HTML; unmatched paths serve extensionless pages (/aboutabout/index.html). The live URL is http://{ipv4}:3000 — there is no TLS on the Service. Your vocs.config.* loads natively — there is no adapter to install.

The build integration is not bundled with alchemy. Install @alchemy.run/frontend-frameworks; the resource loads /vocs/node 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 Vocs project already needs vocs installed.

Your vocs.config.* file loads natively — title, sidebar, and any other Vocs options work exactly as they do outside Alchemy:

vocs.config.ts
import { defineConfig } from "vocs/config";
export default defineConfig({
title: "Docs",
sidebar: [
{ text: "Home", link: "/" },
{ text: "Guide", link: "/guide" },
],
});

If vocs.config.* sets outDir, pass the same value on the resource (default "dist") so generated output stays outside the rebuild hash:

export const Website = Hetzner.Website.Vocs("Website", {
rootDir: "./docs",
outDir: "build",
});

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.Vocs("Website", {
rootDir: "./docs",
});

Omit server to create a cpx12 / ubuntu-24.04 box in fsn1. Pass an existing Hetzner.Server when several sites or APIs should share one VM:

export const Box = Hetzner.Server("box", {
serverType: "cpx12",
image: "ubuntu-24.04",
location: "fsn1",
});
export const Website = Hetzner.Website.Vocs("Website", {
rootDir: "./docs",
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(
"MyVocsSite",
{
providers: Hetzner.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const site = yield* Website;
return { url: site.url };
}),
);

The live URL is http://{ipv4}:3000. The unit listens on port 3000 and answers GET /health (deployUnit curls that path whenever PORT is set). site.server and site.service are set on deploy.

Top-level env is copied onto process.env before build and alchemy dev, and onto the systemd unit at deploy:

alchemy.run.ts
export const Website = Hetzner.Website.Vocs("Website", {
rootDir: "./docs",
env: {
DOCS_TITLE: "Hello from Alchemy!",
},
});

These are process environment variables, not Worker bindings.

Vocs prerenders at build time, so page content that reads process.env is baked into the HTML. The same values are set on the hosted Node process; there is no request-time Vocs server.

const title = process.env.DOCS_TITLE ?? "Docs";

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

export const Website = Hetzner.Website.Vocs("Website", {
rootDir: "./docs",
}).pipe(Alchemy.remote());

domain requires an existing Hetzner DNS Zone (zone). Alchemy creates an A RecordSet pointing at the Server’s public IPv4; url becomes http://{domain}:3000. v1 does not provision a Zone — omitting zone when domain is set fails:

export const Dns = Hetzner.Zone("dns", {
name: "example.com",
});
export const Website = Hetzner.Website.Vocs("Website", {
rootDir: "./docs",
domain: "docs.example.com",
zone: Dns,
});

There is no TLS on the Service.