Skip to content

Services

A Service is a container in a Railway Project. Point it at a public image (hashicorp/http-echo), an Effect program (main), or a GitHub repo (repo + branch). Several Services share one Project. Canvas Functions — Effect-native or one TypeScript file, no Docker, no registry — are Railway.Function.

Pass image without main. Railway pulls the image and runs it. No Docker. No registry. url is the generated *.up.railway.app hostname.

const site = yield* Railway.Project("Site");
const api = yield* Railway.Service("Api", {
project: site,
image: "hashicorp/http-echo",
port: 5678,
});

project is the parent Project. Pass the declaration directly, yielded or module-scope. Changing project replaces the Service.

A Service is a class. Props describe the container. The Effect is the program that runs in it.

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 {};
}),
) {}

main: import.meta.url is the bundle entrypoint. Alchemy bundles this file with Rolldown, generates a Dockerfile (default FROM oven/bun:1), and uploads the context. Railway builds the image.

Return fetch from the init Effect to boot an HTTP server.

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")),
};
}),
) {}

Omit fetch for a background service.

Return methods next to fetch. Bind the class from another Service or Function with bindService / bindFunction. Calls stay on {name}.railway.internal with a shared token. Public *.up.railway.app requests to /__rpc__/* get 401.

export default class Query extends Railway.Service<Query>()(
"Query",
{ project: Site, main: import.meta.url },
Effect.gen(function* () {
return {
greet: (name: string) => Effect.succeed(`hello ${name}`),
};
}),
) {}
const query = yield* Railway.bindService(Query);
const greeting = yield* query.greet("sam");

See Functions for the Function side of the same stub.

Railway picks a default region if you omit region. Updating it is in place. See Regions.

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

port is the port the process listens on. Alchemy writes it to PORT and uses it as the generated *.up.railway.app targetPort. Default 3000 for Effect-native Services. Pass 5678 for hashicorp/http-echo.

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 },

Yield the Service in the Stack. api.url is https://{name}.up.railway.app. Alchemy creates that hostname with serviceDomainCreate.

export default Alchemy.Stack(
"MyApp",
{ providers: Railway.providers(), state: Alchemy.localState() },
Effect.gen(function* () {
const api = yield* Api;
return { url: api.url };
}),
);

There is no IpAssignment resource. Railway’s edge terminates TLS on 443 and forwards to port.

healthcheck / healthcheckPath is the HTTP path Railway probes. healthcheckTimeout is seconds. Railway load-balances public traffic across whatever replicas are running. Alchemy does not pin a count.

const api = yield* Railway.Service("Api", {
project: site,
image: "hashicorp/http-echo",
port: 5678,
healthcheck: "/health",
healthcheckTimeout: 30,
});

repo is the third source, next to image and main. Requires a GitHub connection on the Railway account. buildCommand / startCommand are Railway’s build/start (distinct from hosted build.install). cronSchedule runs the service on a cron expression.

const api = yield* Railway.Service("Api", {
project: site,
repo: "acme/web",
branch: "main",
rootDirectory: "apps/api",
buildCommand: "pnpm build",
startCommand: "pnpm start",
healthcheck: "/health",
});

Railway.Group organizes services, databases, volumes, and buckets on the canvas. IaC: group("Backend", [api, worker, db]).

const backend = yield* Railway.Group("Backend", {
project: site,
resources: [api, worker, db],
});

For an image Service, Alchemy sets source.image and deploys with serviceInstanceDeployV2. Docker is not required.

For an Effect-native Service, Alchemy bundles main with Rolldown and generates a Dockerfile (FROM oven/bun:1 unless you pass image). If the hash matches the last successful deploy, it skips the upload. Otherwise it tars the context and uploads it the same way railway up does. Railway builds and deploys. No local Docker daemon. No registry.

Changed code is a new Railway build and an in-place update. Unchanged code is a no-op.

build.install: ["pg"] ships pg unbundled. Rolldown’s CJS interop turns pg.Client into a namespace (The superclass is not a constructor) if you bundle it.

export default class Api extends Railway.Service<Api>()(
"Api",
{
project: Site,
main: import.meta.url,
build: { install: ["pg"] },
},
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Override the base image with image (must still run bun when main is set).

Yield Config in init. Alchemy reads the value from the env of whoever deploys and writes it onto the Service. Do not pass env: { ... } on a Service unless you are packing a known-plain value.

import * as Config from "effect/Config";
import * as Redacted from "effect/Redacted";
export default class Api extends Railway.Service<Api>()(
"Api",
{
project: Site,
main: import.meta.url,
port: 3000,
},
Effect.gen(function* () {
const apiKey = yield* Config.redacted("API_KEY");
return {
fetch: Effect.gen(function* () {
const token = Redacted.value(apiKey);
// ...
}),
};
}),
) {}

Config.redacted("API_KEY") is Redacted<string>. Unwrap with Redacted.value only where you need the raw string.

Alchemy also injects PORT (when port is set) and stack metadata. For a secret Railway should own and inject into services, use Railway.Variable.

Omit fetch. Use ServerHost.run for a long-running loop:

src/worker.ts
import * as Railway from "alchemy/Railway";
import { ServerHost } from "alchemy/Server";
import * as Effect from "effect/Effect";
import { Site } from "./project.ts";
export default class Worker extends Railway.Service<Worker>()(
"Worker",
{
project: Site,
main: import.meta.url,
region: "us-west2",
},
Effect.gen(function* () {
const host = yield* ServerHost;
yield* host.run(
Effect.gen(function* () {
return yield* Effect.never;
}).pipe(Effect.orDie),
);
}),
) {}

If the process exits, Railway restarts it.

Each Service has its own image, env, and lifecycle. Point several at the same project.

class Api extends Railway.Service<Api>()(
"Api",
{ project: Site, main: import.meta.url, port: 3000 },
/* HTTP */
) {}
class Worker extends Railway.Service<Worker>()(
"Worker",
{ project: Site, main: import.meta.url },
/* mounts a disk, writes files */
) {}

A Volume attaches to one Service. See Volumes.

Service logs live in the Railway dashboard. alchemy logs / alchemy tail don’t support Railway Services yet.

The tutorial builds a Service step by step. Functions covers Effect-native and canvas Functions, including schemaless RPC. Volumes covers MountVolume. Postgres binds with ConnectPostgres. MySQL and Mongo are the same shape. Redis binds with ReadWriteRedis. Buckets bind with PutObject / GetObject. Variables covers Config.redacted, Railway.Variable, and Railway.ref. The Service reference lists every prop. Example: railway-project is an image Service. Example: railway-service is an Effect-native Service bound to Postgres, MySQL, Redis, an Effect Function, and a Group.