Services
A Service is an Effect program running
in Fly.io Machines inside its own Fly App.
It gets its own {name}.fly.dev hostname, addresses, logs, and
metrics. Set count to scale it up or down.
Declare a Service
Section titled “Declare a Service”A Service is a class. Props describe the Machine. The Effect is the program that runs on it.
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 {}; }),) {}The Service creates its Fly App. Deleting the Service deletes that App.
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}.
Serve HTTP with fetch
Section titled “Serve HTTP with fetch”Return fetch from the constructor Effect to boot an HTTP server.
import * as Fly from "alchemy/Fly";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/http/HttpServerResponse";
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { return {}; return { fetch: Effect.succeed(HttpServerResponse.text("hello")), }; }),) {}Omit fetch for a background service.
Pin a region
Section titled “Pin a region”Fly Machines live in a region. The default
is iad. Pass a list, such as region: ["iad", "lhr"], to run
count Machines in each region behind one hostname; see
Several regions, one hostname.
Changing region updates the Service in place.
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url }, { main: import.meta.url, region: "iad" },Set the port
Section titled “Set the port”port is the port the process listens on inside the Machine.
Alchemy writes it to PORT. Default 3000.
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, region: "iad" }, { main: import.meta.url, region: "iad", port: 3000 },The public URL
Section titled “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).
Every published port
Section titled “Every published port”url is one endpoint. A Service that publishes several ports has one
endpoint per port at the same hostname. endpoints lists them with
the 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 is https://{appName}.fly.dev. api.endpoints has three
entries: 443 and 8443 with https:// URLs, and 7000 with no URL,
because raw TCP has no HTTP address.
A stable hostname
Section titled “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, region: "iad", port: 3000 }, { main: import.meta.url, name: "my-api", region: "iad", port: 3000 },Changing name replaces the Service. Fly cannot rename an App, so
Alchemy creates the Service under the new name, then deletes the old
App.
Private Services
Section titled “Private Services”public: false keeps a Service off the internet. It gets only a
free Flycast address
and publishes plain HTTP on port 80, because Fly issues no
certificate for .flycast. url is undefined.
export default class Users extends Fly.Service<Users>()( "Users", { main: import.meta.url, public: false }, Effect.gen(function* () { return { list: () => Effect.succeed([{ id: "u1", name: "Ada" }]), }; }),) {}Other Services call it by binding it.
Turning public on or off updates the Service in place; its App and
hostname stay the same.
The private URL
Section titled “The private URL”privateUrl is where bound callers reach a Service over Fly’s private
network:
- A private Service:
http://{appName}.flycast(plain HTTP on 80). - A public Service:
http://{appName}.flycast:7780. Its default ports are an HTTPS redirect (80) and HTTPS (443), so Alchemy publishes an extra plain-HTTP port for bound callers,bindingPort.
bindingPort defaults to 7780. It answers only requests that arrive
over Fly’s private network. Change it when it collides with another
port, for example another Service’s bindingPort in a
shared App:
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, port: 3000 }, { main: import.meta.url, port: 3000, bindingPort: 7781 },A Service that already publishes a plain-HTTP port uses that port
instead. privateUrl is undefined when the Service publishes
nothing (services: []).
Restrict a Service to the stack’s network
Section titled “Restrict a Service to the stack’s network”Fly’s default private network spans the organization, so every App
in the org can resolve a private Service. network puts the Service’s
App on its own private network instead. Only Apps on the same network
can resolve its privateUrl.
Fly.stackNetwork yields a network name unique to the stack and
stage; yield it inside Effect-valued props:
export default class Users extends Fly.Service<Users>()( "Users", Effect.gen(function* () { return { main: import.meta.url, public: false, network: yield* Fly.stackNetwork, }; }), /* ... */) {}Give every Service in the stack the same network. Changing it
replaces the Service, and it cannot be combined with app.
Connect Services walks through a
private backend behind a public gateway.
Call one Service from another
Section titled “Call one Service from another”Bind a Service with Fly.bindService in the caller’s program. The
client has the methods the target returns (everything besides fetch
and run), typed from its program:
import Users from "./users.ts";
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), }; }),) {}Binding makes Gateway deploy after Users and hands it the address
and caller token of Users. Only Services that bind Users receive
its token, so only they can call its methods. The caller must be on
the same network as the target; otherwise its deploy fails with
Fly.ServiceUnreachable.
users.fetch(HttpClientRequest.get("/path")) sends a request to the
target’s fetch routes. Fly.bindEndpoint(Users, { port: 9000 })
binds one other published port and returns its host, port, url,
and an HTTP client.
Connect Services walks through
several Services on a stack network, the security model, streaming
methods, bindEndpoint, and two Services that bind each other.
Fly’s proxy is the load balancer
Section titled “Fly’s proxy is the load balancer”There is no LoadBalancer resource. Fly runs an Anycast proxy at the
edge. Unless you override services, Alchemy publishes HTTP 80 and
HTTPS 443 on that proxy (plain HTTP 80 for a private Service) and
points them at port inside each Machine.
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.
Scale with count
Section titled “Scale with count”count is how many Machines to provision. Default 1. They all
publish the same proxy service behind {app}.fly.dev. Fly’s proxy
picks an available Machine per request. With autostop: "stop" or
"suspend", some provisioned replicas can be idle; count is not a
promise that every process is continuously running. Use autostart
and minMachinesRunning for Fly’s wake-up and minimum-capacity behavior.
See idle capacity during replacement.
Each replica gets its own Volume from every
MountVolume binding. Volume-backed Services use
rolling updates; blue/green does not clone or share those volumes.
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, region: "iad", port: 3000 }, { main: import.meta.url, region: "iad", count: 3, port: 3000 },What a deploy does
Section titled “What a deploy does”Docker must be running.
Alchemy creates the Service’s App and its addresses if they are
missing, then bundles main with Rolldown. If the hash matches the
last deploy, it skips build and push. Otherwise it builds
linux/amd64 from image (default node:26-slim) and pushes to
registry.fly.io/{app}:{id}-{hash}. With the default rolling policy,
it creates or updates replicas sequentially. For each Machine it starts,
reconcile waits for configured readiness checks before proceeding.
By default, changed code produces a new image and an in-place Machine update.
Unchanged desired inputs are a no-op. Opt into deploy: { strategy: "bluegreen" } to
prepare healthy replacements before retiring the old generation.
Keep one Service declaration. Alchemy manages both generations and uses the
old Machine’s shutdown policy when retiring it. The guide explains readiness,
traffic overlap, Effect finalizers, idle capacity, and recovery. Raw images and
external servers must handle their own shutdown.
Override the base image with image (must still run Node). Pass
services: [] for a process that should not be published.
Config
Section titled “Config”Yield Config in the constructor. Alchemy reads the value from the env of
whoever deploys and writes it onto the Machine.
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); // ... }), }; }),) {}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,
use Fly.Secret and run the Service in that App
(Group Services in one App).
Service-bound secret preparation establishes a required version floor for replacement Machines, not a snapshot of the App vault. Standalone secrets, other Services, and runtime writers are not serialized by Machine leases. An out-of-band secret update alone does not guarantee a new rollout; use an explicit desired-input change when rotation requires replacement. See rotating secrets with your application.
Background services
Section titled “Background services”Omit fetch and pass services: [] so Fly does not publish a proxy.
Return a run Effect for a long-running loop:
import * as Fly from "alchemy/Fly";import * as Effect from "effect/Effect";
export default class Worker extends Fly.Service<Worker>()( "Worker", { main: import.meta.url, region: "iad", services: [] }, Effect.succeed({ run: Effect.never }),) {}Effect.never above only keeps the example alive. Acquire queue connections
and start real consumers inside run, not during the outer initialization
Effect that also participates in planning. You can return both fetch and
run for mixed HTTP and background work.
For blue/green, a private worker also needs a named readiness check and a real
server to answer it. A run-only program does not create a readiness endpoint.
Cordoning controls proxy traffic, not job acquisition; both generations can
consume work before promotion.
With blue/green or explicit shutdown, the managed bootstrap initiates runtime
cleanup on SIGTERM/SIGINT while HTTP requests and shared dependencies remain
alive. The application owns its stop-acquisition barrier and bounded job drain.
Use ordinary finalizers and a separately owned work scope for jobs that must
survive worker-loop interruption; no new public shutdown hook is required.
See the queue worker cleanup example.
Fly’s restart policy controls what happens when the process exits. Suspension is not ordinary process shutdown, so it is not a promise that finalizers run.
Group Services in one App
Section titled “Group Services in one App”Pass app to run a Service inside an existing
Fly.App instead of its own. The Services share
the App’s hostname, addresses, and secrets. Each Service still has its
own Machines, image, env, and lifecycle. Changing app, or adding or
removing it, replaces the Service.
export const Site = Fly.App("Site");
class Api extends Fly.Service<Api>()( "Api", { app: Site, main: import.meta.url, port: 3000 }, /* HTTP */) {}
class Worker extends Fly.Service<Worker>()( "Worker", { app: Site, main: import.meta.url, services: [] }, /* background loop */) {}Group Services when they need something that attaches to a Fly.App:
- a
Fly.SecretorSecretKeyshared by several Services - a custom domain via
Fly.Certificate - Fly-native control of the App (
flyCLI, dashboard settings)
Alchemy manages no public addresses for a Service in a shared App.
Allocate an IpAssignment
on the App. url and endpoints follow each Service’s own ports, so
Services in one App tell themselves apart by port: an API on 443 has
url https://{appName}.fly.dev, and an admin UI on 8443 has
https://{appName}.fly.dev:8443. public and network do not apply
(combining them with app fails with Fly.InvalidServiceProps).
Ports in a shared App
Section titled “Ports in a shared App”Fly’s proxy routes an App’s traffic by port only, so each Service in
the App needs its own ports. When two Services publish the same port
and protocol, the App fails with Fly.ServicePortConflict before any
Machine is created: at plan time when the App already exists,
otherwise when the App is reconciled. Each Service’s deploy also checks
the Machines already running in the App, including ones created
outside the stack.
class Api extends Fly.Service<Api>()( "Api", { app: Site, main: import.meta.url, port: 3000 }, /* HTTPS on 443 */) {}
class Admin extends Fly.Service<Admin>()( "Admin", { app: Site, main: import.meta.url, port: 3000, bindingPort: 7781, services: [ { protocol: "tcp", internalPort: 3000, ports: [{ port: 8443, handlers: ["tls", "http"] }] }, ], }, /* HTTPS on 8443 */) {}The count includes bindingPort: two Services in one App need
distinct bindingPorts. A background Service with services: []
publishes nothing and never conflicts.
Bind a Service in a shared App
Section titled “Bind a Service in a shared App”A Service in a shared App is bindable too. Its deploy adds a Flycast
address to the App, and its privateUrl is
http://{appName}.flycast on its own plain-HTTP port or its
bindingPort. Callers must be on the App’s network. A Service in a
shared App cannot be part of a
binding cycle;
only Services that own their App can bind each other.
Machine logs live in the Fly dashboard and fly logs -a {appName}.
alchemy logs (including --tail) doesn’t support Fly Services yet.
Where next
Section titled “Where next”The tutorial builds a Service step by step.
Connect Services binds several
Services to each other on a private network.
Networking covers public and private Services.
Sprites are org-scoped sandboxes that
hibernate — no App, no Docker image.
Regions lists codes and how Volumes follow.
Volumes covers MountVolume.
Postgres binds with ConnectPostgres.
Redis binds with ReadWriteRedis.
Tigris binds with PutObject / GetObject.
Secrets covers Config.Redacted and Fly.Secret.
The Service reference lists every prop.