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.
Move the App to a module
Section titled “Move the App to a module”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:
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.
Update the Stack
Section titled “Update the Stack”Import the App and yield it from the Stack:
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, }; }),);Create the Service file
Section titled “Create the Service file”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:
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.
Serve HTTP with fetch
Section titled “Serve HTTP with fetch”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:
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.
Pin a region
Section titled “Pin a region”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" },Set the port
Section titled “Set the port”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 a health route
Section titled “Add a health route”Add /health so you (and later, Fly checks) can probe the process:
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!"); }), };Wire the Service into the Stack
Section titled “Wire the Service into the Stack”The Api class is just a typed identifier — yielding it inside the
Stack’s Effect is what registers the resource and starts the deploy:
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.
Allocate a shared IPv4
Section titled “Allocate a shared IPv4”{app}.fly.dev over IPv4 needs an address on the App. Add a shared
Anycast IPv4 (free) next to the App:
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.
Register the address in the Stack
Section titled “Register the address in the Stack”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;Deploy
Section titled “Deploy”bun alchemy deploynpm run alchemy deploypnpm alchemy deployyarn alchemy deployPlan: 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:
src/api.tswas bundled with Rolldown into a single ESM file.- Alchemy built a
linux/amd64image fromoven/bun:1and pushed it toregistry.fly.io/{app}:{id}-{hash}using an App deploy token. - It created a Machine in
iad, waited untilstarted, and pointed the Fly proxy at port 3000.
Try it out
Section titled “Try it out”curl https://myapp-site-dev-a1b2c3d4.fly.dev# → Hello from Fly!Ship a change
Section titled “Ship a change”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.devURL 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.