Skip to content

Part 2: Deploy a Service

In Part 1 you deployed an App. Now you will deploy a Fly.Service: an Effect program running in a Fly Machine. Alchemy bundles it, builds a Docker image, and pushes it to registry.fly.io. No Dockerfile. No fly.toml.

So far the App lives inside the Stack’s generator. To let other files reference it, declare it at module scope instead — a resource declared this way is an Effect you can import and yield* anywhere. Create src/app.ts:

src/app.ts
import * as Fly from "alchemy/Fly";
export const Site = Fly.App("Site");

The logical id is still Site, so Alchemy recognizes it as the same App you deployed in Part 1 — moving declarations between files doesn’t recreate anything.

Import the App and yield it from the Stack:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
import { Site } from "./src/app.ts";
export default Alchemy.Stack(
"MyApp",
{
providers: Fly.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const site = yield* Fly.App("Site");
const site = yield* Site;
return {
appName: site.appName,
url: site.url,
};
}),
);

A Service in Alchemy is a class — it has both an infrastructure definition and a runtime implementation expressed as an Effect. Create src/api.ts with the smallest possible declaration:

src/api.ts
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
import { Site } from "./app.ts";
export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url },
Effect.gen(function* () {
return {};
}),
) {}

The <Api> type argument plus the empty () is a one-time bit of ceremony — it lets TypeScript reason about Api as a typed handle. app: Site says which App this Service runs in (each Service is its own Machine), and main: import.meta.url tells Alchemy this same file is the bundle entrypoint.

Add a fetch field — Alchemy treats anything returned from the Effect.gen block as the runtime API, and fetch specifically is wired to an HTTP server listening on the Service’s port:

src/api.ts
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { Site } from "./app.ts";
export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url },
Effect.gen(function* () {
return {};
return {
fetch: Effect.succeed(HttpServerResponse.text("Hello from Fly!")),
};
}),
) {}

HttpServerResponse is the same effect/unstable/http API used on every other Alchemy runtime — the handler you write here would run unchanged on Cloudflare Workers or AWS Lambda.

Fly Machines live in a region. The default is iad (Ashburn). Set it explicitly so the Volume you add in Part 3 can match:

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url },
{ app: Site, main: import.meta.url, region: "iad" },

Give the Service an explicit port so the Fly proxy knows where to forward:

export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, region: "iad" },
{ app: Site, main: import.meta.url, region: "iad", port: 3000 },

The port is written to the process environment as PORT and used as the Machine’s internal_port. By default Alchemy publishes HTTP 80 and HTTPS 443 on the Fly proxy in front of it.

Add /health so you (and later, Fly checks) can probe the process:

src/api.ts
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
// ...
return {
fetch: Effect.succeed(HttpServerResponse.text("Hello from Fly!")),
fetch: Effect.gen(function* () {
const request = yield* HttpServerRequest;
const url = new URL(request.url, "http://service");
if (url.pathname === "/health") {
return HttpServerResponse.json({ ok: true });
}
return HttpServerResponse.text("Hello from Fly!");
}),
};

The Api class is just a typed identifier — yielding it inside the Stack’s Effect is what registers the resource and starts the deploy:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Fly from "alchemy/Fly";
import * as Effect from "effect/Effect";
import Api from "./src/api.ts";
import { Site } from "./src/app.ts";
export default Alchemy.Stack(
"MyApp",
{
providers: Fly.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const site = yield* Site;
const api = yield* Api;
return {
appName: site.appName,
url: site.url,
url: api.url,
};
}),
);

Yielding Api returns the resolved Service outputs — the Machine id, region, state, and the public url.

{app}.fly.dev over IPv4 needs an address on the App. Add a shared Anycast IPv4 (free) next to the App:

src/app.ts
import * as Fly from "alchemy/Fly";
export const Site = Fly.App("Site");
export const PublicIp = Fly.IpAssignment("Shared", {
app: Site,
type: "shared_v4",
});

type: "shared_v4" is the free Anycast address. Dedicated v4 is billed and may 400 if the org has no IPv4 quota; prefer v6 or shared_v4 unless you need a dedicated address.

Yield the assignment from the Stack so it is part of the graph:

import Api from "./src/api.ts";
import { Site } from "./src/app.ts";
import { PublicIp, Site } from "./src/app.ts";
Effect.gen(function* () {
const site = yield* Site;
yield* PublicIp;
const api = yield* Api;
Terminal window
bun alchemy deploy
Plan: 2 to create

+ Shared (Fly.IpAssignment)
+ Api (Fly.Service)
 Site (Fly.App)

Proceed?
◉ Yes ○ No
 Shared (Fly.IpAssignment) created
 Api (Fly.Service) created
{
  appName: "myapp-site-dev-a1b2c3d4",
  url: "https://myapp-site-dev-a1b2c3d4.fly.dev",
}

A lot just happened:

  1. src/api.ts was bundled with Rolldown into a single ESM file.
  2. Alchemy built a linux/amd64 image from oven/bun:1 and pushed it to registry.fly.io/{app}:{id}-{hash} using an App deploy token.
  3. It created a Machine in iad, waited until started, and pointed the Fly proxy at port 3000.
Terminal window
curl https://myapp-site-dev-a1b2c3d4.fly.dev
# → Hello from Fly!

Edit the greeting in src/api.ts and deploy again:

Plan: 1 to update

~ Api (Fly.Service)

Proceed?
◉ Yes ○ No
 Api (Fly.Service) updated

Alchemy hashes the bundle: if your code didn’t change, the Service is a no-op; if it did, a new image is built, pushed, and the Machine updated in place.

You now have:

  • An HTTP Service running as a Fly Machine in iad
  • A public https://{app}.fly.dev URL via a shared IPv4
  • A code-hash-based update loop — edit, deploy, new image

In Part 3, you’ll attach a Volume and persist data across deploys.