Skip to content

Service

Source: src/Fly/Service.ts

A Service is an Effect program running in a Fly.io Machine. Set count to scale it up or down. Several Services share one App.

A Service is a class. Props describe the Machine. The Effect is the program that runs on it.

app is the parent App. Pass the declaration directly, yielded or module-scope. main: import.meta.url is the bundle entrypoint. Alchemy bundles this file with Rolldown, builds a Docker image (default oven/bun:1), and pushes it to registry.fly.io/{app}:{id}-{hash}.

src/api.ts
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
import { Site } from "./app.ts";
export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url },
Effect.gen(function* () {
return {};
}),
) {}

Return fetch from the init Effect to boot an HTTP server. Omit fetch for a background service.

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Fly Machines live in a region. Default is iad. See Regions for the list of codes.

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, region: "iad" },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

port is the port the process listens on inside the Machine. Alchemy writes it to PORT. Default is 3000.

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, region: "iad", port: 3000 },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Yield the Service in the Stack. api.url is https://{appName}.fly.dev. Alchemy does not create this hostname. It is the parent App’s fly.dev name. The Service does not get its own URL.

export default Alchemy.Stack(
"MyApp",
{ providers: Fly.providers(), state: Alchemy.localState() },
Effect.gen(function* () {
const api = yield* Api;
return { url: api.url };
}),
);

url is undefined when you pass services: [] (nothing is published).

There is no LoadBalancer resource. Fly runs an Anycast proxy at the edge.

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, region: "iad", port: 3000 },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Unless you override services, Alchemy publishes HTTP 80 and HTTPS 443 on that proxy and points them at port inside each Machine (internal_port). A request to https://{appName}.fly.dev lands on Fly’s edge. Fly terminates TLS on 443, picks one started Machine that published this service, and forwards to port where fetch runs.

{app}.fly.dev does not answer over IPv4 until the App has an IpAssignment. Allocate a shared Anycast IPv4 on the same App and yield it next to the Service.

export const PublicIp = Fly.IpAssignment("Shared", {
app: Site,
type: "shared_v4",
});
Effect.gen(function* () {
const api = yield* Api;
const ip = yield* PublicIp;
return { url: api.url, ip: ip.ip };
});

count is how many Machines to keep running. Default is 1. They all publish the same proxy service, so they all sit behind {app}.fly.dev. Fly’s proxy picks one Machine per request. Each replica gets its own Volume from every MountVolume binding.

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, region: "iad", count: 3, port: 3000 },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Yield Config in init. Alchemy reads the value from the env of whoever deploys and writes it onto the Machine. Do not pass env: { ... } on a Service.

Config.redacted("API_KEY") is Redacted<string>. Unwrap with Redacted.value only where you need the raw string.

Alchemy also injects PORT (when port is set) and stack metadata. For a secret Fly should own and inject into every Machine on the App, use Secret.

import * as Config from "effect/Config";
import * as Redacted from "effect/Redacted";
export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, port: 3000 },
Effect.gen(function* () {
const apiKey = yield* Config.redacted("API_KEY");
return {
fetch: Effect.gen(function* () {
const token = Redacted.value(apiKey);
return HttpServerResponse.text("ok");
}),
};
}),
) {}

Bind MountVolume inside init. App and region come from the Service. count: 3 creates three Volumes, one per replica. Provide MountVolumeLive.

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, region: "iad", count: 3, port: 3000 },
Effect.gen(function* () {
const disk = yield* Fly.MountVolume({ path: "/data", sizeGb: 1 });
const fs = yield* FileSystem.FileSystem;
return {
fetch: Effect.gen(function* () {
const text = yield* fs.readFileString(`${disk.path}/hello.txt`);
return HttpServerResponse.text(text);
}),
};
}).pipe(Effect.provide(Fly.MountVolumeLive)),
) {}

guest is CPU kind, CPU count, and memory. Default is shared-cpu, 1 CPU, 256 MB. Set gpuKind and gpus for a GPU. Guest updates in place.

export default class Api extends Fly.Service<Api>()(
"Api",
{
app: Site,
main: import.meta.url,
region: "iad",
port: 3000,
guest: { cpuKind: "shared", cpus: 2, memoryMb: 512 },
},
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Machine names are unique per App. Omit name and Alchemy generates one from the stack, stage, and logical ID.

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, name: "api", port: 3000 },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

handler is the named export to load from main. Default is "default".

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, handler: "api", port: 3000 },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

image is the generated Dockerfile’s FROM. Default is oven/bun:1. It must still run bun. A content-hash change of main updates the Machine in place.

export default class Api extends Fly.Service<Api>()(
"Api",
{
app: Site,
main: import.meta.url,
image: "oven/bun:1.2",
port: 3000,
},
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

services defaults to HTTP 80 + HTTPS 443 toward port. Pass a custom list to change handlers or autostop. Pass [] so Fly does not publish a proxy.

export default class Worker extends Fly.Service<Worker>()(
"Worker",
{ app: Site, main: import.meta.url, region: "iad", services: [] },
Effect.gen(function* () {
return {};
}),
) {}

Omit port and fetch. Pass services: []. Use ServerHost.run for a long-running loop. If the process exits, Fly restarts it.

import { ServerHost } from "alchemy/Server";
export default class Worker extends Fly.Service<Worker>()(
"Worker",
{ app: Site, main: import.meta.url, region: "iad", services: [] },
Effect.gen(function* () {
const host = yield* ServerHost;
yield* host.run(
Effect.gen(function* () {
return yield* Effect.never;
}).pipe(Effect.orDie),
);
}),
) {}

build is Rolldown input / output overrides plus pure-annotation options. Use it when main needs extra entry points or externals.

Externals

export default class Api extends Fly.Service<Api>()(
"Api",
{
app: Site,
main: import.meta.url,
port: 3000,
build: { input: { external: ["sharp"] } },
},
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Install pg unbundled

pg is CommonJS. Rolldown’s interop turns Client into a namespace. Install it into the image so @effect/sql-pg / Drizzle.Postgres load it with Node’s CJS semantics — same build.install as Lambda.

export default class Api extends Fly.Service<Api>()(
"Api",
{
app: Site,
main: import.meta.url,
port: 3000,
build: { install: ["pg"] },
},
Effect.gen(function* () {
const conn = yield* Fly.ConnectPostgres(Db);
const db = yield* Drizzle.Postgres(conn.connectionString);
return {
fetch: Effect.gen(function* () {
const rows = yield* db.execute("select 1 as ok");
return HttpServerResponse.json({ rows });
}),
};
}).pipe(Effect.provide(Fly.ConnectPostgresHttp)),
) {}

Each Service has its own Machines, image, and lifecycle. Point several at the same app.

class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, port: 3000 },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}
class Worker extends Fly.Service<Worker>()(
"Worker",
{ app: Site, main: import.meta.url, services: [] },
Effect.gen(function* () {
return {};
}),
) {}