1---
2title: "Part 2: Deploy a Service"
3description: Bundle an Effect HTTP server, deploy it to your Hetzner Server as a systemd unit, and serve requests on a public URL.
4sidebar:
5 order: 2
6---
7
8import Terminal from "../../../../components/Terminal.astro";
9import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
10
11In [Part 1](/hetzner/tutorial/part-1) you deployed a Server. Now
12you'll put code on it: a `Hetzner.Service` — an Effect program that
13Alchemy bundles, copies over SSH, and runs as a systemd unit. No
14Dockerfile, no unit files, no scp scripts.
15
16## Move the Server to a module
17
18So far the resources live inside the Stack's generator. To let other
19files reference them, declare them at **module scope** instead — a
20resource declared this way is an Effect you can import and `yield*`
21anywhere. Create `src/server.ts` and move the key and Server from
22Part 1 into it:
23
24```typescript
25// src/server.ts
26import * as Hetzner from "alchemy/Hetzner";
27
28export const Key = Hetzner.SshKey("laptop", {
29 publicKey: "ssh-ed25519 AAAA... you@laptop",
30});
31
32export const Box = Hetzner.Server("box", {
33 serverType: "cx22",
34 image: "ubuntu-24.04",
35 location: "nbg1",
36 sshKeys: [Key],
37});
38```
39
40Notice `sshKeys: [Key]` passes the declaration directly, without
41yielding — resource-valued props accept the resource or an Effect
42producing it. The logical ids are still `laptop` and `box`, so
43Alchemy recognizes them as the same resources you deployed in
44Part 1 — moving declarations between files doesn't recreate
45anything.
46
47## Update the Stack
48
49Import the Server and yield it from the Stack:
50
51```diff lang="typescript"
52// alchemy.run.ts
53import * as Alchemy from "alchemy";
54import * as Hetzner from "alchemy/Hetzner";
55import * as Effect from "effect/Effect";
56+import { Box } from "./src/server.ts";
57
58export default Alchemy.Stack(
59 "MyApp",
60 {
61 providers: Hetzner.providers(),
62 state: Alchemy.localState(),
63 },
64 Effect.gen(function* () {
65- const key = yield* Hetzner.SshKey("laptop", {
66- publicKey: "ssh-ed25519 AAAA... you@laptop",
67- });
68-
69- const server = yield* Hetzner.Server("box", {
70- serverType: "cx22",
71- image: "ubuntu-24.04",
72- location: "nbg1",
73- sshKeys: [key],
74- });
75+ const server = yield* Box;
76
77 return {
78 ipv4: server.ipv4,
79 ipv6: server.ipv6,
80 };
81 }),
82);
83```
84
85The key is still part of the graph — it's registered when the
86Server's `sshKeys` reference resolves.
87
88## Create the Service file
89
90A Service in Alchemy is a class — it has both an infrastructure
91definition and a runtime implementation expressed as an Effect.
92Create `src/api.ts` with the smallest possible declaration:
93
94```typescript
95// src/api.ts
96import * as Hetzner from "alchemy/Hetzner";
97import * as Effect from "effect/Effect";
98import { Box } from "./server.ts";
99
100export default class Api extends Hetzner.Service<Api>()(
101 "Api",
102 { server: Box, main: import.meta.url },
103 Effect.gen(function* () {
104 return {};
105 }),
106) {}
107```
108
109The `<Api>` type argument plus the empty `()` is a one-time bit of
110ceremony — it lets TypeScript reason about `Api` as a typed handle.
111`server: Box` says which machine this Service runs on, and
112`main: import.meta.url` tells Alchemy this same file is the bundle
113entrypoint: at deploy time it's bundled with Rolldown and shipped to
114the Server.
115
116## Serve HTTP with `fetch`
117
118Add a `fetch` field — Alchemy treats anything returned from the
119`Effect.gen` block as the runtime API, and `fetch` specifically is
120wired to an HTTP server listening on the Service's port:
121
122```diff lang="typescript"
123// src/api.ts
124import * as Hetzner from "alchemy/Hetzner";
125import * as Effect from "effect/Effect";
126+import * as HttpServerResponse from "effect/http/HttpServerResponse";
127import { Box } from "./server.ts";
128
129export default class Api extends Hetzner.Service<Api>()(
130 "Api",
131 { server: Box, main: import.meta.url },
132 Effect.gen(function* () {
133- return {};
134+ return {
135+ fetch: Effect.succeed(HttpServerResponse.text("Hello from Hetzner!")),
136+ };
137 }),
138) {}
139```
140
141`HttpServerResponse` is the same `effect/http` API used on
142every other Alchemy runtime — the handler you write here would run
143unchanged on Cloudflare Workers or AWS Lambda.
144
145:::note
146Notice the "Effect returning an Effect" pattern. The outer generator
147is the **Construction phase** — it runs at deploy time and at process
148startup. The inner `fetch` generator is the **Runtime phase** — it
149runs per request.
150:::
151
152## Set the port
153
154Give the Service an explicit port so we know where to reach it:
155
156```diff lang="typescript"
157export default class Api extends Hetzner.Service<Api>()(
158 "Api",
159- { server: Box, main: import.meta.url },
160+ { server: Box, main: import.meta.url, port: 3000 },
161```
162
163The port is written to the process environment as `PORT` and used to
164build the Service's `url` attribute (`http://<server-ipv4>:<port>`).
165
166## Add a health route
167
168When a Service declares a `port`, the deploy doesn't just start the
169unit and hope — it polls `http://127.0.0.1:<port>/health` on the
170Server until it answers, and fails the deploy (with the unit's
171journal in the output) if it never does. Add the route:
172
173```diff lang="typescript"
174// src/api.ts
175+import { HttpServerRequest } from "effect/http/HttpServerRequest";
176// ...
177 return {
178- fetch: Effect.succeed(HttpServerResponse.text("Hello from Hetzner!")),
179+ fetch: Effect.gen(function* () {
180+ const request = yield* HttpServerRequest;
181+ const url = new URL(request.url, "http://service");
182+ if (url.pathname === "/health") {
183+ return yield* HttpServerResponse.json({ ok: true });
184+ }
185+ return HttpServerResponse.text("Hello from Hetzner!");
186+ }),
187 };
188```
189
190## Wire the Service into the Stack
191
192The `Api` class is just a typed identifier — yielding it inside the
193Stack's Effect is what registers the resource and starts the deploy:
194
195```diff lang="typescript"
196// alchemy.run.ts
197import * as Alchemy from "alchemy";
198import * as Hetzner from "alchemy/Hetzner";
199import * as Effect from "effect/Effect";
200+import Api from "./src/api.ts";
201import { Box } from "./src/server.ts";
202
203export default Alchemy.Stack(
204 "MyApp",
205 {
206 providers: Hetzner.providers(),
207 state: Alchemy.localState(),
208 },
209 Effect.gen(function* () {
210 const server = yield* Box;
211+ const api = yield* Api;
212
213 return {
214 ipv4: server.ipv4,
215- ipv6: server.ipv6,
216+ url: api.url,
217 };
218 }),
219);
220```
221
222Yielding `Api` returns the resolved Service outputs — the systemd
223unit name, the port, and the public `url` we surface from the Stack.
224
225## Deploy
226
227<Tabs syncKey="pkgManager">
228 <TabItem label="bun" icon="bun">
229 <Code code="bun alchemy deploy" lang="sh" />
230 </TabItem>
231 <TabItem label="npm" icon="npm">
232 <Code code="npm run alchemy deploy" lang="sh" />
233 </TabItem>
234 <TabItem label="pnpm" icon="pnpm">
235 <Code code="pnpm alchemy deploy" lang="sh" />
236 </TabItem>
237 <TabItem label="yarn" icon="seti:yarn">
238 <Code code="yarn alchemy deploy" lang="sh" />
239 </TabItem>
240</Tabs>
241
242<Terminal content={`[u]Plan[/u]: [g]1 to create[/g]
243
244[g]+[/g] [b]Api[/b] [d](Hetzner.Service)[/d]
245[d]•[/d] [b]box[/b] [d](Hetzner.Server)[/d]
246
247Proceed?
248◉ Yes ○ No
249[g]✓[/g] [b]Api[/b] [d](Hetzner.Service)[/d] [g]created[/g]
250{
251[s2]ipv4: "203.0.113.10",
252[s2]url: "http://203.0.113.10:3000",
253}`} />
254
255A lot just happened over one SSH connection:
256
2571. `src/api.ts` was bundled with Rolldown into a single ESM file.
2582. Alchemy connected to the Server using the **deploy key** it
259 injected when the Server was created — no SSH configuration on
260 your side.
2613. It unpacked the bundle to `/opt/<unit>/` and wrote an
262 `EnvironmentFile` with `PORT` and the stack metadata. The
263 [Bun](https://bun.sh) runtime is already there — Alchemy's
264 cloud-init bootstrap installed it when the Server was created.
2654. It wrote a systemd unit (`Restart=always`), enabled it, and
266 waited for `/health` to answer.
267
268## Try it out
269
270```sh
271curl http://203.0.113.10:3000
272# → Hello from Hetzner!
273```
274
275The port is reachable because a fresh Hetzner server has **no
276firewall** — every port is open until you apply one. In
277[Part 4](/hetzner/tutorial/part-4) we'll lock this down.
278
279## Ship a change
280
281Edit the greeting in `src/api.ts` and deploy again:
282
283<Terminal content={`[u]Plan[/u]: [y]1 to update[/y]
284
285[y]~[/y] [b]Api[/b] [d](Hetzner.Service)[/d]
286
287Proceed?
288◉ Yes ○ No
289[g]✓[/g] [b]Api[/b] [d](Hetzner.Service)[/d] [y]updated[/y]`} />
290
291Alchemy hashes the bundle: if your code didn't change, the Service
292is a no-op; if it did, the new bundle is copied up and the unit
293restarted — a few seconds end to end.
294
295## Recap
296
297You now have:
298
299- An HTTP Service running on your Server as a systemd unit, with
300 `Restart=always` supervision
301- A public URL serving requests, verified healthy at deploy time
302- A code-hash-based update loop — edit, deploy, restarted
303
304In [Part 3](/hetzner/tutorial/part-3), you'll attach a Volume and
305persist data across deploys.