Fly.Service reference
bindService
Section titled “bindService”Source:
src/Fly/BindService.ts
Bind another Service and get a typed client for it. The client
calls the Service’s methods (everything its program returns besides
fetch and run) and forwards fetch requests, over Fly’s private
network.
Binding makes the caller deploy after the Service and hands it the
Service’s private address and caller token. Only callers that bind a
Service receive its token, so other Services on the same network cannot
call its methods. Every call must also carry Fly’s signed Fly-Src
header from the same organization, so the public internet cannot call
them. Calls are plain HTTP inside Fly’s WireGuard-encrypted network.
bindService: Call a private Service
Section titled “bindService: Call a private Service”Methods on the target
export default class Users extends Fly.Service<Users>()( "Users", { main: import.meta.url, public: false }, Effect.gen(function* () { return { list: () => Effect.succeed(USERS), get: (id: string) => Effect.succeed(USERS.find((user) => user.id === id)), }; }),) {}Bind it and call a method
export default class Orders extends Fly.Service<Orders>()( "Orders", { main: import.meta.url, public: false }, Effect.gen(function* () { const users = yield* Fly.bindService(Users); return { list: () => Effect.forEach(ORDERS, (order) => users .get(order.userId) .pipe(Effect.map((user) => ({ ...order, user }))), ), }; }),) {}bindService: Stream and forward HTTP
Section titled “bindService: Stream and forward HTTP”A method that returns a Stream
const all = yield* users.streamAll().pipe(Stream.runCollect);Send a request to the Service’s fetch
const response = yield* users.fetch(HttpClientRequest.get("/users/u1"));bindService: Bind one port
Section titled “bindService: Bind one port”bindEndpoint targets one published port, such as an admin API
or a raw TCP protocol. Deploy fails with Fly.EndpointNotPublished
when the Service does not publish it.
const admin = yield* Fly.bindEndpoint(Users, { port: 9000 });const stats = yield* admin.client.get("/stats");const host = yield* admin.host; // "{appName}.flycast"bindService: Two Services that bind each other
Section titled “bindService: Two Services that bind each other”Declare each as a tag class with its method shape, and implement each
in its own file as the default export of .make.
export class Ping extends Fly.Service<Ping, Named>()("Ping") {}export class Pong extends Fly.Service<Pong, Named>()("Pong") {}
// src/ping.tsexport default Ping.make( { main: import.meta.url }, Effect.gen(function* () { const pong = yield* Fly.bindService(Pong); return { name: () => Effect.succeed("ping") }; }),);bindService: Errors
Section titled “bindService: Errors”Each fails the caller’s deploy before the caller is created:
Fly.ServiceUnreachable when the caller and the Service are on
different networks, Fly.ServiceNotBindable when the Service publishes
no ports, and Fly.EndpointNotPublished for a port it does not publish.
Service
Section titled “Service”Source:
src/Fly/Service.ts
A Service is an Effect program running on Fly.io Machines in its own
Fly App. It gets its own {name}.fly.dev hostname, addresses, logs,
and metrics, the way each Cloudflare Worker is its own endpoint. Set
count to scale it up or down.
Service: Declare a Service
Section titled “Service: Declare a Service”A Service is a class. Props describe the Machine. The Effect is the program that runs on it.
The Service creates its Fly App, and deleting the Service deletes it.
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}.
import * as Fly from "alchemy/Fly";import * as Effect from "effect/Effect";
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { return {}; }),) {}Service: Serve HTTP with fetch
Section titled “Service: Serve HTTP with fetch”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", { main: import.meta.url }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Service: Pin a region
Section titled “Service: Pin a region”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", { main: import.meta.url, region: "iad" }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Service: Run in several regions
Section titled “Service: Run in several regions”Pass a list to run the same program in several regions behind one
hostname. count Machines run in each region, and Fly’s proxy sends
each request to the nearest healthy one. regions on the Service
lists where it runs.
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, region: ["iad", "lhr"], count: 2 }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Adding or removing a region updates the Service in place and keeps
its App and hostname. Machines already in a kept region stay; a
removed region’s Machines are deleted. Each Machine’s Volume from
MountVolume is created in that Machine’s region.
Service: Set the port
Section titled “Service: Set the port”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", { main: import.meta.url, region: "iad", port: 3000 }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Service: The public URL
Section titled “Service: The public URL”A Service is public by default. Alchemy allocates a shared IPv4 and
an IPv6 on its App (both free) and api.url is
https://{appName}.fly.dev.
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: Every published port
Section titled “Service: Every published port”url is one endpoint. A Service that publishes several ports has one
endpoint per port at the same hostname, listed in endpoints with
its port, handlers, and a URL when the port speaks HTTP. Ports that
only redirect to HTTPS are left out.
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, services: [ { internalPort: 3000, ports: [ { port: 80, handlers: ["http"], forceHttps: true }, { port: 443, handlers: ["tls", "http"] }, ], }, { internalPort: 9000, ports: [{ port: 8443, handlers: ["tls", "http"] }] }, { internalPort: 7000, ports: [{ port: 7000 }] }, ], }, Effect.gen(function* () { return {}; }),) {}
api.url; // "https://{appName}.fly.dev"api.endpoints.map((endpoint) => endpoint.url);// ["https://{appName}.fly.dev", "https://{appName}.fly.dev:8443", undefined]Service: Private Services
Section titled “Service: Private Services”public: false keeps a Service off the internet. It gets only a
Flycast address and publishes plain HTTP on port 80 (Fly issues no
certificate for .flycast). url is undefined, and privateUrl is
http://{appName}.flycast. Calls go through Fly’s proxy, so service
checks, autostart, and blue/green cutover still apply.
Turning public on or off updates the Service in place. Its App and
hostname stay the same.
Service: Call another Service
Section titled “Service: Call another Service”A Service can return methods next to fetch. Another Service calls
them by binding it with bindService, which returns a typed
client. Binding makes the caller deploy after the Service, and only
callers that bind a Service receive its caller token, so only they
can call its methods.
A private Service with methods
export default class Users extends Fly.Service<Users>()( "Users", { main: import.meta.url, public: false }, Effect.gen(function* () { return { list: () => Effect.succeed(USERS), get: (id: string) => Effect.succeed(USERS.find((user) => user.id === id)), }; }),) {}Bind it from another Service
export default class Gateway extends Fly.Service<Gateway>()( "Gateway", { main: import.meta.url }, Effect.gen(function* () { const users = yield* Fly.bindService(Users); return { fetch: Effect.gen(function* () { return yield* HttpServerResponse.json(yield* users.list()); }).pipe(Effect.orDie), }; }),) {}Calls use plain HTTP to privateUrl inside Fly’s WireGuard-encrypted
private network. The Service accepts a method call only with its
caller token and a Fly-Src signature from Fly’s proxy for the same
organization, so the public internet can never call it. A public
Service publishes an extra plain-HTTP port for bound callers,
bindingPort (default 7780), since its own ports are an HTTPS
redirect and HTTPS. client.fetch sends a request to the Service’s
fetch routes, and bindEndpoint targets one published port.
Two Services can bind each other; declare them as tag classes and
provide their .make layers (see the
Connect Services guide).
Service: Isolate Services on a private network
Section titled “Service: Isolate Services on a private network”Fly’s default private network spans the whole organization, so any
App in it can reach a private Service’s address. Put a stack’s
Services on their own network with network. stackNetwork
names one per stack and stage. Apps on other networks, including the
default one, cannot resolve them, and binding a Service on another
network fails the caller’s deploy with Fly.ServiceUnreachable
before anything is created. A public Service on the network still
serves url, which makes it the stack’s single entry point.
export class Users extends Fly.Service<Users>()( "Users", Effect.gen(function* () { return { main: import.meta.url, public: false, network: yield* Fly.stackNetwork, }; }), Effect.gen(function* () { return { list: () => Effect.succeed(USERS) }; }),) {}
export class Gateway extends Fly.Service<Gateway>()( "Gateway", Effect.gen(function* () { return { main: import.meta.url, network: yield* Fly.stackNetwork }; }), Effect.gen(function* () { const users = yield* Fly.bindService(Users); return { fetch: Effect.gen(function* () { return yield* HttpServerResponse.json(yield* users.list()); }).pipe(Effect.orDie), }; }),) {}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", { 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.
Service: Configure routing health checks
Section titled “Service: Configure routing health checks”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", { 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")), }; }),) {}Service: Scale with count
Section titled “Service: Scale with count”count is how many Machines to provision, including idle capacity.
Default is 1. Replicas publish the same proxy service behind
{appName}.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", { main: import.meta.url, region: "iad", count: 3, port: 3000 }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Service: Config
Section titled “Service: Config”Yield Config in init. Alchemy reads the value from the env of
whoever deploys and writes it onto the Machine, so there is no need
to copy it into env.
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 an App,
run the Service in an App (app) and use Secret.
import * as Config from "effect/Config";import * as Redacted from "effect/Redacted";
export default class Api extends Fly.Service<Api>()( "Api", { 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"); }), }; }),) {}Service: Mount a disk
Section titled “Service: Mount a disk”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", { 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)),) {}Service: Guest size
Section titled “Service: Guest size”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", { 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")), }; }),) {}Service: A stable hostname
Section titled “Service: A stable hostname”name names the Service’s App, so it is the {name}.fly.dev
hostname. App names are globally unique across Fly. Omit name and
Alchemy generates one from the stack, stage, and logical ID.
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, name: "api", port: 3000 }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Service: Named export
Section titled “Service: Named export”handler is the named export to load from main. Default is
"default".
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, handler: "api", port: 3000 }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Service: Base image
Section titled “Service: Base image”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", { main: import.meta.url, image: "node:26", port: 3000, }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Service: Custom proxy services
Section titled “Service: Custom proxy services”services defaults to HTTP 80 (redirecting to HTTPS) + HTTPS 443
toward port, or plain HTTP 80 for a private Service. 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", { main: import.meta.url, region: "iad", services: [] }, Effect.gen(function* () { return {}; }),) {}Service: Background services
Section titled “Service: Background services”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", { 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), ); }),) {}Service: Bundle config
Section titled “Service: Bundle config”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", { 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", { 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)),) {}Service: Group Services in one App
Section titled “Service: Group Services in one App”Pass app to run a Service inside an existing App instead of
its own. The Services share the App’s hostname, addresses, and
Secrets, and a Certificate on the App covers them.
Alchemy manages no addresses for a Service in a shared App: allocate
an IpAssignment on the App. url and endpoints follow the
Service’s own ports, so Services in one App tell themselves apart by
port (https://{appName}.fly.dev and https://{appName}.fly.dev:8443).
public does not apply.
export const Site = Fly.App("Site");
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 {}; }),) {}Fly’s proxy routes an App’s traffic by port only, so each Service in
the App needs its own ports, including its own bindingPort when
Alchemy adds one. Two Services on the same port and protocol fail with
Fly.ServicePortConflict before any Machine is created: at plan time
when the App already exists, otherwise when the App deploys. Grouped
Services can be bound like any other; they cannot bind each other in
a cycle.
Service: Blue/green deployments
Section titled “Service: Blue/green deployments”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", { 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.