Skip to content

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.

Take the fly-microservices example: a private Users Service, a private Orders Service that calls Users, and a public Gateway that calls both.

Users returns two methods, list and get. It returns no fetch; bound callers call the methods directly.

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

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.

Orders is a second private Service on the same network:

src/orders.ts
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 in the Orders program:

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

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.

Gateway joins the same network and leaves out public: false, so it is public:

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

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

Call the bound methods from fetch:

src/gateway.ts
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),
};
}),

Yield the three Services and return their URLs:

alchemy.run.ts
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,
};
}),
);
Terminal window
alchemy deploy

Alchemy 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"
Terminal window
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.

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.

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.

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.

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:

Terminal window
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.

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.

A method can return a Stream. The bound client returns the same Stream, delivered element by element:

// in Users
streamAll: () => Stream.fromIterable(USERS),
// in the caller
const all = yield* users.streamAll().pipe(Stream.runCollect);

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.

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:

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

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

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.