Skip to content

Part 3: Persist Data with a Volume

Your Service from Part 2 is stateless. Writes to the Machine’s root disk die with the Machine. In this part you mount a Volume so files survive deploys.

There is no standalone Volume resource. Inside the Service’s init, Fly.MountVolume({ path, sizeGb }) creates a per-replica disk in the Service’s app and region:

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

MountVolume is a binding: at deploy time it tells the Service “create a 1 GB Volume and put it in config.mounts at /data”, and at runtime it hands you the resolved mount.path.

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

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

Yield FileSystem in the outer Effect. Close over it in fetch. Do not yield it per request.

src/api.ts
import * as FileSystem from "effect/FileSystem";
// ...
Effect.gen(function* () {
const mount = yield* Fly.MountVolume({ path: "/data", sizeGb: 1 });
const fs = yield* FileSystem.FileSystem;
return {
fetch: /* ... */,
};
}).pipe(Effect.provide(Fly.MountVolumeLive)),

The Volume is a directory. Use fs from init:

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 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 Fly!");
}),

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 Fly!");
Terminal window
bun alchemy deploy
Plan: 1 to update

~ Api (Fly.Service)

Proceed?
◉ Yes ○ No
 Api (Fly.Service) updated

During the Service’s deploy, Alchemy creates the Volume and writes it into the Machine’s config.mounts at /data. The disk lives in the Service’s region (iad).

Terminal window
# Store a file on the Volume
curl -X PUT https://myapp-site-dev-a1b2c3d4.fly.dev/hello.txt -d 'Hello, Volume!'
# Read it back
curl https://myapp-site-dev-a1b2c3d4.fly.dev/hello.txt
# → Hello, Volume!

Ship another code change and deploy — the file is still there. If the Machine is replaced, the Volume re-attaches on the next deploy.

You now have:

  • A 1 GB Volume in iad mounted at /data on the API Machine
  • A Service reading and writing it through Effect’s FileSystem — no SDK, just files
  • Data that outlives deploys and Machine replacement

In Part 4, you’ll store a secret on the App and read it from the Service, then tear the stack down.