Skip to content

Fly.Service reference

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 node:26-slim), 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).

Service: Fly’s proxy is the load balancer

Section titled “Service: Fly’s proxy is the load balancer”

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.

The generated service includes a TCP check on port. To customize it, provide services and configure each service’s checks property. With rolling updates, reconcile waits for each started replica’s checks before updating the next replica. Missing or non-passing results are polled within deploy.healthTimeout (60 seconds by default), then fail deployment with Fly.ReplicaChecksNotPassing. Later replicas remain unchanged; earlier updates are not rolled back. A single rolling replica can be unavailable. Blue/green checks replacements before retiring the old set, with representative/floor readiness for idle capacity.

export default class Api extends Fly.Service<Api>()(
"Api",
{
app: Site,
main: import.meta.url,
port: 3000,
services: [
{
protocol: "tcp",
internalPort: 3000,
ports: [
{ port: 80, handlers: ["http"], forceHttps: true },
{ port: 443, handlers: ["tls", "http"] },
],
checks: [
{
type: "http",
port: 3000,
method: "GET",
path: "/health",
protocol: "http",
interval: "15s",
timeout: "2s",
gracePeriod: "30s",
},
],
},
],
},
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

{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 provision, including idle capacity. Default is 1. Replicas publish the same proxy service behind {app}.fly.dev; Fly’s proxy picks an available Machine per request. Each replica gets its own Volume from every MountVolume binding; attached volumes require rolling updates.

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 node:26-slim. 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: "node:26",
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 {};
}),
) {}

Opt into healthy replacement Machines instead of in-place updates. The default TCP service check proves the server is listening; supply service HTTP checks when readiness also depends on application state.

export default class Api extends Fly.Service<Api>()(
"Api",
{
app: Site,
main: import.meta.url,
deploy: { strategy: "bluegreen" },
shutdown: { timeout: "30 seconds" },
},
Effect.succeed({ fetch: Effect.succeed(HttpServerResponse.text("ready")) }),
) {}

Keep one Service declaration. The old process retains its own shutdown signal and deadline when the replacement’s policy changes. Managed SIGTERM/SIGINT shutdown drains HTTP while runtime resource finalizers run; shared dependencies remain alive until both settle or the deadline expires. Applications own stop-acquisition barriers, separately scoped jobs, and bounded drain or checkpoint logic using ordinary finalizers, not a new shutdown hook. External servers own their signal handling. An old bootstrap cannot gain handlers retroactively. Volumes are incompatible with blue/green; physical IDs and names change. Service-bound secret versions are floors, not vault snapshots, and native Machine leases are not deployment-wide locks. Leases do not serialize vault writers or every simultaneous first deployment; serialize CI invocations for the same resource.

Stop and suspend autostop policies preserve idle nonrepresentatives while a representative and the required running floor pass readiness. Requested idle policy is restored before old retirement; a new instance needs fresh checks. Suspension is not SIGTERM shutdown and does not run ordinary shutdown finalizers. Replacements do not inherit suspended process memory. See the deployment guide for recovery, idle capacity, application responsibilities, and verification limits.