| 1 | --- |
| 2 | title: "Part 2: Deploy a Service" |
| 3 | description: Bundle an Effect HTTP server, deploy it to your Hetzner Server as a systemd unit, and serve requests on a public URL. |
| 4 | sidebar: |
| 5 | order: 2 |
| 6 | --- |
| 7 | |
| 8 | import Terminal from "../../../../components/Terminal.astro"; |
| 9 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 10 | |
| 11 | In [Part 1](/hetzner/tutorial/part-1) you deployed a Server. Now |
| 12 | you'll put code on it: a `Hetzner.Service` — an Effect program that |
| 13 | Alchemy bundles, copies over SSH, and runs as a systemd unit. No |
| 14 | Dockerfile, no unit files, no scp scripts. |
| 15 | |
| 16 | ## Move the Server to a module |
| 17 | |
| 18 | So far the resources live inside the Stack's generator. To let other |
| 19 | files reference them, declare them at **module scope** instead — a |
| 20 | resource declared this way is an Effect you can import and `yield*` |
| 21 | anywhere. Create `src/server.ts` and move the key and Server from |
| 22 | Part 1 into it: |
| 23 | |
| 24 | ```typescript |
| 25 | // src/server.ts |
| 26 | import * as Hetzner from "alchemy/Hetzner"; |
| 27 | |
| 28 | export const Key = Hetzner.SshKey("laptop", { |
| 29 | publicKey: "ssh-ed25519 AAAA... you@laptop", |
| 30 | }); |
| 31 | |
| 32 | export const Box = Hetzner.Server("box", { |
| 33 | serverType: "cx22", |
| 34 | image: "ubuntu-24.04", |
| 35 | location: "nbg1", |
| 36 | sshKeys: [Key], |
| 37 | }); |
| 38 | ``` |
| 39 | |
| 40 | Notice `sshKeys: [Key]` passes the declaration directly, without |
| 41 | yielding — resource-valued props accept the resource or an Effect |
| 42 | producing it. The logical ids are still `laptop` and `box`, so |
| 43 | Alchemy recognizes them as the same resources you deployed in |
| 44 | Part 1 — moving declarations between files doesn't recreate |
| 45 | anything. |
| 46 | |
| 47 | ## Update the Stack |
| 48 | |
| 49 | Import the Server and yield it from the Stack: |
| 50 | |
| 51 | ```diff lang="typescript" |
| 52 | // alchemy.run.ts |
| 53 | import * as Alchemy from "alchemy"; |
| 54 | import * as Hetzner from "alchemy/Hetzner"; |
| 55 | import * as Effect from "effect/Effect"; |
| 56 | +import { Box } from "./src/server.ts"; |
| 57 | |
| 58 | export 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 | |
| 85 | The key is still part of the graph — it's registered when the |
| 86 | Server's `sshKeys` reference resolves. |
| 87 | |
| 88 | ## Create the Service file |
| 89 | |
| 90 | A Service in Alchemy is a class — it has both an infrastructure |
| 91 | definition and a runtime implementation expressed as an Effect. |
| 92 | Create `src/api.ts` with the smallest possible declaration: |
| 93 | |
| 94 | ```typescript |
| 95 | // src/api.ts |
| 96 | import * as Hetzner from "alchemy/Hetzner"; |
| 97 | import * as Effect from "effect/Effect"; |
| 98 | import { Box } from "./server.ts"; |
| 99 | |
| 100 | export 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 | |
| 109 | The `<Api>` type argument plus the empty `()` is a one-time bit of |
| 110 | ceremony — 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 |
| 113 | entrypoint: at deploy time it's bundled with Rolldown and shipped to |
| 114 | the Server. |
| 115 | |
| 116 | ## Serve HTTP with `fetch` |
| 117 | |
| 118 | Add a `fetch` field — Alchemy treats anything returned from the |
| 119 | `Effect.gen` block as the runtime API, and `fetch` specifically is |
| 120 | wired to an HTTP server listening on the Service's port: |
| 121 | |
| 122 | ```diff lang="typescript" |
| 123 | // src/api.ts |
| 124 | import * as Hetzner from "alchemy/Hetzner"; |
| 125 | import * as Effect from "effect/Effect"; |
| 126 | +import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 127 | import { Box } from "./server.ts"; |
| 128 | |
| 129 | export 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 |
| 142 | every other Alchemy runtime — the handler you write here would run |
| 143 | unchanged on Cloudflare Workers or AWS Lambda. |
| 144 | |
| 145 | :::note |
| 146 | Notice the "Effect returning an Effect" pattern. The outer generator |
| 147 | is the **Construction phase** — it runs at deploy time and at process |
| 148 | startup. The inner `fetch` generator is the **Runtime phase** — it |
| 149 | runs per request. |
| 150 | ::: |
| 151 | |
| 152 | ## Set the port |
| 153 | |
| 154 | Give the Service an explicit port so we know where to reach it: |
| 155 | |
| 156 | ```diff lang="typescript" |
| 157 | export 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 | |
| 163 | The port is written to the process environment as `PORT` and used to |
| 164 | build the Service's `url` attribute (`http://<server-ipv4>:<port>`). |
| 165 | |
| 166 | ## Add a health route |
| 167 | |
| 168 | When a Service declares a `port`, the deploy doesn't just start the |
| 169 | unit and hope — it polls `http://127.0.0.1:<port>/health` on the |
| 170 | Server until it answers, and fails the deploy (with the unit's |
| 171 | journal 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 | |
| 192 | The `Api` class is just a typed identifier — yielding it inside the |
| 193 | Stack's Effect is what registers the resource and starts the deploy: |
| 194 | |
| 195 | ```diff lang="typescript" |
| 196 | // alchemy.run.ts |
| 197 | import * as Alchemy from "alchemy"; |
| 198 | import * as Hetzner from "alchemy/Hetzner"; |
| 199 | import * as Effect from "effect/Effect"; |
| 200 | +import Api from "./src/api.ts"; |
| 201 | import { Box } from "./src/server.ts"; |
| 202 | |
| 203 | export 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 | |
| 222 | Yielding `Api` returns the resolved Service outputs — the systemd |
| 223 | unit 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 | |
| 247 | Proceed? |
| 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 | |
| 255 | A lot just happened over one SSH connection: |
| 256 | |
| 257 | 1. `src/api.ts` was bundled with Rolldown into a single ESM file. |
| 258 | 2. Alchemy connected to the Server using the **deploy key** it |
| 259 | injected when the Server was created — no SSH configuration on |
| 260 | your side. |
| 261 | 3. 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. |
| 265 | 4. It wrote a systemd unit (`Restart=always`), enabled it, and |
| 266 | waited for `/health` to answer. |
| 267 | |
| 268 | ## Try it out |
| 269 | |
| 270 | ```sh |
| 271 | curl http://203.0.113.10:3000 |
| 272 | # → Hello from Hetzner! |
| 273 | ``` |
| 274 | |
| 275 | The port is reachable because a fresh Hetzner server has **no |
| 276 | firewall** — 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 | |
| 281 | Edit 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 | |
| 287 | Proceed? |
| 288 | ◉ Yes ○ No |
| 289 | [g]✓[/g] [b]Api[/b] [d](Hetzner.Service)[/d] [y]updated[/y]`} /> |
| 290 | |
| 291 | Alchemy hashes the bundle: if your code didn't change, the Service |
| 292 | is a no-op; if it did, the new bundle is copied up and the unit |
| 293 | restarted — a few seconds end to end. |
| 294 | |
| 295 | ## Recap |
| 296 | |
| 297 | You 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 | |
| 304 | In [Part 3](/hetzner/tutorial/part-3), you'll attach a Volume and |
| 305 | persist data across deploys. |