1---
2title: "Part 3: Persist Data with a Volume"
3description: Attach a Hetzner Volume, mount it into your Service with the MountVolume binding, and store files that survive deploys.
4sidebar:
5 order: 3
6---
7
8import Terminal from "../../../../components/Terminal.astro";
9import { Code, Tabs, TabItem } from "@astrojs/starlight/components";
10
11Your Service from [Part 2](/hetzner/tutorial/part-2) is stateless —
12anything it writes to the Server's root disk dies with the Server.
13In this part you'll attach a **Volume** — Hetzner's network block
14storage — and mount it into the Service so files survive deploys and
15even Server replacement.
16
17## Declare a Volume
18
19Add a Volume next to the Server in `src/server.ts`:
20
21```diff lang="typescript"
22// src/server.ts
23import * as Hetzner from "alchemy/Hetzner";
24
25export const Key = Hetzner.SshKey("laptop", { /* ... */ });
26
27export 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
42the 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
47Inside the Service's constructor, bind the Volume with
48`Hetzner.MountVolume` — pass the declaration directly, no yielding
49required:
50
51```diff lang="typescript"
52// src/api.ts
53import * as Hetzner from "alchemy/Hetzner";
54import * as Effect from "effect/Effect";
55import { HttpServerRequest } from "effect/http/HttpServerRequest";
56import * as HttpServerResponse from "effect/http/HttpServerResponse";
57-import { Box } from "./server.ts";
58+import { Box, Data } from "./server.ts";
59
60export 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
75runtime it hands you the resolved `mount.path` and `mount.device`.
76
77## Provide the binding layer
78
79Bindings 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
96Now 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
124Add the read branch, turning a missing file into a 404:
125
126```diff lang="typescript"
127if (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
143return 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
168Proceed?
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
173During the Service's deploy, Alchemy attaches the Volume to the
174Server, then (over the same SSH session) creates `/data`, mounts the
175device, and adds an `/etc/fstab` entry so the mount survives
176reboots. 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
183curl -X PUT http://203.0.113.10:3000/hello.txt -d 'Hello, Volume!'
184
185# Read it back
186curl http://203.0.113.10:3000/hello.txt
187# → Hello, Volume!
188```
189
190Ship another code change and deploy — the file is still there. The
191Volume is a separate resource with its own lifecycle: even if the
192Server is replaced, the Volume re-attaches to the new machine on the
193next deploy.
194
195## Recap
196
197You 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
205In [Part 4](/hetzner/tutorial/part-4), you'll put a firewall in
206front of the Server and a managed Load Balancer in front of the
207Service.