Skip to content

feat(railway): Project, Service, Postgres, Redis, Bucket, and bindings - #1295

Open
sam-goodwin wants to merge 11 commits into
mainfrom
feat/railway-provider
Open

feat(railway): Project, Service, Postgres, Redis, Bucket, and bindings#1295
sam-goodwin wants to merge 11 commits into
mainfrom
feat/railway-provider

Conversation

@sam-goodwin

@sam-goodwin sam-goodwin commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Railway provider. Auth is RAILWAY_API_TOKEN or alchemy login (oauth CLI pairing). Resource-valued props accept T | Effect<T, never, Providers>. Depends on distilled #472.

A Project is a workspace-scoped namespace. A Service is a container — a public image, a GitHub repo, or an Effect program Alchemy bundles and pushes. A Function is an Effect program on the canvas Bun runtime (no Docker, no registry). Postgres, MySQL, Mongo, Redis, Volumes, Variables, and Buckets live in the same TypeScript program.

providers

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

Project

const site = yield* Railway.Project("Site");

const named = yield* Railway.Project("Site", {
  name: "my-site",
  description: "production web app",
});

site.url is https://railway.com/project/{projectId}. Production is site.environmentId — do not recreate it as an Environment.

Environment

const staging = yield* Railway.Environment("Staging", {
  project: site,
  sourceEnvironmentId: site.environmentId,
});

Service

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

const fromGit = yield* Railway.Service("Web", {
  project: site,
  repo: "acme/web",
  branch: "main",
});

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

api.url is https://{name}.up.railway.app. api.dnsName is {name}.railway.internal. Effect-native images are pushed to registry — Railway has no private registry of its own. replicas may be a count or a per-region map.

Function

export default class Ping extends Railway.Function<Ping>()(
  "Ping",
  { project: Site, main: import.meta.url },
  Effect.gen(function* () {
    return {
      fetch: Effect.succeed(HttpServerResponse.text("ok")),
    };
  }),
) {}

const ping = yield* Railway.Function("Ping", {
  project: site,
  source: `
    Bun.serve({
      hostname: "0.0.0.0",
      port: Number(process.env.PORT ?? 3000),
      fetch() { return new Response("ok"); },
    });
  `,
});

const job = yield* Railway.Function("Cleanup", {
  project: site,
  source: `console.log("tick");`,
  cronSchedule: "0 * * * *",
});

Canvas Bun runtime. No Docker. No registry. Cap is 96KB. Distinct from Service({ main, registry }).

Postgres

const db = yield* Railway.Postgres("Db", { project: site });

Private hostname is {name}.railway.internal. public (default true) creates a TCP proxy; publicConnectionUri is for laptop access and deploy-time migrations.

ConnectPostgres

export default class Api extends Railway.Service<Api>()(
  "Api",
  { project: Site, main: import.meta.url, registry: "ghcr.io/acme", build: { install: ["pg"] } },
  Effect.gen(function* () {
    const conn = yield* Railway.ConnectPostgres(Db);
    const db = yield* Drizzle.Postgres(conn.connectionString);
    return {
      fetch: Effect.gen(function* () {
        const rows = yield* db.execute("select 1 as ok");
        return HttpServerResponse.json({ rows });
      }),
    };
  }).pipe(Effect.provide(Railway.ConnectPostgresHttp)),
) {}

MySQL

const mysql = yield* Railway.MySQL("Mysql", { project: site });

Official mysql:9 image, volume at /var/lib/mysql, optional TCP proxy. Private URI is {name}.railway.internal:3306. Alias Railway.mysql.

ConnectMySQL

export default class Api extends Railway.Service<Api>()(
  "Api",
  { project: Site, main: import.meta.url, registry: "ghcr.io/acme", build: { install: ["mysql2"] } },
  Effect.gen(function* () {
    const conn = yield* Railway.ConnectMySQL(Db);
    const db = yield* Drizzle.MySQL(conn.connectionString);
    return {
      fetch: Effect.gen(function* () {
        const rows = yield* db.execute("select 1 as ok", "objects");
        return HttpServerResponse.json({ rows });
      }),
    };
  }).pipe(Effect.provide(Railway.ConnectMySQLHttp)),
) {}

Mongo

const mongo = yield* Railway.mongo("Mongo", { project: site });

Official mongo:8, volume at /data/db, IPv6 bind for {name}.railway.internal. Alias Railway.Mongo.

ConnectMongo

export default class Api extends Railway.Service<Api>()(
  "Api",
  { project: Site, main: import.meta.url, registry: "ghcr.io/acme", build: { install: ["mongodb"] } },
  Effect.gen(function* () {
    const conn = yield* Railway.ConnectMongo(Db);
    return {
      fetch: Effect.gen(function* () {
        const url = yield* conn.connectionString;
        const ping = yield* Railway.pingMongo(Redacted.value(url));
        return HttpServerResponse.json(ping);
      }),
    };
  }).pipe(Effect.provide(Railway.ConnectMongoHttp)),
) {}

pingMongo

const ping = yield* Railway.pingMongo(mongo.publicConnectionUri);

