Skip to content

Part 2: Deploy a Service

In Part 1 you deployed a Project. Now you will deploy a Railway.Service: an Effect program running in a Railway container. Alchemy bundles it, generates a Dockerfile, and uploads the context. Railway builds the image.

So far the Project 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/project.ts:

src/project.ts
import * as Railway from "alchemy/Railway";
export const Site = Railway.Project("Site");

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

Import the Project and yield it from the Stack:

alchemy.run.ts
import * as Alchemy from "alchemy";
import * as Railway from "alchemy/Railway";
import * as Effect from "effect/Effect";
import { Site } from "./src/project.ts";
export default Alchemy.Stack(
"MyApp",
{
providers: Railway.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const site = yield* Railway.Project("Site");
const site = yield* Site;
return {
name: site.name,
projectId: site.projectId,
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 Railway from "alchemy/Railway";
import * as Effect from "effect/Effect";
import { Site } from "./project.ts";
export default class Api extends Railway.Service<Api>()(
"Api",
{
project: 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. project: Site says which Project this Service runs in, 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 Railway from "alchemy/Railway";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { Site } from "./project.ts";
export default class Api extends Railway.Service<Api>()(
"Api",
{
project: Site,
main: import.meta.url,
},
Effect.gen(function* () {
return {};
return {
fetch: Effect.succeed(HttpServerResponse.text("Hello from Railway!")),
};
}),
) {}

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.

Railway Services live in a region. Omit it and Railway picks a default. Set it explicitly so the Volume you add in Part 3 can match:

export default class Api extends Railway.Service<Api>()(
"Api",
{ project: Site, main: import.meta.url },
{ project: Site, main: import.meta.url, region: "us-west2" },

Updating region later is in place.

Give the Service an explicit port so Railway’s edge knows where to forward:

export default class Api extends Railway.Service<Api>()(
"Api",
{ project: Site, main: import.meta.url, region: "us-west2" },
{ project: Site, main: import.meta.url, region: "us-west2", port: 3000 },

The port is written to the process environment as PORT and used as the generated domain’s targetPort. Alchemy publishes HTTPS on Railway’s edge in front of it.

Add /health so you can probe the process:

src/api.ts
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
// ...
return {
fetch: Effect.succeed(HttpServerResponse.text("Hello from Railway!")),
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 Railway!");
}),
};

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 Railway from "alchemy/Railway";
import * as Effect from "effect/Effect";
import Api from "./src/api.ts";
import { Site } from "./src/project.ts";
export default Alchemy.Stack(
"MyApp",
{
providers: Railway.providers(),
state: Alchemy.localState(),
},
Effect.gen(function* () {
const site = yield* Site;
const api = yield* Api;
return {
name: site.name,
projectId: site.projectId,
url: site.url,
url: api.url,
};
}),
);

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

Terminal window
bun alchemy deploy
Plan: 1 to create

+ Api (Railway.Service)
 Site (Railway.Project)

Proceed?
◉ Yes ○ No
 Api (Railway.Service) created
{
  name: "myapp-site-dev-a1b2c3d4",
  projectId: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
  url: "https://myapp-api-dev-a1b2c3d4.up.railway.app",
}

A lot just happened:

  1. src/api.ts was bundled with Rolldown into a single ESM file.
  2. Alchemy generated a Dockerfile (FROM oven/bun:1) and uploaded the context. Railway built the image.
  3. It created the Service, attached a *.up.railway.app domain, and pointed Railway’s edge at port 3000.
Terminal window
curl https://myapp-api-dev-a1b2c3d4.up.railway.app
# → Hello from Railway!

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

Plan: 1 to update

~ Api (Railway.Service)

Proceed?
◉ Yes ○ No
 Api (Railway.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 Service updated in place.

You now have:

  • An HTTP Service running as a Railway container in us-west2
  • A public https://{name}.up.railway.app URL
  • A code-hash-based update loop — edit, deploy, new image

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