Skip to content

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.

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

src/api.ts
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}.

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.

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" },

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 },

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).

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.

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.

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.

src/users.ts
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.

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.

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:

src/gateway.ts
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.

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.

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 },

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.

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.

Omit fetch and pass services: [] so Fly does not publish a proxy. Return a run Effect for a long-running loop:

src/worker.ts
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.

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.Secret or SecretKey shared by several Services
  • a custom domain via Fly.Certificate
  • Fly-native control of the App (fly CLI, 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).

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.

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.

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.