Connect Services
Each Service is its own Fly App. One Service
calls another by binding it: Fly.bindService(Users) returns a
typed client for the methods Users returns, and the calls travel over
Fly’s private network to Users’s privateUrl.
Two things decide who can call a Service. The first is the network. Every App in the organization joins Fly’s default private network unless told otherwise. Set network: yield* Fly.stackNetwork on each Service to put a stack’s Services on a network of their own, unique to the stack and stage, which Apps on any other network can’t resolve. The second is the binding. Fly signs every private request with the calling App’s name, and a Service answers method calls only from the Apps that bind it.
- binds
- Users, Orders
- binds
- Users
- binds
- nothing
Take the
fly-microservices example:
a private Users Service, a private Orders Service that calls
Users, and a public Gateway that calls both.
Create the Users Service
Section titled “Create the Users Service”Users returns two methods, list and get. It returns no fetch;
bound callers call the methods directly.
import * as Fly from "@alchemy.run/fly";import * as Effect from "effect/Effect";
export interface User { id: string; name: string;}
export const USERS: User[] = [ { id: "u1", name: "Ada" }, { id: "u2", name: "Grace" },];
export default class Users extends Fly.Service<Users>()( "Users", Effect.gen(function* () { return { 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)), }; }),) {}public: false gives the Service’s App a private Flycast address and
no internet address. The props are an Effect so the next step can
yield* inside them.
Put Users on the stack’s network
Section titled “Put Users on the stack’s network”Add network so only this stage’s Services can reach Users:
Effect.gen(function* () { return { main: import.meta.url, public: false, network: yield* Fly.stackNetwork, };}),Fly.stackNetwork yields a network name built from the stack and
stage, such as flymicroservices-dev. Fly creates the network with the
first App that names it.
Create the Orders Service
Section titled “Create the Orders Service”Orders is a second private Service on the same network:
import * as Fly from "@alchemy.run/fly";import * as Effect from "effect/Effect";
const ORDERS = [ { id: "o1", userId: "u1", item: "keyboard" }, { id: "o2", userId: "u2", item: "monitor" },];
export default class Orders extends Fly.Service<Orders>()( "Orders", Effect.gen(function* () { return { main: import.meta.url, public: false, network: yield* Fly.stackNetwork, }; }), Effect.gen(function* () { return {}; }),) {}Bind Users from Orders
Section titled “Bind Users from Orders”Bind Users in the Orders program:
import Users from "./users.ts";// ...Effect.gen(function* () { const users = yield* Fly.bindService(Users); return {};}),users is a typed client with list and get. Binding also makes
Orders deploy after Users, hands Orders the address of Users,
and adds Orders to the Apps Users answers.
Call Users from Orders
Section titled “Call Users from Orders”Return a list method that looks up each order’s user:
Effect.gen(function* () { const users = yield* Fly.bindService(Users); return {}; return { list: () => Effect.forEach(ORDERS, (order) => users .get(order.userId) .pipe(Effect.map((user) => ({ ...order, user }))), ), };}),users.get(order.userId) is a call over the private network. The
result is typed as User | undefined, the return type of get in
Users.
Create the public Gateway
Section titled “Create the public Gateway”Gateway joins the same network and leaves out public: false, so it
is public:
import * as Fly from "@alchemy.run/fly";import * as Effect from "effect/Effect";
export default class Gateway extends Fly.Service<Gateway>()( "Gateway", Effect.gen(function* () { return { main: import.meta.url, network: yield* Fly.stackNetwork, }; }), Effect.gen(function* () { return {}; }),) {}A public Service on the network still serves url, so Gateway is
the stack’s single entry point from the internet.
Bind Users and Orders from the Gateway
Section titled “Bind Users and Orders from the Gateway”import Orders from "./orders.ts";import Users from "./users.ts";// ...Effect.gen(function* () { const users = yield* Fly.bindService(Users); const orders = yield* Fly.bindService(Orders); return {};}),orders exposes the list method Orders returns.
Serve routes from the Gateway
Section titled “Serve routes from the Gateway”Call the bound methods from fetch:
import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";// ...Effect.gen(function* () { const users = yield* Fly.bindService(Users); const orders = yield* Fly.bindService(Orders); return {}; return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const path = new URL(request.url, "http://gateway").pathname; if (path === "/users") { return yield* HttpServerResponse.json(yield* users.list()); } if (path === "/orders") { return yield* HttpServerResponse.json(yield* orders.list()); } return HttpServerResponse.text("try /users or /orders"); }).pipe(Effect.orDie), };}),Wire the Stack
Section titled “Wire the Stack”Yield the three Services and return their URLs:
import * as Alchemy from "alchemy";import * as Fly from "@alchemy.run/fly";import * as Effect from "effect/Effect";import Gateway from "./src/gateway.ts";import Orders from "./src/orders.ts";import Users from "./src/users.ts";
export default Alchemy.Stack( "FlyMicroservices", { providers: Fly.providers(), state: Alchemy.localState() }, Effect.gen(function* () { const users = yield* Users; const orders = yield* Orders; const gateway = yield* Gateway;
return { url: gateway.url, network: gateway.network, usersPrivateUrl: users.privateUrl, ordersPrivateUrl: orders.privateUrl, }; }),);Deploy
Section titled “Deploy”alchemy deployAlchemy deploys Users, then Orders, then Gateway, and prints the
outputs:
url: "https://flymicroservices-gateway-dev-a1b2c3d4.fly.dev"network: "flymicroservices-dev"usersPrivateUrl: "http://flymicroservices-users-dev-e5f6a7b8.flycast"ordersPrivateUrl: "http://flymicroservices-orders-dev-c9d0e1f2.flycast"Try it out
Section titled “Try it out”curl https://flymicroservices-gateway-dev-a1b2c3d4.fly.dev/users# → [{"id":"u1","name":"Ada"},{"id":"u2","name":"Grace"}]
curl https://flymicroservices-gateway-dev-a1b2c3d4.fly.dev/orders# → [{"id":"o1","userId":"u1","item":"keyboard","user":{"id":"u1","name":"Ada"}}, ...]The second request crosses two private calls: Gateway → Orders → Users.
How deploy order works
Section titled “How deploy order works”Fly.bindService(Users) makes the caller depend on Users. Orders
binds Users, and Gateway binds both, so Alchemy deploys Users,
then Orders, then Gateway. The caller’s deploy writes the bound
Service’s private address onto the caller’s Machines, and the bound
Service’s deploy records the caller’s App as allowed to call it.
The order of yield* in the Stack does not matter. The bindings
decide it.
Public url and private privateUrl
Section titled “Public url and private privateUrl”| Service | url |
privateUrl |
|---|---|---|
Users |
undefined |
http://{users-app}.flycast |
Orders |
undefined |
http://{orders-app}.flycast |
Gateway |
https://{gateway-app}.fly.dev |
http://{gateway-app}.flycast:7780 |
url is the internet endpoint and is set only for a public Service.
privateUrl is where bound callers reach the Service. A private
Service serves plain HTTP on port 80, so its privateUrl has no port.
A public Service’s default ports are an HTTPS redirect (80) and HTTPS
(443), so Alchemy publishes an extra plain-HTTP port for bound callers,
bindingPort (default 7780).
You rarely use privateUrl directly. Fly.bindService resolves it
for you.
Security model
Section titled “Security model”A method call from Orders to Users is accepted only when two checks pass. Neither uses a secret.
The caller must be on the same network. Users is on the stack’s network, so only Apps on that network can resolve {users-app}.flycast. Binding a Service on another network fails the caller’s deploy with Fly.ServiceUnreachable, before the caller’s App is created.
The caller must be an App that binds the Service, named by a valid Fly-Src signature. Fly’s proxy signs every request that arrives over Flycast with an ed25519 Fly-Src header naming the calling App and organization, and the time it was sent. Users checks the signature with Fly’s public key, that the caller is in the same organization, that the header is under 30 seconds old, and that the App is one that binds Users. A Service on the same network that didn’t bind Users gets 401 from its methods. A request from the internet carries no valid signature, so a public Service’s methods answer 401 to it, and the extra bindingPort answers only private-network requests.
The same signed identity lets a Service reach AWS and GCP without a key. See Federated identity.
Fly issues no certificate for .flycast, so calls are plain HTTP.
They travel inside Fly’s private network, which Fly encrypts with
WireGuard.
What an outside App sees
Section titled “What an outside App sees”An App that is not on the stack’s network cannot resolve the private names. From a Machine in an App on the org’s default network:
wget -qO- http://flymicroservices-users-dev-e5f6a7b8.flycast# wget: bad address 'flymicroservices-users-dev-e5f6a7b8.flycast'
wget -qO- http://flymicroservices-users-dev-e5f6a7b8.internal# wget: bad address 'flymicroservices-users-dev-e5f6a7b8.internal'Other stages of the same stack get their own Fly.stackNetwork, so
dev and prod Services cannot call each other either.
Call HTTP routes with client.fetch
Section titled “Call HTTP routes with client.fetch”A bound client also forwards requests to the Service’s fetch
handler. A relative URL resolves against the Service’s privateUrl:
import * as HttpClientRequest from "effect/http/HttpClientRequest";
const users = yield* Fly.bindService(Users);const response = yield* users.fetch(HttpClientRequest.get("/hello"));const text = yield* response.text;Use it for a Service that serves HTTP routes, such as a framework
server. Its fetch routes don’t check the caller; see the
caution above.
Stream results from a method
Section titled “Stream results from a method”A method can return a Stream. The bound client returns the same
Stream, delivered element by element:
// in UsersstreamAll: () => Stream.fromIterable(USERS),
// in the callerconst all = yield* users.streamAll().pipe(Stream.runCollect);Bind one port with Fly.bindEndpoint
Section titled “Bind one port with Fly.bindEndpoint”Fly.bindEndpoint binds a single published port of a Service, for a
port other than its main HTTP port. Publish the port on the target.
Here both ports forward to the Service’s fetch on 3000, which reads
Fly’s fly-forwarded-port header to tell them apart:
services: [ { protocol: "tcp", internalPort: 3000, ports: [{ port: 80, handlers: ["http"] }] }, { protocol: "tcp", internalPort: 3000, ports: [{ port: 9000, handlers: ["http"] }] },],Then bind it from the caller:
const admin = yield* Fly.bindEndpoint(Users, { port: 9000 });const stats = yield* admin.client.get("/stats");admin.client is an HttpClient whose relative URLs go to
http://{users-app}.flycast:9000. For a raw TCP port, open your own
connection to yield* admin.host ({users-app}.flycast) and
admin.port. yield* admin.url is the full plain-HTTP URL.
bindEndpoint adds no caller check. It relies on the network and
whatever the port itself checks.
Two Services that bind each other
Section titled “Two Services that bind each other”A class can only bind a Service that already exists as a value. For two Services that bind each other, declare each as a tag class with the shape of its methods:
interface Named { name: () => Effect.Effect<string>;}
export class Ping extends Fly.Service<Ping, Named>()("Ping") {}export class Pong extends Fly.Service<Pong, Named>()("Pong") {}Implement each in its own file with .make, binding the other. The
file’s default export is the implementation the Machine runs:
import { Ping, Pong } from "./services.ts";
export default Ping.make( { main: import.meta.url }, Effect.gen(function* () { const pong = yield* Fly.bindService(Pong); return { name: () => Effect.succeed("ping"), fetch: Effect.gen(function* () { return HttpServerResponse.text(`ping hears ${yield* pong.name()}`); }).pipe(Effect.orDie), }; }),);src/pong.ts is the same with the roles swapped.
Provide both implementations to the Stack’s Effect:
import PingLive from "./src/ping.ts";import PongLive from "./src/pong.ts";
Effect.gen(function* () { const ping = yield* Ping; const pong = yield* Pong; return { ping: ping.url, pong: pong.url };}).pipe(Effect.provide(Layer.mergeAll(PingLive, PongLive)));Alchemy creates a stub of each Service first, then wires the bindings.
Only Services that own their App can be part of such a cycle. A
Service placed in a shared App with app cannot.
Errors
Section titled “Errors”| Error | When | Fix |
|---|---|---|
Fly.ServiceUnreachable |
The caller and the bound Service are on different networks. Fails before the caller’s App is created. | Give both the same network, usually Fly.stackNetwork. |
Fly.ServiceNotBindable |
The bound Service publishes no ports (services: []), so it has no private address. |
Publish a port, or stop binding it. |
Fly.EndpointNotPublished |
Fly.bindEndpoint names a port the Service does not publish. |
Add the port to the Service’s services, or bind a published port. |
Where next
Section titled “Where next”- Services — private Services,
bindingPort, and grouping Services in one App. - IPs & certificates — public and private Services, and addresses on your own App.
- Blue/green deployments — replace a Service without dropping calls from its callers.
- The fly-microservices example
and the
Servicereference.