Redis

const cache = yield* Railway.Redis("Cache", { project: site });

ReadRedis

const cache = yield* Railway.ReadRedis(Cache);
const value = yield* cache.get("marker");

Provide Railway.ReadRedisHttp.

WriteRedis

const cache = yield* Railway.WriteRedis(Cache);
yield* cache.set("marker", "hello");

Provide Railway.WriteRedisHttp.

ReadWriteRedis

export default class Api extends Railway.Service<Api>()(
  "Api",
  { project: Site, main: import.meta.url, registry: "ghcr.io/acme" },
  Effect.gen(function* () {
    const cache = yield* Railway.ReadWriteRedis(Cache);
    return {
      fetch: Effect.gen(function* () {
        yield* cache.set("marker", "hello");
        const value = yield* cache.get("marker");
        return HttpServerResponse.json({ value });
      }),
    };
  }).pipe(Effect.provide(Railway.ReadWriteRedisHttp)),
) {}

alchemy/Redis

import * as Redis from "alchemy/Redis";

const cache = Redis.makeReadWrite(url);
yield* cache.set("marker", "hello");
const value = yield* cache.get("marker");

Fly and Railway ReadWriteRedis share this RESP client. Error tags are Redis.UrlMissing / Redis.CommandError.

Bucket

const data = yield* Railway.Bucket("Data", { project: site });

S3-compatible. Region defaults to sjc. Changing project, environment, or region replaces the Bucket.

PutObject

const putObject = yield* Railway.PutObject(Data);
yield* putObject({
  Key: "hello.txt",
  Body: "hello",
  ContentType: "text/plain",
});

Provide Railway.PutObjectHttp.

GetObject

const getObject = yield* Railway.GetObject(Data);
const text = yield* getObject({ Key: "hello.txt" }).pipe(
  Effect.flatMap((result) =>
    Stream.mkString(Stream.decodeText(result.Body!)),
  ),
);

Provide Railway.GetObjectHttp.

HeadObject

const headObject = yield* Railway.HeadObject(Data);
const head = yield* headObject({ Key: "hello.txt" });

Provide Railway.HeadObjectHttp.

ListObjectsV2

const listObjects = yield* Railway.ListObjectsV2(Data);
const result = yield* listObjects({ Prefix: "jobs/", MaxKeys: 100 });

Provide Railway.ListObjectsV2Http.

DeleteObject

const deleteObject = yield* Railway.DeleteObject(Data);
yield* deleteObject({ Key: "hello.txt" });

Provide Railway.DeleteObjectHttp.

Volume

const disk = yield* Railway.Volume("Data", {
  project: site,
  mountPath: "/data",
});

MountVolume

export default class Api extends Railway.Service<Api>()(
  "Api",
  { project: Site, main: import.meta.url, registry: "ghcr.io/acme" },
  Effect.gen(function* () {
    const disk = yield* Railway.MountVolume(Data, { path: "/data" });
    return {
      fetch: Effect.succeed(HttpServerResponse.text(disk.path)),
    };
  }).pipe(Effect.provide(Railway.MountVolumeLive)),
) {}

VolumeBackup

Pro-plan. Snapshot of a mounted volume instance. lock is one-way. schedules is instance state (DAILY / WEEKLY / MONTHLY).

const snap = yield* Railway.VolumeBackup("Nightly", {
  volume: data,
  lock: true,
  schedules: ["DAILY", "WEEKLY"],
});

restoreVolumeBackup

Destructive to the live volume instance.

yield* Railway.restoreVolumeBackup({
  volumeInstanceId: snap.volumeInstanceId,
  volumeInstanceBackupId: snap.volumeInstanceBackupId,
});

restoreVolumePITR

Forks a new Postgres service from a point-in-time archive. Source stays online.

yield* Railway.restoreVolumePITR({
  volumeInstanceId: db.volumeInstanceId,
  targetTimestamp: "2026-08-01T00:00:00.000Z",
});

Variable

const token = yield* Railway.Variable("ApiToken", {
  project: site,
  name: "API_TOKEN",
  value: Redacted.make("sk_live_…"),
});

Config.redacted in a Service is for deploy-time .env. Use Railway.Variable when Railway should own a shared value. Plaintext is never stored in attributes.

ref

Railway template (${{Service.KEY}} / ${{shared.NAME}}). Not a resolved URI.

yield* Railway.Variable("DatabaseUrl", {
  project: site,
  service: api,
  name: "DATABASE_URL",
  value: Railway.ref(db, "DATABASE_URL"),
});

env: {
  SENTRY_DSN: Railway.ref("shared", "SENTRY_DSN"),
}

TcpProxy

const proxy = yield* Railway.TcpProxy("DbProxy", {
  postgres: db,
  environment: site,
  applicationPort: 5432,
});

Public endpoint is {domain}:{proxyPort} on *.proxy.rlwy.net.

CustomDomain

const www = yield* Railway.CustomDomain("Www", {
  service: api,
  environment: site,
  domain: "www.example.com",
});

Point DNS at verificationDnsHost / verificationToken. HTTP Services already get a generated *.up.railway.app domain.

