Skip to content

Part 3: Persist Data with a Volume

Your Service from Part 2 is stateless — anything it writes to the Server’s root disk dies with the Server. In this part you’ll attach a Volume — Hetzner’s network block storage — and mount it into the Service so files survive deploys and even Server replacement.

Add a Volume next to the Server in src/server.ts:

src/server.ts
import * as Hetzner from "alchemy/Hetzner";
export const Key = Hetzner.SshKey("laptop", { /* ... */ });
export const Box = Hetzner.Server("box", {
serverType: "cx22",
image: "ubuntu-24.04",
location: "nbg1",
sshKeys: [Key],
});
export const Data = Hetzner.Volume("data", {
size: 10,
format: "ext4",
location: "nbg1",
});

size is in GB (10 is the minimum), and the location must match the Server’s — volumes attach over the local network. Note what’s not here: no server prop. The Service will claim it instead.

Inside the Service’s init, bind the Volume with Hetzner.MountVolume — pass the declaration directly, no yielding required:

src/api.ts
import * as Hetzner from "alchemy/Hetzner";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { Box } from "./server.ts";
import { Box, Data } from "./server.ts";
export default class Api extends Hetzner.Service<Api>()(
"Api",
{ server: Box, main: import.meta.url, port: 3000 },
Effect.gen(function* () {
const mount = yield* Hetzner.MountVolume(Data, { path: "/data" });
return {
fetch: /* ... */,
};
}),
) {}

MountVolume is a binding: at deploy time it tells the Service “attach this Volume to my Server and mount it at /data”, and at runtime it hands you the resolved mount.path and mount.device.

Bindings declare a capability; layers implement it. Provide MountVolumeLive on the Service’s init Effect:

Effect.gen(function* () {
const mount = yield* Hetzner.MountVolume(Data, { path: "/data" });
return {
fetch: /* ... */,
};
}),
}).pipe(Effect.provide(Hetzner.MountVolumeLive)),
) {}

Now use the mount from the handler. The runtime provides Effect’s FileSystem service, so the Volume is just a directory:

src/api.ts
import * as FileSystem from "effect/FileSystem";
// ...
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const url = new URL(request.url, "http://service");
if (url.pathname === "/health") {
return HttpServerResponse.json({ ok: true });
}
const fs = yield* FileSystem.FileSystem;
const file = `${mount.path}${url.pathname}`;
if (request.method === "PUT") {
const body = yield* request.text;
yield* fs.writeFileString(file, body).pipe(Effect.orDie);
return HttpServerResponse.empty({ status: 204 });
}
return HttpServerResponse.text("Hello from Hetzner!");
}),

Add the read branch, turning a missing file into a 404:

if (request.method === "PUT") {
const body = yield* request.text;
yield* fs.writeFileString(file, body).pipe(Effect.orDie);
return HttpServerResponse.empty({ status: 204 });
}
if (request.method === "GET") {
const text = yield* fs.readFileString(file).pipe(
Effect.catchAll(() => Effect.succeed(undefined)),
);
if (text === undefined) {
return HttpServerResponse.text("Not found", { status: 404 });
}
return HttpServerResponse.text(text);
}
return HttpServerResponse.text("Hello from Hetzner!");
Terminal window
bun alchemy deploy
Plan: 1 to create, 1 to update

+ data (Hetzner.Volume)
~ Api (Hetzner.Service)

Proceed?
◉ Yes ○ No
 data (Hetzner.Volume) created
 Api (Hetzner.Service) updated

During the Service’s deploy, Alchemy attaches the Volume to the Server, then (over the same SSH session) creates /data, mounts the device, and adds an /etc/fstab entry so the mount survives reboots. Every step is idempotent — two Services mounting the same (volume, path) share one attach and one mount.

Terminal window
# Store a file on the Volume
curl -X PUT http://203.0.113.10:3000/hello.txt -d 'Hello, Volume!'
# Read it back
curl http://203.0.113.10:3000/hello.txt
# → Hello, Volume!

Ship another code change and deploy — the file is still there. The Volume is a separate resource with its own lifecycle: even if the Server is replaced, the Volume re-attaches to the new machine on the next deploy.

You now have:

  • A 10 GB Volume attached to your Server and mounted at /data, with an fstab entry for reboots
  • A Service reading and writing it through Effect’s FileSystem — no SDK, just files
  • Data that outlives deploys and Server replacement

In Part 4, you’ll put a firewall in front of the Server and a managed Load Balancer in front of the Service.