1---
2title: Octane
3description: Deploy an OctaneJS app to Hetzner with Hetzner.Website.Octane — SSR as a systemd unit on port 3000, client assets baked into the unit, and Octane's own Vite dev server under alchemy dev.
4sidebar:
5 order: 14
6---
7
8import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
9
10export const devDeps = "@alchemy.run/frontend-frameworks";
11
12`Hetzner.Website.Octane` deploys an [OctaneJS](https://octanejs.dev)
13fullstack app to a Hetzner Cloud Server. Octane wraps Vite, so the
14resource is deliberately thin: it runs your project's own
15`vite build` — Octane's plugin builds the client bundle and the
16self-contained SSR server bundle, and the Node deploy target's
17finishing pass wraps its fetch handler as a Node HTTP program. The
18server runs as a systemd unit on port 3000 (`Hetzner.Service` on a
19`Hetzner.Server`); client assets are baked into the unit. The live
20URL is `http://{ipv4}:3000` — no TLS on the Service. No Dockerfile,
21no build command to run.
22
23## Install
24
25The build integration is not bundled with alchemy. Install
26`@alchemy.run/frontend-frameworks`; the resource loads the package's
27`/octane` and `/octane/node` exports from your project at deploy
28time. It is only used at build time, so a dev dependency is enough:
29
30<Tabs syncKey="pkgManager">
31 <TabItem label="bun" icon="bun">
32 <Code code={`bun add -d ${devDeps}`} lang="sh" />
33 </TabItem>
34 <TabItem label="npm" icon="npm">
35 <Code code={`npm install -D ${devDeps}`} lang="sh" />
36 </TabItem>
37 <TabItem label="pnpm" icon="pnpm">
38 <Code code={`pnpm add -D ${devDeps}`} lang="sh" />
39 </TabItem>
40 <TabItem label="yarn" icon="seti:yarn">
41 <Code code={`yarn add -D ${devDeps}`} lang="sh" />
42 </TabItem>
43</Tabs>
44
45## Configure Octane
46
47Keep native compiler and route settings in `octane.config.ts`, without
48an adapter:
49
50```typescript
51// octane.config.ts
52import { defineConfig, RenderRoute } from "@octanejs/vite-plugin";
53
54export default defineConfig({
55 router: {
56 routes: [new RenderRoute({ path: "/", entry: ["App", "/src/App.tsx"] })],
57 },
58});
59```
60
61`Hetzner.Website.Octane` selects hosting and automatically wraps Octane's
62default native Node output as an HTTP server. The legacy Node marker
63adapter remains optional for existing projects.
64
65Vite plugins like Tailwind go in `vite.config.ts` next to it,
66alongside the Octane plugin:
67
68```typescript
69// vite.config.ts
70import { octane } from "@octanejs/vite-plugin";
71import tailwindcss from "@tailwindcss/vite";
72import { defineConfig } from "vite";
73
74export default defineConfig({
75 plugins: [octane(), tailwindcss()],
76});
77```
78
79## Declare the Website
80
81Declare the site as a module-level const (rather than inline in the
82Stack):
83
84```typescript
85// alchemy.run.ts
86import * as Hetzner from "alchemy/Hetzner";
87
88export const Website = Hetzner.Website.Octane("Website");
89```
90
91Omit `server` and Alchemy creates a `cpx12` / `ubuntu-24.04` Server
92in `fsn1`. Pass an existing Server so several sites or APIs share
93one VM:
94
95```typescript
96export const Box = Hetzner.Server("Box", {
97 serverType: "cpx12",
98 image: "ubuntu-24.04",
99 location: "fsn1",
100});
101
102export const Website = Hetzner.Website.Octane("Website", {
103 server: Box,
104});
105```
106
107## Add it to the Stack
108
109Yield the site from your Stack and return its URL:
110
111```typescript
112// alchemy.run.ts
113import * as Alchemy from "alchemy";
114import * as Effect from "effect/Effect";
115
116export default Alchemy.Stack(
117 "MyOctaneSite",
118 {
119 providers: Hetzner.providers(),
120 state: Alchemy.localState(),
121 },
122 Effect.gen(function* () {
123 const site = yield* Website;
124 return { url: site.url };
125 }),
126);
127```
128
129On deploy, `url` is `http://{ipv4}:3000`. The resolved site also
130exposes `server` and `service` (both `undefined` under
131`alchemy dev`).
132
133## Add environment variables
134
135Process environment is configured under `env` — plain strings, or
136`Redacted` values. They are copied onto `process.env` before the
137build and onto the systemd unit at deploy time:
138
139```diff lang="typescript"
140// alchemy.run.ts
141export const Website = Hetzner.Website.Octane("Website", {
142+ env: {
143+ GREETING: "Hello from alchemy",
144+ API_BASE: "https://api.example.com",
145+ },
146});
147```
148
149The same values are injected into the dev server's process
150environment under `alchemy dev`, so server code reads them in both
151modes.
152
153## Read the environment in server code
154
155Octane's server runs in Node on the Server, so middleware and
156`ServerRoute` handlers read the environment from `process.env`:
157
158```typescript
159// octane.config.ts
160new ServerRoute({
161 path: "/api/greeting",
162 methods: ["GET"],
163 handler: async () => {
164 return Response.json({ greeting: process.env.GREETING ?? "hello" });
165 },
166});
167```
168
169## Asset routing
170
171Octane's intended setup is asset-first with SSR on miss: exact files
172in `dist/client` are served by the Node process without invoking the
173Octane handler, and every miss streams from Octane SSR. That is the
174default — there is nothing to configure. `GET /health` answers for
175the unit health check.
176
177## Local dev
178
179```sh
180bun alchemy dev
181```
182
183`alchemy dev` runs Octane's own Vite dev server — the plugin's
184in-process SSR middleware serves rendering, server routes, and RPC
185with full HMR. `site.url` is the local address and no Hetzner
186resources are created (`server` and `service` are `undefined`). Wrap
187the site in `Alchemy.remote()` to deploy the real infrastructure even
188during dev.
189
190## Custom domain
191
192`domain` requires an existing Hetzner DNS `zone`. Alchemy creates an
193A `RecordSet` pointing at the Server's public IPv4. There is no TLS
194on the Service — `url` becomes `http://{domain}:3000`. Passing
195`domain` without `zone` fails.
196
197```typescript
198const zone = yield* Hetzner.Zone("Dns", {
199 name: "example.com",
200});
201
202const site = yield* Hetzner.Website.Octane("Web", {
203 domain: "app.example.com",
204 zone,
205});
206```
207
208See [Zones & records](/hetzner/networking/dns).
209
210## Where next
211
212- [`Hetzner.Website.Octane` reference](/providers/hetzner/website/octane)
213- [Servers](/hetzner/compute/servers) and
214 [Services](/hetzner/compute/services)
215- [Zones & records](/hetzner/networking/dns)