PrivateNetwork

Named mesh in an environment. Destroy is a no-op — Railway has no per-network delete.

const net = yield* Railway.PrivateNetwork("Mesh", {
  environment: site,
  name: "backend",
});

PrivateNetworkEndpoint

Per-service DNS name on a named network. name is the prefix ({name}.{network.dnsName}).

const endpoint = yield* Railway.PrivateNetworkEndpoint("ApiDns", {
  network: net,
  service: api,
  name: "api",
});

Group

Canvas group. Writes EnvironmentConfig.groups via environmentPatchCommit.

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

Template

Deploys a marketplace template (postgres, UUID, …). Omit project and Alchemy creates an owned one.

const fromMarket = yield* Railway.Template("Postgres", {
  templateId: "postgres",
  project: site,
});

Sandbox

Ephemeral Linux VM. Priority Boarding. No in-place update.

const box = yield* Railway.Sandbox("Box", {
  environment: site,
  idleTimeoutMinutes: 10,
});

execSandbox

const result = yield* Railway.execSandbox({
  sandboxId: box.sandboxId,
  environmentId: box.environmentId,
  command: "echo hello",
});

heartbeatSandbox

yield* Railway.heartbeatSandbox({
  sandboxId: box.sandboxId,
  environmentId: box.environmentId,
});

createSandboxCheckpoint

yield* Railway.createSandboxCheckpoint({
  sandboxId: box.sandboxId,
  environmentId: box.environmentId,
  name: "after-deps",
});

listSandboxCheckpoints

const checkpoints = yield* Railway.listSandboxCheckpoints({
  environmentId: box.environmentId,
});

renameSandboxCheckpoint

yield* Railway.renameSandboxCheckpoint({
  environmentId: box.environmentId,
  name: "after-deps",
  newName: "ready",
});

deleteSandboxCheckpoint

yield* Railway.deleteSandboxCheckpoint({
  environmentId: box.environmentId,
  name: "ready",
});

Exec

Runtime binding. Provide Railway.ExecHttp. From tests prefer execSandbox.

const run = yield* Railway.Exec(box);
const result = yield* run({ command: "echo hello" });

CloudAgent

Persistent coding-agent VM. Priority Boarding. Changing name or environment replaces the disk.

const agent = yield* Railway.CloudAgent("Coder", {
  environment: site,
});

sleepCloudAgent

Stops compute billing. Disk stays.

yield* Railway.sleepCloudAgent(agent);

wakeCloudAgent

Re-runs the entrypoint with files in place.

yield* Railway.wakeCloudAgent(agent);

UsageLimit

Soft/hard dollar cap on the workspace customer.

const cap = yield* Railway.UsageLimit("SpendCap", {
  project: site,
  limit: { softLimitDollars: 50, hardLimitDollars: 100 },
});

usage

const rows = yield* Railway.usage({
  project: site,
  measurements: ["CPU_USAGE", "MEMORY_USAGE_GB"],
});

estimatedUsage

const estimate = yield* Railway.estimatedUsage({
  measurements: ["CPU_USAGE"],
});

Catalog

const workspace = yield* Railway.currentWorkspace;
const regions = yield* Railway.listRegions();
const west = yield* Railway.findRegion("us-west2");

listAuditLogs

Query-only. One page; default 50.

const logs = yield* Railway.listAuditLogs({
  project: site,
  first: 50,
});

getAuditLog

const log = yield* Railway.getAuditLog({ id: logs[0].id });

listAuditLogEventTypes

const types = yield* Railway.listAuditLogEventTypes();

loginSessionUrl

Pairing URL for railway login --browserless. alchemy login with method: "oauth" runs this flow.

const code = yield* railway.loginSessionCreate({});
const url = Railway.loginSessionUrl(code, { hostname: "dev-box" });

Railway is one GraphQL service. Call @distilled.cloud/railway
directly — no ./railway subpath.
Railway allows one project create per workspace every 30 seconds.
Concurrent stacks were racing that and failing as RailwayRateLimited.
Hold a process slot, retry the typed tag, and wait out the window
after a success.
Project, Environment, Variable, Volume, Postgres, Redis, Bucket,
TcpProxy, image Service, Effect Api + Worker, and every binding
(ConnectPostgres, ReadWriteRedis, S3, MountVolume). CustomDomain
when RAILWAY_TEST_DOMAIN is set.
loginSession*, privateNetworkEndpoint, audit-log scalars, usage
limits, volume-backup lock, canvas merge, and empty-token auth.
…atalog

Canvas Functions, GitHub source/healthcheck/replicas on Service, MySQL
and Mongo with Connect* bindings, named private networks, Groups,
Templates, Sandboxes, Cloud Agents, UsageLimit, VolumeBackup, oauth
login sessions, audit logs, and Railway.ref variable templates.
Railway.Function is a Platform like Service: class + main + fetch, no
registry. Canvas source/cron stays. Live tests cover both.
Pull RESP + makeRead/makeWrite/makeReadWrite into alchemy/Redis so Fly
and Railway bindings do not duplicate the runtime client.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant