Skip to content

2.0.0-beta.75 - Railway, Fly, Hetzner & Websites

beta.75 ships Railway, Fly.io, and Hetzner — each with an Effect-native Service platform. The same website call now deploys to all three plus Cloudflare and AWS: Vite, Astro, Next.js, Nuxt, SvelteKit, Waku, Octane, Foldkit, and StaticSite, with one props vocabulary. alchemy dev runs your AWS Lambda, S3, DynamoDB, SQS, ECS, EC2, and website stack on your machine — no account, no credentials, hot reload. SQL migrations have one format, Workers can sit behind Access and receive inbound email, and removal policies persist.

This post covers beta.72 through beta.75.

effect 4.0 has moved from beta to release candidates, and alchemy now depends on effect’s rc dist-tag (#1245, #1318). The minimum peer is >=4.0.0-rc.112; install effect and the platform packages from the rc tag:

Terminal window
bun add alchemy@latest effect@rc @effect/platform-bun@rc @effect/platform-node@rc

The Website family now deploys to Cloudflare, AWS, Hetzner, Railway, and Fly (#1140, #1353, #1351). Your astro.config.* loads natively. Stage-varying values go in the astro: bag. Change the namespace to change the cloud:

const site = yield* Cloudflare.Website.Astro("Blog", {
rootDir: "./apps/blog",
env: { API_BASE: api.url },
astro: { site: `https://${stage}.example.com`, output: "server" },
domain: "blog.example.com",
});
const site = yield* Cloudflare.Website.Astro("Blog", {
const site = yield* Fly.Website.Astro("Blog", {
rootDir: "./apps/blog",
env: { API_BASE: api.url },
astro: { site: `https://${stage}.example.com`, output: "server" },
domain: "blog.example.com",
});

AWS.Website.Astro and Railway.Website.Astro take the same props. Hetzner adds the DNS zone when you set domain:

const site = yield* Hetzner.Website.Astro("Blog", {
rootDir: "./apps/blog",
env: { API_BASE: api.url },
astro: { site: `https://${stage}.example.com`, output: "server" },
domain: "blog.example.com",
zone,
});

alchemy dev is the framework’s own server on every cloud — native HMR, no cloud resources. A Vite SPA is the same shape, assets-only on AWS (no Lambda):

const site = yield* AWS.Website.Vite("Web", {
domain: "app.example.com",
});

Next.js is the same call. Cloudflare and AWS run OpenNext; Fly, Hetzner, and Railway run next build and a Node server:

const site = yield* Fly.Website.Nextjs("Site", {
rootDir: "./apps/web",
env: { DATABASE_URL: db.connectionUri },
domain: "app.example.com",
});

AWS’s old server: bag is now flat props:

const site = yield* AWS.Website.Astro("Blog", {
server: {
memorySize: 1024,
environment: { API_URL: api.url },
},
memorySize: 1024,
env: { API_URL: api.url },
astro: { site: `https://${stage}.example.com` },
domain: "blog.example.com",
});

Vite, Foldkit, Astro, Nextjs, Nuxt, SvelteKit, Waku, Octane, and StaticSite on all five. Cloudflare.Website.Vocs also lands on Fly, Hetzner, and Railway. React Router, SolidStart, and TanStack Start stay on Cloudflare (Website.Vite) and AWS (dedicated composites). Each cloud has a matching example (examples/{cloudflare,aws,fly,hetzner,railway}-website-{framework}).

Docs: Cloudflare · AWS · Fly · Hetzner · Railway.

A new Railway provider (#1295): Project, Service, Function, Postgres, MySQL, Mongo, Redis, Bucket, Volume, Variable, CustomDomain, TcpProxy, and PrivateNetwork. Auth is RAILWAY_API_TOKEN or alchemy login.

A Project is the parent. Railway.Service is the Function/Worker analog: an Effect program in a container. Alchemy bundles main, builds a Docker image, and pushes it to a registry Railway can pull (GHCR or Docker Hub — Railway has no private registry of its own):

const Site = Railway.Project("Site");
const Db = Railway.Postgres("Db", { project: Site });
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.select().from(users);
return HttpServerResponse.json({ rows });
}),
};
}).pipe(Effect.provide(Railway.ConnectPostgresHttp)),
) {}

api.url is https://{name}.up.railway.app. Redis and S3-style Buckets bind the same way:

const Cache = Railway.Redis("Cache", { project: Site });
const Data = Railway.Bucket("Data", { project: Site });
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);
const putObject = yield* Railway.PutObject(Data);
return {
fetch: Effect.gen(function* () {
yield* putObject({ Key: "hello.txt", Body: "hello" });
yield* cache.set("last-write", "hello.txt");
return HttpServerResponse.text("ok");
}),
};
}).pipe(Effect.provide([Railway.ReadWriteRedisHttp, Railway.PutObjectHttp])),
) {}

Pass image for a public tag (hashicorp/http-echo) or repo for GitHub; healthcheck / cronSchedule match Railway IaC.

Railway.Function is the canvas runtime: a single TypeScript file on Bun. No Docker. No registry. Cap is 96KB. Distinct from Service({ main, registry }):

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

Docs: Railway · Tutorial.

A new Hetzner Cloud provider (#1258): Server, SshKey, Volume, VolumeAttachment, Network, Firewall, PlacementGroup, PrimaryIp, FloatingIp, FloatingIpAssignment, Certificate, LoadBalancer, Image, and DNS Zone + RecordSet. Auth is a single HCLOUD_TOKEN (or alchemy login).

export const Key = Hetzner.SshKey("laptop", {
publicKey: "ssh-ed25519 AAAA… you@laptop",
});
export const Box = Hetzner.Server("box", {
serverType: "cpx12",
image: "ubuntu-24.04",
location: "nbg1",
sshKeys: [Key],
});

Hetzner.Service is the Function/Worker analog for a server you own. Write an Effect program with a fetch handler; deploying bundles it, ships it to the server, and runs it under systemd with a readiness gate:

export const Data = Hetzner.Volume("data", {
size: 40,
format: "ext4",
location: "nbg1",
server: Box,
});
export default class Api extends Hetzner.Service<Api>()(
"api",
{ server: Box, main: import.meta.url, port: 3000 },
Effect.gen(function* () {
const disk = yield* Hetzner.MountVolume(Data, { path: "/var/lib/api" });
return {
fetch: Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const body = yield* fs.readFileString(`${disk.path}/hello.txt`);
return yield* HttpServerResponse.text(body);
}),
};
}).pipe(Effect.provide(Hetzner.MountVolumeLive)),
) {}

Bindings follow the same capability model as AWS and Cloudflare: MountVolume formats, attaches, and mounts a Volume into the service, Hetzner.Ssh(Box) is a typed exec/scp client for a Server, and the DNS zone splits into least-privilege ReadDns / WriteDns / ReadWriteDns clients. Networking is a private Network, Firewall rules, and a LoadBalancer with managed TLS certificates:

export const Lb = Hetzner.LoadBalancer("lb", {
location: "nbg1",
loadBalancerType: "lb11",
targets: [{ type: "server", server: Box, usePrivateIp: true }],
services: [
{
protocol: "https",
listenPort: 443,
destinationPort: 3000,
certificates: [Managed],
http: { redirectHttp: true },
},
],
});

Hetzner gets its own docs tab (#1260): a setup guide, a four-part tutorial (server → service → volume → network + load balancer), and guides for servers, services, volumes, networking, and DNS.

Docs: Hetzner · Tutorial.

A new Fly.io provider (#1261): App, Machine, Service, Sprite, Volume + VolumeSnapshot, IpAssignment, Certificate, Secret, SecretKey, and managed data — Postgres, Upstash Redis, and Tigris object storage. Auth is a single FLY_API_TOKEN.

Fly.Service is the Function/Worker analog: an always-on Effect program in a Fly Machine. Alchemy bundles main, builds a Docker image, pushes it, and Fly’s Anycast proxy load-balances across count replicas:

const Site = Fly.App("Site");
const Db = Fly.Postgres("Db", {
region: "iad",
migrations: schema, // Drizzle.Schema, a directory, or { dir, table }
});
export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, region: "iad", count: 3, port: 3000 },
Effect.gen(function* () {
const conn = yield* Fly.ConnectPostgres(Db);
const db = yield* Drizzle.Postgres(conn.connectionString);
return {
fetch: Effect.gen(function* () {
const rows = yield* db.select().from(users);
return HttpServerResponse.json({ rows });
}),
};
}).pipe(Effect.provide(Fly.ConnectPostgresHttp)),
) {}

Upstash Redis and Tigris object storage bind the same way — ReadRedis / WriteRedis / ReadWriteRedis on a Fly.Redis, and S3-style PutObject/GetObject on a Fly.Bucket:

const Cache = Fly.Redis("Cache"); // Upstash Redis
const Data = Fly.Bucket("Data"); // Tigris object storage
export default class Api extends Fly.Service<Api>()(
"Api",
{ app: Site, main: import.meta.url, port: 3000 },
Effect.gen(function* () {
const cache = yield* Fly.ReadWriteRedis(Cache);
const putObject = yield* Fly.PutObject(Data);
return {
fetch: Effect.gen(function* () {
yield* putObject({ Key: "hello.txt", Body: "hello" });
yield* cache.set("last-write", "hello.txt");
return HttpServerResponse.text("ok");
}),
};
}).pipe(Effect.provide([Fly.ReadWriteRedisHttp, Fly.PutObjectHttp])),
) {}

The rest of the surface follows the same capability model: GetSecret/WriteSecret on app secrets, Encrypt/Decrypt/Sign/Verify on a SecretKey, and MountVolume for disks. Already have an image? Fly.Machine runs it as one VM per resource — yield another to add capacity.

Fly.Sprite deploys an Effect program onto a Fly Sprite — a persistent Linux computer built for AI-agent workloads. It wakes on demand, hibernates when idle, and keeps its filesystem between wakes. No parent App, no Docker image: alchemy bundles main and writes it straight onto the Sprite:

export default class Box extends Fly.Sprite<Box>()(
"Box",
{ main: import.meta.url },
Effect.gen(function* () {
return { fetch: Effect.succeed(HttpServerResponse.text("hello")) };
}),
) {}

Docs: Fly.io · Tutorial.

alchemy dev now runs your AWS stack on your machine (#1154). No account, no credentials:

Terminal window
bun alchemy dev

Same program as production. A Function that writes to a bucket and a queue:

export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url, functionUrl: true },
Effect.gen(function* () {
const files = yield* AWS.S3.Bucket("Files");
const jobs = yield* AWS.SQS.Queue("Jobs");
const putObject = yield* AWS.S3.PutObject(files);
const sendMessage = yield* AWS.SQS.SendMessage(jobs);
return {
fetch: Effect.gen(function* () {
yield* putObject({ Key: "hello.txt", Body: "hello" });
yield* sendMessage({ MessageBody: "process hello.txt" });
return yield* HttpServerResponse.text("ok");
}).pipe(Effect.orDie),
};
}).pipe(
Effect.provide([AWS.S3.PutObjectHttp, AWS.SQS.SendMessageHttp]),
),
) {}

hits local endpoints:

Api.functionUrl http://b71ff5c0….lambda-url.us-east-1.localhost:4566/
Jobs.queueUrl http://localhost:4566/000000000000/jobs-dev-x7k2
Files.bucketName files-dev-x7k2

Save the file and the Function serves the new code in ~100–500ms. putObject lands in the local bucket; queue consumers fire; websites run their own dev servers.

Opt a resource onto the real cloud with Alchemy.remote():

const secret = yield* AWS.SecretsManager.Secret("ProdSecret", { ... })
.pipe(Alchemy.remote());

A remote resource is live end to end: its binding clients route to the real cloud too, not just its lifecycle (#1406). Switching local ↔ live plans a replacement, so dev state never silently becomes cloud state.

Docs: AWS local development.

alchemy dev runs ECS tasks and Lambda MicroVMs the same way (#1185, #1335) — same program, two runtimes:

export default class Web extends AWS.ECS.Task<Web>()(
"Web",
{ main: import.meta.url, port: 8080 },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}
export default class Box extends AWS.Lambda.MicrovmImage<Box>()(
"Box",
{ main: import.meta.url },
Effect.gen(function* () {
return {
fetch: Effect.succeed(HttpServerResponse.text("hello")),
};
}),
) {}

Save a file and both reload. EC2 instances that host an Effect program do too.

Docs: AWS local development.

Alchemy’s migration bookkeeping was an invented table shape incompatible with what drizzle-kit, Prisma, and wrangler actually maintain. There is now exactly one format, owned by Alchemy (#1226): __alchemy_migrations, with one prop on every SQL database resource (D1, Neon, PlanetScale):

const db = yield* Cloudflare.D1.Database("app-db", {
migrations: "./migrations", // or { dir, table }
});

Drizzle.Schema plugs into the same prop. It diffs your TypeScript schema against the checked-in snapshot and generates SQL migrations on drift, and passing the resource itself orders generation before application in the same deploy:

const schema = yield* Drizzle.Schema("AppSchema", {
schema: "./src/schema.ts",
dialect: "sqlite",
});
const db = yield* Cloudflare.D1.Database("app-db", {
migrations: schema,
});

A database previously migrated with drizzle-kit, Prisma, or wrangler is adopted on first deploy by a one-way conversion: the old tool’s applied history is copied into Alchemy’s table once, validated against your local migration files, and the old table is left frozen — never written, never dropped. Nothing replays.

History validation is now strict — a recorded migration with no matching local file fails the deploy instead of passing silently. If you squashed and deleted old migration files, restore them before upgrading.

Docs: SQL.

Support for Workers protected by Access (#1228): declare policies inline on the Worker and alchemy manages the dedicated Access application for you —

export default class Api extends Cloudflare.Worker<Api>()("Api", {
main: import.meta.url,
access: {
policies: [
{ decision: "allow", include: [{ emailDomain: "example.com" }] },
],
},
}, /* ... */) {}

— or share one Cloudflare.Access.Application across several Workers (access: App), or protect every current and future Worker in the account with the AllWorkers destination. Enrollment goes through the binding contract, so removing the prop (or the Worker) un-enrolls it on the next reconcile.

At runtime, Cloudflare.Access.Context reads the authenticated identity, and dev: { access: { identity } } stubs a logged-in user under alchemy dev so you can test the gated paths locally:

fetch: Effect.gen(function* () {
const access = yield* Cloudflare.Access.Context;
const identity = yield* access.getIdentity(); // email, groups, idp…
return yield* HttpServerResponse.json({ email: identity?.email });
}),

Docs: Protect a Worker with Access.

Workers can now receive email (#401). Cloudflare.email({ zone }).subscribe(handler) registers the email handler and provisions the zone’s email routing rules for you:

export default Cloudflare.Worker(
"Inbox",
{ main: import.meta.url },
Effect.gen(function* () {
yield* Cloudflare.email({ zone: "example.com" }).subscribe((message) =>
message.forward("ops@example.com"),
);
return {};
}).pipe(Effect.provide(Cloudflare.EmailEventSourceLive)),
);

Docs: Email Workers.

RemovalPolicy.retain() added to an already-deployed resource never reached state — the policy is a decoration, not a prop, so the noop apply path skipped it, and a later rename or removal orphan-deleted the resource anyway. beta.75 persists the policy on the noop path (#1253) and prints a note whenever a deploy flips one.

Two behavior changes come with it, both protective. Destroying a non-empty R2 bucket now fails with BucketNotEmpty instead of silently emptying it first — the old behavior deleted 60k objects in the incident that motivated the fix. Opt disposable buckets back in explicitly:

const cache = yield* Cloudflare.R2.Bucket("Cache", { forceDestroy: true });

And because the policy now actually persists, a retain() you removed from the code takes effect: the next removal deletes the resource for real. If anything depends on a retain that’s no longer declared, put it back before deploying.

Docs: Resource lifecycle.

AWS.Lambda.Function deploys a container image (#1077). Build from a Dockerfile into a managed ECR repo, or point image.uri at an existing tag or digest. Zip XOR image at the type level — mixing main with image is a compile error. Thanks Simon Westerlund!

const api = yield* AWS.Lambda.Function("ContainerApi", {
image: { context: "./lambda", dockerfile: "Dockerfile" },
architecture: "arm64",
functionUrl: true,
});
  • Cloudflare.Website.Vocs (#1278) — first-class Vocs docs sites, also on Fly, Hetzner, and Railway.
  • Isolated installs bundle (#1324) — generated entries are shims over real alchemy/Runtime/Bootstrap/* modules, so bun --linker=isolated and pnpm no longer leave Effect programs crashing at boot with Cannot find module.
  • Axiom dataset edge deployments (#1361) — edgeDeployment on Axiom.Dataset; OTLP endpoints derive from Axiom’s regional URL. Thanks Aman Varshney!
  • Cognito email OTP, CustomEmailSender, and KMS (#1323) — on UserPool.
  • PlanetScale Metal SKUs (#1319) — on PostgresClusterSize.
  • r2.dev managed domain (#1329) — enable it on Cloudflare.R2.Bucket.
  • nuke --local clears the emulator (#1287) — alchemy unsafe nuke --local enumerates and deletes through each provider’s local implementation (the floci-emulated account, the Cloudflare local runtime), so wiping local state is one command with no cloud credentials in reach.
  • Hetzner init scripts compose with the bootstrap (#1289) — Hetzner.Server.userData merges into a multipart cloud-init document alongside alchemy’s bootstrap instead of replacing it, and changing it plans a replacement (cloud-init only runs on first boot).
  • Prisma: object storage, locked Postgres state, and deploy triggers (#1061) — Prisma.Bucket + BucketAccessKey resources with ReadBucket/WriteBucket/ReadWriteBucket bindings, a State/PostgresState backend with per-stack advisory locks, and Deployment.triggers to force a redeploy when an opaque key/value (e.g. a rotated secret) changes. Thanks Will Madden!
  • Structured DNS record data (#1257) — Cloudflare DNS records accept typed component objects for SVCB, HTTPS, SRV, CAA, and friends via the same content prop, with the shape discriminated by type. Thanks Arya Saatvik! Adoption also disambiguates records sharing (name, type) by content/priority instead of clobbering the first match (#1264).
  • Worker versions carry static assets (#1266) — a preview version can ship its own assets without touching the parent deployment. Thanks Rahul Mishra, who also migrated the workspace to pnpm (#1214), bumped effect to rc.110 then rc.111 (#1245, #1318), made process cleanup explicit and scoped (#1175), and improved platform runtime detection (#1197)!
  • Cloudflare Containers reach host services in dev (#1344, #1335, #1366) — loopback env URLs rewrite to the docker host, container images hot-reload on context/Dockerfile/prop changes, and Linux dev containers reach loopback host databases through a unix-socket tunnel instead of the (often firewalled) bridge IP. A failed docker build now fails the dev deploy with docker’s real error — and a buildx hint when --load is the problem — instead of looping on “Container exited while waiting for port” (#1377).
  • Docker.Container fixes (#1398) — ports: [{ external: 0 }] reports the actually-assigned host port, extraHosts forwards as --add-host, and reconciliation only disconnects networks alchemy itself attached instead of tearing off Docker’s default bridge.
  • Nested Worker env plans converge (#1273) — a Worker bound to an already-yielded Worker no longer plans update forever on identical deploys. Thanks Odysseas Papadimas!
  • alchemy dev survives a broken save (#1259) — a mid-edit save whose module evaluation throws now logs and waits for the next change instead of killing the watch session.
  • Env-bound strings deploy bare (#1254) — a queue name no longer lands in its binding JSON-quoted, so the dashboard and hand-written env reads see the real value.
  • App Runner reaps auto-created log groups on delete (#1327).
  • SvelteKit 3 config shape (#1347) — @sveltejs/kit@3.0.0-next.21+.
  • Process env wins over dotenv defaults (#1355).
  • Dependency security bumps (#1255) — the vulnerable extract-zip and libvips advisories are out of the runtime’s dependency closure.
  • Worker.domain pins its zone (#1241) — zoneId/zone/zoneName on domain, and unpinned hostnames resolve by name instead of the first page of an account-wide zone list. Thanks Ben Snyder!
  • Cloudflare.Website.Foldkit (#1120) and per-workflow step limits (limits: { steps }, #816) — thanks Filip Falcon!
  • Pipelines streams bind to Workers (#1176) — a stream in env becomes a real pipelines binding instead of falling through to JSON. Thanks spinx!
  • DynamoDB multi-attribute GSI keys (#1216) — GSI partition and sort keys composed of up to 4 attributes each, with order-preserving diffs.
  • RDS DBParameterGroup.parameters (#1205) — engine settings reconciled against live user-modified values. Thanks skusez!
  • Lambda packaging fixes — installed packages keep their file modes and symlinks (#855, thanks Joaquín Pérez!) and nested zipCode archives are byte-deterministic across builds (#1211).
  • Astro forwards prerenderEnvironment (#1242) — thanks Maxwell Brown!
  • The CLI honors STAGE (#1244) — thanks sethcarlton! And the version check never suggests a downgrade (#1116).
  • @effect/vitest moves to devDependencies (#1191) — installing alchemy no longer pulls a test toolchain (and its esbuild peer conflicts) into your resolution. Thanks Kristóf Siket!
  • A missing .make Layer names itself (#1055) — yielding a bare-tag resource without its implementation Layer dies with a MissingImplementationError explaining exactly what to provide, instead of a TypeError deep in a provider. Thanks apostoli!
  • Circular Worker layers typecheck (#1199) — Worker.make no longer leaks the other Worker’s tag into Layer.mergeAll requirements. Thanks Patrik Duksin!