| 1 | --- |
| 2 | title: "Part 3: Persist Data with a Volume" |
| 3 | description: Attach a Hetzner Volume, mount it into your Service with the MountVolume binding, and store files that survive deploys. |
| 4 | sidebar: |
| 5 | order: 3 |
| 6 | --- |
| 7 | |
| 8 | import Terminal from "../../../../components/Terminal.astro"; |
| 9 | import { Code, Tabs, TabItem } from "@astrojs/starlight/components"; |
| 10 | |
| 11 | Your Service from [Part 2](/hetzner/tutorial/part-2) is stateless — |
| 12 | anything it writes to the Server's root disk dies with the Server. |
| 13 | In this part you'll attach a **Volume** — Hetzner's network block |
| 14 | storage — and mount it into the Service so files survive deploys and |
| 15 | even Server replacement. |
| 16 | |
| 17 | ## Declare a Volume |
| 18 | |
| 19 | Add a Volume next to the Server in `src/server.ts`: |
| 20 | |
| 21 | ```diff lang="typescript" |
| 22 | // src/server.ts |
| 23 | import * as Hetzner from "alchemy/Hetzner"; |
| 24 | |
| 25 | export const Key = Hetzner.SshKey("laptop", { /* ... */ }); |
| 26 | |
| 27 | export const Box = Hetzner.Server("box", { |
| 28 | serverType: "cx22", |
| 29 | image: "ubuntu-24.04", |
| 30 | location: "nbg1", |
| 31 | sshKeys: [Key], |
| 32 | }); |
| 33 | |
| 34 | +export const Data = Hetzner.Volume("data", { |
| 35 | + size: 10, |
| 36 | + format: "ext4", |
| 37 | + location: "nbg1", |
| 38 | +}); |
| 39 | ``` |
| 40 | |
| 41 | `size` is in GB (10 is the minimum), and the `location` must match |
| 42 | the Server's — volumes attach over the local network. Note what's |
| 43 | *not* here: no `server` prop. The Service will claim it instead. |
| 44 | |
| 45 | ## Mount it into the Service |
| 46 | |
| 47 | Inside the Service's constructor, bind the Volume with |
| 48 | `Hetzner.MountVolume` — pass the declaration directly, no yielding |
| 49 | required: |
| 50 | |
| 51 | ```diff lang="typescript" |
| 52 | // src/api.ts |
| 53 | import * as Hetzner from "alchemy/Hetzner"; |
| 54 | import * as Effect from "effect/Effect"; |
| 55 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 56 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 57 | -import { Box } from "./server.ts"; |
| 58 | +import { Box, Data } from "./server.ts"; |
| 59 | |
| 60 | export default class Api extends Hetzner.Service<Api>()( |
| 61 | "Api", |
| 62 | { server: Box, main: import.meta.url, port: 3000 }, |
| 63 | Effect.gen(function* () { |
| 64 | + const mount = yield* Hetzner.MountVolume(Data, { path: "/data" }); |
| 65 | |
| 66 | return { |
| 67 | fetch: /* ... */, |
| 68 | }; |
| 69 | }), |
| 70 | ) {} |
| 71 | ``` |
| 72 | |
| 73 | `MountVolume` is a **binding**: at deploy time it tells the Service |
| 74 | "attach this Volume to my Server and mount it at `/data`", and at |
| 75 | runtime it hands you the resolved `mount.path` and `mount.device`. |
| 76 | |
| 77 | ## Provide the binding layer |
| 78 | |
| 79 | Bindings declare a capability; layers implement it. Provide |
| 80 | `MountVolumeLive` on the Service's constructor Effect: |
| 81 | |
| 82 | ```diff lang="typescript" |
| 83 | Effect.gen(function* () { |
| 84 | const mount = yield* Hetzner.MountVolume(Data, { path: "/data" }); |
| 85 | |
| 86 | return { |
| 87 | fetch: /* ... */, |
| 88 | }; |
| 89 | - }), |
| 90 | + }).pipe(Effect.provide(Hetzner.MountVolumeLive)), |
| 91 | ) {} |
| 92 | ``` |
| 93 | |
| 94 | ## Write files with `PUT /:name` |
| 95 | |
| 96 | Now use the mount from the handler. The runtime provides Effect's |
| 97 | `FileSystem` service, so the Volume is just a directory: |
| 98 | |
| 99 | ```diff lang="typescript" |
| 100 | // src/api.ts |
| 101 | +import * as FileSystem from "effect/FileSystem"; |
| 102 | // ... |
| 103 | fetch: Effect.gen(function* () { |
| 104 | const request = yield* HttpServerRequest; |
| 105 | const url = new URL(request.url, "http://service"); |
| 106 | if (url.pathname === "/health") { |
| 107 | return HttpServerResponse.json({ ok: true }); |
| 108 | } |
| 109 | + const fs = yield* FileSystem.FileSystem; |
| 110 | + const file = `${mount.path}${url.pathname}`; |
| 111 | + |
| 112 | + if (request.method === "PUT") { |
| 113 | + const body = yield* request.text; |
| 114 | + yield* fs.writeFileString(file, body).pipe(Effect.orDie); |
| 115 | + return HttpServerResponse.empty({ status: 204 }); |
| 116 | + } |
| 117 | + |
| 118 | return HttpServerResponse.text("Hello from Hetzner!"); |
| 119 | }), |
| 120 | ``` |
| 121 | |
| 122 | ## Read files with `GET /:name` |
| 123 | |
| 124 | Add the read branch, turning a missing file into a 404: |
| 125 | |
| 126 | ```diff lang="typescript" |
| 127 | if (request.method === "PUT") { |
| 128 | const body = yield* request.text; |
| 129 | yield* fs.writeFileString(file, body).pipe(Effect.orDie); |
| 130 | return HttpServerResponse.empty({ status: 204 }); |
| 131 | } |
| 132 | |
| 133 | +if (request.method === "GET") { |
| 134 | + const text = yield* fs.readFileString(file).pipe( |
| 135 | + Effect.catch(() => Effect.succeed(undefined)), |
| 136 | + ); |
| 137 | + if (text === undefined) { |
| 138 | + return HttpServerResponse.text("Not found", { status: 404 }); |
| 139 | + } |
| 140 | + return HttpServerResponse.text(text); |
| 141 | +} |
| 142 | |
| 143 | return HttpServerResponse.text("Hello from Hetzner!"); |
| 144 | ``` |
| 145 | |
| 146 | ## Deploy |
| 147 | |
| 148 | <Tabs syncKey="pkgManager"> |
| 149 | <TabItem label="bun" icon="bun"> |
| 150 | <Code code="bun alchemy deploy" lang="sh" /> |
| 151 | </TabItem> |
| 152 | <TabItem label="npm" icon="npm"> |
| 153 | <Code code="npm run alchemy deploy" lang="sh" /> |
| 154 | </TabItem> |
| 155 | <TabItem label="pnpm" icon="pnpm"> |
| 156 | <Code code="pnpm alchemy deploy" lang="sh" /> |
| 157 | </TabItem> |
| 158 | <TabItem label="yarn" icon="seti:yarn"> |
| 159 | <Code code="yarn alchemy deploy" lang="sh" /> |
| 160 | </TabItem> |
| 161 | </Tabs> |
| 162 | |
| 163 | <Terminal content={`[u]Plan[/u]: [g]1 to create[/g], [y]1 to update[/y] |
| 164 | |
| 165 | [g]+[/g] [b]data[/b] [d](Hetzner.Volume)[/d] |
| 166 | [y]~[/y] [b]Api[/b] [d](Hetzner.Service)[/d] |
| 167 | |
| 168 | Proceed? |
| 169 | ◉ Yes ○ No |
| 170 | [g]✓[/g] [b]data[/b] [d](Hetzner.Volume)[/d] [g]created[/g] |
| 171 | [g]✓[/g] [b]Api[/b] [d](Hetzner.Service)[/d] [y]updated[/y]`} /> |
| 172 | |
| 173 | During the Service's deploy, Alchemy attaches the Volume to the |
| 174 | Server, then (over the same SSH session) creates `/data`, mounts the |
| 175 | device, and adds an `/etc/fstab` entry so the mount survives |
| 176 | reboots. Every step is idempotent — two Services mounting the same |
| 177 | `(volume, path)` share one attach and one mount. |
| 178 | |
| 179 | ## Try it out |
| 180 | |
| 181 | ```sh |
| 182 | # Store a file on the Volume |
| 183 | curl -X PUT http://203.0.113.10:3000/hello.txt -d 'Hello, Volume!' |
| 184 | |
| 185 | # Read it back |
| 186 | curl http://203.0.113.10:3000/hello.txt |
| 187 | # → Hello, Volume! |
| 188 | ``` |
| 189 | |
| 190 | Ship another code change and deploy — the file is still there. The |
| 191 | Volume is a separate resource with its own lifecycle: even if the |
| 192 | Server is replaced, the Volume re-attaches to the new machine on the |
| 193 | next deploy. |
| 194 | |
| 195 | ## Recap |
| 196 | |
| 197 | You now have: |
| 198 | |
| 199 | - A 10 GB Volume attached to your Server and mounted at `/data`, |
| 200 | with an fstab entry for reboots |
| 201 | - A Service reading and writing it through Effect's `FileSystem` — |
| 202 | no SDK, just files |
| 203 | - Data that outlives deploys and Server replacement |
| 204 | |
| 205 | In [Part 4](/hetzner/tutorial/part-4), you'll put a firewall in |
| 206 | front of the Server and a managed Load Balancer in front of the |
| 207 | Service. |