2.0.0-beta.80 - GCP, Neon & Secret Providers
beta.80 has three headline additions. Google Cloud arrives with
about 1,050 resources across 190 services, Effect-native runtimes on
Cloud Run, Cloud Functions, and GKE, and bindings that grant each host
the narrowest IAM role GCP allows. Neon becomes a full backend
provider: Functions, object storage, managed Auth, the Data API, AI
Gateway, and 13 Neon.Website frameworks, all in one stack. Secret
providers let a stack load its Config from dotenv files,
Doppler, or Infisical, per stage, with browser login on your
machine and OIDC in CI.
Effect 4.0 stable is now the baseline. Prisma ORM v8 also lands with an Effect-native Postgres runtime. A
Fly.Service is now its own App with a public URL, typed calls to
other Services, blue/green deploys, and multi-container Machines, and
--include / --exclude deploy a slice of a stack. On Cloudflare:
durable callbacks, hibernating WebSocket RPC, presigned R2 URLs under
alchemy dev, and Queue subscriptions from any resource.
Google Cloud
Section titled “Google Cloud”alchemy/GCP is a new provider
(#1336) — thanks
Michael K! It covers about 1,050
resources across 190 services, with Effect-native runtimes on every GCP
compute host:
| Host | Effect runtime |
|---|---|
GCP.Function (a Cloud Run Service) |
fetch |
GCP.Run.Job |
run, exits on completion |
GCP.Run.WorkerPool |
run, long-lived |
GCP.CloudFunctions.Function |
fetch on Node 22 |
Kubernetes.Deployment / Job on GKE |
same as EKS, through Workload Identity |
Bindings follow the same shape as AWS and Cloudflare. Each yield*
grants one role to the host’s runtime service account, scoped as tightly
as GCP allows, and the grant is revoked when the binding is removed.
Here is an order service: a Cloud Run API that stores orders in Firestore and publishes an event, and a second service that consumes those events from Pub/Sub. Start with the database and the queue:
import * as GCP from "alchemy/GCP";
export const Orders = GCP.Firestore.Database("Orders", { location: "us-central1", type: "FIRESTORE_NATIVE",});
export const OrderEvents = GCP.PubSub.Topic("OrderEvents", {});Websites on Cloud Run
Section titled “Websites on Cloud Run”GCP.Website adds framework websites and StaticSite on Cloud Run
(#1898). Choose from
Next.js, Nuxt, Astro, SvelteKit, TanStack Start, React Router, SolidStart,
Waku, Vite, Vocs, Vinext, Foldkit, and Octane. The shared options cover
public access, ingress, scaling, and container resources.
const site = yield* GCP.Website.Nextjs("Web", { rootDir: "./web",});A database
Section titled “A database”The API writes each order to Firestore and announces it on the topic:
import * as GCP from "alchemy/GCP";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";import { OrderEvents, Orders } from "./resources.ts";
export default class Api extends GCP.Function<Api>()( "Api", { main: import.meta.url, location: "us-central1", invokerIamDisabled: true }, Effect.gen(function* () { // datastore.user, under an IAM Condition naming only this database const db = yield* GCP.Firestore.ReadWriteDatabase(Orders); // pubsub.publisher on this topic only const events = yield* GCP.PubSub.WriteTopic(OrderEvents);
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const { email, total } = (yield* request.json) as { email: string; total: number }; const orderId = crypto.randomUUID();
yield* db.set(`orders/${orderId}`, { email, total, status: "created" }); yield* events.publish({ data: JSON.stringify({ orderId, email, total }) });
return yield* HttpServerResponse.json({ orderId }, { status: 202 }); }).pipe(Effect.orDie), }; }).pipe( Effect.provide([GCP.Firestore.ReadWriteDatabaseHttp, GCP.PubSub.WriteTopicHttp]), ),) {}A queue consumer
Section titled “A queue consumer”The Fulfillment service subscribes to the topic and marks each order
fulfilled. consumeTopicMessages creates the push subscription and the
route it delivers to:
import * as GCP from "alchemy/GCP";import * as Effect from "effect/Effect";import * as Stream from "effect/Stream";import * as HttpServerResponse from "effect/http/HttpServerResponse";import { OrderEvents, Orders } from "./resources.ts";
export default class Fulfillment extends GCP.Function<Fulfillment>()( "Fulfillment", { main: import.meta.url, location: "us-central1" }, Effect.gen(function* () { const db = yield* GCP.Firestore.WriteDatabase(Orders);
yield* GCP.PubSub.consumeTopicMessages(OrderEvents, (messages) => messages.pipe( Stream.runForEach(({ message }) => Effect.gen(function* () { const { orderId } = JSON.parse( Buffer.from(message.data ?? "", "base64").toString("utf8"), ) as { orderId: string }; yield* db.update(`orders/${orderId}`, { status: "fulfilled" }); }), ), // A failure answers the push with a 500, so Pub/Sub redelivers. Effect.orDie, ), );
return { fetch: Effect.succeed(HttpServerResponse.text("ok")) }; }).pipe( Effect.provide([GCP.Run.TopicEventSource, GCP.Firestore.WriteDatabaseHttp]), ),) {}Fulfillment has no public URL. Pub/Sub signs each push with an OIDC
token for the service’s own account, which gets run.invoker on the
service, and every request’s token is verified before the handler runs.
Jobs and WorkerPools consume the same topic by pulling instead, with
GCP.Run.TopicPullEventSource.
Add both services to a stack:
import * as Alchemy from "alchemy";import * as GCP from "alchemy/GCP";import * as Effect from "effect/Effect";import Api from "./src/Api.ts";import Fulfillment from "./src/Fulfillment.ts";
export default Alchemy.Stack( "Orders", { providers: GCP.providers(), state: Alchemy.localState() }, Effect.gen(function* () { const api = yield* Api; yield* Fulfillment; return { url: api.uri }; }),);Storage, Pub/Sub, Firestore, and BigQuery have Read / Write /
ReadWrite clients. GCP.SQL.Connect reaches Cloud SQL,
GCP.Run.InvokeService calls another private service with a
Google-signed ID token, and GCP.AIPlatform.GenerateContent calls
Gemini. Besides Pub/Sub, event sources cover Storage, Cloud Scheduler,
and Eventarc.
The default region comes from your profile (else us-central1), and
GCP.Region("europe-west1") overrides it for a stack. Reconciles wait
until GCP reports a resource ready, deletes wait until it is gone, and
pnpm nuke:gcp clears a test project. Sixteen examples — including
gcp-pubsub-fanout, gcp-event-pipeline, gcp-cloud-sql-drizzle,
gcp-vertex-ai, and gcp-gke — each pass a live deploy-and-destroy
test.
Docs: GCP · Setup · Bindings · Event pipeline.
Neon: Functions, storage, Auth, and websites
Section titled “Neon: Functions, storage, Auth, and websites”alchemy/Neon grows from Projects and Branches into a full backend
(#1695). A
Neon.Function runs a native Fetch handler or an Effect program on
Node 24, and bindings wire in Postgres and object storage without
passing your deployment API key to the application:
export const Backend = Effect.gen(function* () { const project = yield* Neon.Project("Db", { region: "aws-us-east-2", migrations: "./migrations", }); const uploads = yield* Neon.Bucket("Uploads", { project, access: "private" }); return { project, uploads };});export default class Api extends Neon.Function<Api>()( "Api", Effect.gen(function* () { const { project } = yield* Backend; return { project, main: import.meta.url }; }), Effect.gen(function* () { const { project, uploads } = yield* Backend; const db = yield* Neon.Connect(project); const sql = yield* SQL.Postgres({ url: db.connectionString }); const files = yield* Neon.WriteBucket(uploads);
return { fetch: Effect.gen(function* () { const rows = yield* sql`SELECT now() AS time`; yield* files.put("latest.json", JSON.stringify(rows)); return yield* HttpServerResponse.json(rows); }).pipe(Effect.orDie), }; }).pipe(Effect.provide([Neon.ConnectHttp, Neon.WriteBucketHttp])),) {}Thirteen frameworks deploy as Fetch Functions, with the same props as on the other providers:
export default Alchemy.Stack( "App", { providers: Neon.providers(), state: Alchemy.localState() }, Effect.gen(function* () { const api = yield* Api; const site = yield* Prisma.Website.Astro("Blog", { const site = yield* Neon.Website.Astro("Blog", { rootDir: "./apps/blog", env: { API_BASE: api.url }, }); return { url: site.url }; }),);Also in the box:
- Storage —
Bucket, typedObject<T>seed values,Read/Write/ReadWritebucket and object bindings, and presigned GET/PUT URLs. - Auth — managed Better Auth with
AuthOAuthProvider,AuthTrustedDomain, and aConnectAuthbinding for the Auth and JWKS URLs. - Data API —
DataApiconfigures PostgREST;QueryDataApiforwards the caller’s JWT so row-level security applies. - AI Gateway —
QueryAIGatewayplusmakeLanguageModelLayerfor Effect AI’sLanguageModel. - Events —
CronEventSourceandBucketEventSourceregister a typed handler and its trigger in one call. - Operations —
Credential,CustomDomain, and experimental organization governance: API keys, member roles, spending limits, and VPC endpoints.
alchemy dev runs each website’s native framework server and a local
Fetch runtime for Functions; .pipe(Alchemy.remote()) deploys one for
real.
Docs: Neon · Tutorial · Preview branches · Production Auth.
Secret providers: dotenv, Doppler, and Infisical
Section titled “Secret providers: dotenv, Doppler, and Infisical”A stack now chooses where its secrets come from
(#1728) — thanks
Rahul Mishra! Every
Config.Redacted("DATABASE_URL") in your program — in the stack, in a
resource provider, or captured by a Worker or Lambda — resolves from the
list you give Alchemy.Stack’s new secrets option. Nothing is written
to process.env or to disk.
import * as Alchemy from "alchemy";import * as Cloudflare from "alchemy/Cloudflare";import * as Doppler from "alchemy/Doppler";import * as Effect from "effect/Effect";import Api from "./src/api.ts";
export default Alchemy.Stack( "MyApp", { providers: Cloudflare.providers(), state: Cloudflare.state(), secrets: [ Doppler.Secrets(({ stage }) => ({ project: "my-app", config: stage === "prod" ? "prd" : "dev", })), Alchemy.Secrets.DotEnv(), ], }, Effect.gen(function* () { const api = yield* Api; return { url: api.url }; }),);The Worker reads the secret the same way it always has:
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const databaseUrl = yield* Config.Redacted("DATABASE_URL"); const sql = yield* SQL.Postgres({ url: Effect.succeed(databaseUrl) }); return { fetch: Effect.gen(function* () { return yield* HttpServerResponse.json(yield* sql`SELECT now() AS time`); }).pipe(Effect.orDie), }; }),) {}Rotating the value in Doppler and running alchemy deploy now ships it:
a changed Config value read during a Worker’s Init plans an update
(#1837).
Precedence
Section titled “Precedence”Later entries override earlier ones. A missing key falls through to the
next source, and an empty string counts as a value. The process
environment is always the implicit last entry, so a one-off
DATABASE_URL=… alchemy deploy or a CI runner’s exported variables
still win. List Alchemy.Secrets.ProcessEnv() yourself to rank it
elsewhere, or pass { disabled: true } to leave the shell out.
Per stage
Section titled “Per stage”secrets and every provider’s options also accept a callback that
receives the stage, so one stack serves dev, pr-42, and prod from
different places:
secrets: ({ stage }) => stage === "dev" ? Alchemy.Secrets.DotEnv({ path: [".env", ".env.local"] }) : Infisical.Secrets({ project: "my-app", environment: stage, path: "/api" }),Logging in, locally and in CI
Section titled “Logging in, locally and in CI”On your machine, alchemy profile edit --add Doppler opens a browser
login, and alchemy profile edit --add Infisical stores a Universal
Auth machine identity or a user token. In CI, set DOPPLER_TOKEN /
INFISICAL_TOKEN, or skip long-lived tokens entirely with OIDC: set
DOPPLER_IDENTITY_ID or INFISICAL_IDENTITY_ID, and Alchemy exchanges
the platform’s identity token on GitHub Actions, GitLab CI, Vercel, or
Google Cloud for a short-lived one on every run:
jobs: deploy: permissions: id-token: write contents: read steps: - run: bun alchemy deploy --stage prod --yes env: DOPPLER_IDENTITY_ID: ${{ vars.DOPPLER_IDENTITY_ID }}--log-level debug prints which variable names each source loaded,
never their values. Any Effect Layer that provides a ConfigProvider
works as a custom entry.
Docs: Secret providers · Doppler · Infisical.
Prisma ORM v8
Section titled “Prisma ORM v8”Prisma ORM v8 runs on an Effect-native Postgres runtime (#1198). Queries, prepared statements, streams, and transactions are Effects with typed errors and per-execution connection cleanup. Write the contract in TypeScript or in Prisma Schema Language (PSL); both use the same runtime. Prisma stays an optional peer.
TypeScript-first
Section titled “TypeScript-first”Define the contract with Prisma’s own builders:
import { defineContract } from "alchemy/Prisma/ORM";
export const contract = defineContract({}, ({ field, model, rel }) => ({ models: { User: model("User", { fields: { id: field.id.uuidv7Native(), email: field.text().unique(), name: field.text(), }, relations: { posts: rel.hasMany("Post", { by: "authorId" }) }, }), Post: model("Post", { fields: { id: field.id.uuidv7Native(), title: field.text(), authorId: field.uuidNative(), }, relations: { author: rel.belongsTo("User", { from: "authorId", to: "id" }) }, }), },}));Pass it straight to Postgres — no generated code:
import * as Cloudflare from "alchemy/Cloudflare";import * as PrismaPostgres from "alchemy/Prisma/ORM/Postgres";import * as Effect from "effect/Effect";import * as HttpServerResponse from "effect/http/HttpServerResponse";import { Hyperdrive } from "./db.ts";import { contract } from "./prisma/contract.ts";
export default class Api extends Cloudflare.Worker<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const conn = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); const db = yield* PrismaPostgres.Postgres(conn.connectionString, { contract });
return { fetch: Effect.gen(function* () { const users = yield* db.orm.public.User.include("posts").all(); return yield* HttpServerResponse.json({ users }); }).pipe(Effect.orDie), }; }).pipe(Effect.provide(Cloudflare.Hyperdrive.ConnectBinding)),) {}PSL-first
Section titled “PSL-first”Keep contract.psl authoritative:
model User { id id.uuidv7Native() email String @unique name String posts Post[]}
model Post { id id.uuidv7Native() title String authorId Uuid author User @relation(fields: [authorId], references: [id])}Register Alchemy’s Effect generator in prisma.config.ts, then run
alchemy prisma generate (or --watch):
import { defineConfig as ormConfig } from "@prisma/orm-postgres/config";import { definePrismaConfig } from "prisma/config";import { withEffect } from "alchemy/Prisma/ORM/generator";
export default definePrismaConfig({ orm: withEffect( ormConfig({ contract: "./src/prisma/contract.psl", output: "./src/prisma/generated", }), { client: true, schemas: true }, ),});The Worker imports the generated makeDatabase instead of the
contract; queries are identical:
import * as PrismaPostgres from "alchemy/Prisma/ORM/Postgres";import { contract } from "./prisma/contract.ts";import { makeDatabase } from "./prisma/generated/client.ts";
Effect.gen(function* () { const conn = yield* Cloudflare.Hyperdrive.Connect(Hyperdrive); const db = yield* PrismaPostgres.Postgres(conn.connectionString, { contract }); const db = yield* makeDatabase(conn.connectionString);Both forms can also produce standalone Effect Schemas for rows —
makeSchemas(contract) or the generated schemas.ts. The
cloudflare-neon-prisma and cloudflare-neon-prisma-psl examples run
each form end to end on Neon through Hyperdrive, with migrations
applied on deploy by Prisma.Contract and Prisma.Migrate.
Docs: Contracts · Postgres · Migrations.
Fly: a Service owns its App
Section titled “Fly: a Service owns its App”A Fly.Service is now its own Fly App with its own public URL
(#1794). You no
longer declare an App and an IP address first:
export const Site = Fly.App("Site");export const PublicIp = Fly.IpAssignment("Shared", { app: Site, type: "shared_v4" });
export default class Api extends Fly.Service<Api>()( "Api", { app: Site, main: import.meta.url }, { main: import.meta.url }, Effect.gen(function* () { return { fetch: Effect.succeed(HttpServerResponse.text("hello")) }; }),) {}public: false keeps a Service off the internet. A Service can return
methods beside fetch, and another Service binds it with
Fly.bindService for a typed client:
export default class Users extends Fly.Service<Users>()( "Users", { main: import.meta.url, public: false }, Effect.gen(function* () { return { list: () => Effect.succeed(USERS) }; }),) {}
// src/gateway.tsexport default class Gateway extends Fly.Service<Gateway>()( "Gateway", { main: import.meta.url }, Effect.gen(function* () { const users = yield* Fly.bindService(Users); return { fetch: Effect.gen(function* () { return yield* HttpServerResponse.json(yield* users.list()); }).pipe(Effect.orDie), }; }),) {}Calls are plain HTTP over Flycast. Each one carries the caller’s token
and Fly’s signed Fly-Src header, so only Services that bind Users
can call it. network: yield* Fly.stackNetwork isolates Services on a
private network per stack and stage, and region: ["iad", "lhr"] runs
count Machines in each region behind one hostname.
Blue/green deploys
Section titled “Blue/green deploys”Add deploy: { strategy: "bluegreen" } and the next alchemy deploy
starts a checked replacement before stopping the running version
(#1706).
shutdown gives in-flight responses time to finish:
export default class Api extends Fly.Service<Api>()( "Api", { main: import.meta.url, deploy: { strategy: "bluegreen" }, shutdown: { timeout: "60 seconds" }, },Websites take the same two props. An interrupted rollout resumes on the next deploy, and a failed readiness check leaves the old version serving. Rolling updates stay the default and remain the only option for volume-backed Services.
Multi-container Machines
Section titled “Multi-container Machines”Run an API and its sidecar in one Machine with Fly’s native container groups, rolling or blue/green (#1768, #1769) — thanks Florian Bienefelt!
export const App = Fly.App("Preview");
export const Preview = Fly.Machine("Preview", { app: App, containers: [ { name: "api", image: "ghcr.io/acme/api:v3", healthChecks: [{ http: { port: 3000, path: "/health" } }], }, { name: "worker", image: "ghcr.io/acme/worker:v3", dependsOn: [{ name: "api", condition: "healthy" }], }, ],});Florian also added Flycast private_v6 addresses to
Fly.IpAssignment
(#1749) and made
rolling updates wait for service checks before touching the next
replica (#1670).
Fly bindings now carry their values through the runtime context
(#1554) — thanks
Michael K!
Docs: Services · Connecting Services · Deployments · Networking.
Deploy part of a stack
Section titled “Deploy part of a stack”--include and --exclude reconcile a subset of resources and Actions
(#1757). Each flag
takes an FQN, a logical ID, or a glob, and can repeat:
alchemy deploy --stage pr-123 --include 'Database/**'alchemy dev --exclude 'Services/Background/**'alchemy plan --include 'Database/Branch' --include 'Database/Password'Dependencies of included resources come along automatically; excluding
one fails the plan with its dependency chain. Excluded state is left
untouched. Test.make’s deploy takes the same filters:
const { test, deploy } = Test.make({ providers: Cloudflare.providers() });
test( "migrates the database", Effect.gen(function* () { yield* deploy(Stack, { include: ["Database/**"] }); }),);Durable callbacks
Section titled “Durable callbacks”Alchemy.makeCallback schedules typed work that survives the request
(#1668). Durable
Objects are the first host: jobs live in SQLite and fire from native
alarms, and scheduling joins the storage transaction, so the write and
the job commit together:
export default class Document extends Cloudflare.DurableObject<Document>()( "Document", Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.gen(function* () { const onSnapshot = yield* Alchemy.makeCallback( "snapshot", Effect.fn(function* (payload: { revision: string; body: string }) { yield* state.storage.put(`revisions:${payload.revision}`, payload.body); }), );
return { save: Effect.fn(function* (revision: string, body: string) { yield* state.storage.transaction( Effect.gen(function* () { yield* state.storage.put("document", { revision, body }); yield* onSnapshot.schedule(revision, { after: "30 seconds", payload: { revision, body }, }); }), ); }), }; }); }),) {}Delivery is at least once with retries. Existing scheduleEvent jobs
keep running beside callbacks on the object’s single alarm. Alarm
updates are now batched once per transaction
(#1701).
Docs: Durable Objects → Register a durable callback.
Hibernating WebSocket RPC
Section titled “Hibernating WebSocket RPC”An RpcDurableObject that returns its handler Layer now serves Effect
RPC over hibernating WebSockets as well as HTTP
(#1705). The
request picks the transport; your code doesn’t change.
Define the RPCs once and share them between server and client:
import * as Schema from "effect/Schema";import { Rpc, RpcGroup } from "effect/rpc";
export class CounterRpcs extends RpcGroup.make( Rpc.make("increment", { payload: {}, success: Schema.Number }),) {}Implement them in an RpcDurableObject:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { CounterRpcs } from "./rpcs.ts";
export default class Counter extends Cloudflare.RpcDurableObject<Counter>()( "Counter", { schema: CounterRpcs }, Effect.gen(function* () { const state = yield* Cloudflare.DurableObjectState; return Effect.gen(function* () { return CounterRpcs.toLayer({ increment: () => state.storage.transaction( state.storage.get<number>("count").pipe( Effect.map((n) => (n ?? 0) + 1), Effect.tap((n) => state.storage.put("count", n)), ), ).pipe(Effect.orDie), }); }); }),) {}The Worker picks an object by name and forwards the connection to it:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";import Counter from "./counter.ts";
export default class CounterWorker extends Cloudflare.Worker<CounterWorker>()( "CounterWorker", { main: import.meta.url }, Effect.gen(function* () { const counters = yield* Counter; return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const path = new URL(request.url, "https://worker").pathname; const name = /^\/counters\/([\w-]+)$/.exec(path)?.[1]; if (!name) return HttpServerResponse.empty({ status: 404 }); return yield* counters.fetch(name, request); }), }; }),) {}In the browser, alchemy/Cloudflare/RpcWebSocketClient builds a typed
client over one WebSocket. It imports only Effect modules, so it is safe
to bundle:
import * as RpcWebSocketClient from "alchemy/Cloudflare/RpcWebSocketClient";import * as Context from "effect/Context";import * as Effect from "effect/Effect";import * as Layer from "effect/Layer";import * as RpcClient from "effect/rpc/RpcClient";import type { RpcClientError } from "effect/rpc/RpcClientError";import * as Socket from "effect/socket/Socket";import { CounterRpcs } from "./rpcs.ts";
class CounterClient extends Context.Service< CounterClient, RpcClient.FromGroup<typeof CounterRpcs, RpcClientError>>()("CounterClient") {}
const CounterClientLive = RpcWebSocketClient.layer( CounterClient, CounterRpcs, "wss://example.com/counters/alice",).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal));
const program = Effect.gen(function* () { const counter = yield* CounterClient; return yield* counter.increment({});}).pipe(Effect.provide(CounterClientLive));Every call on that socket goes to the "alice" object. While the
connection is idle the Durable Object can hibernate; the next call wakes
it and is answered on the same socket. Alchemy handles the heartbeats and
restores the connection after a wake. In-flight calls are not replayed:
if the object restarts mid-call, the socket closes with code 1012.
Docs: Effect RPC → HTTP and WebSocket RPC · Forward browser connections.
Typed WebSocket attachments
Section titled “Typed WebSocket attachments”If you write your own WebSocket handlers on a plain DurableObject
instead of using RPC, you can now validate the data you attach to each
socket with an Effect Schema
(#1723). A session
read back after hibernation comes back decoded and typed, where
deserializeAttachment used to return an unchecked value:
import * as Cloudflare from "alchemy/Cloudflare";import * as Effect from "effect/Effect";import * as Schema from "effect/Schema";
const Session = Schema.Struct({ id: Schema.String, joinedAt: Schema.DateFromString,});
export default class Room extends Cloudflare.DurableObject<Room>()( "Rooms", Effect.gen(function* () { return Effect.gen(function* () { return { fetch: Effect.gen(function* () { const [response, socket] = yield* Cloudflare.upgrade(); const joinedAt = yield* Effect.sync(() => new Date()); yield* socket.setAttachment(Session, { id: crypto.randomUUID(), joinedAt }); return response; }), webSocketMessage: Effect.fn(function* (socket: Cloudflare.WebSocket) { const session = yield* socket.getAttachment(Session); // joinedAt: Date yield* socket.send(`joined ${session.joinedAt.toISOString()}`); }), }; }); }),) {}A missing or invalid attachment fails with a typed
WebSocketAttachmentError, so you decide whether to close the socket or
ignore it.
Docs: Hibernatable WebSockets → Validate attachments after hibernation.
Presigned R2 URLs in alchemy dev
Section titled “Presigned R2 URLs in alchemy dev”Presigned R2 URLs and S3 clients work under alchemy dev with no extra
configuration (#1793).
The same code signs against the Worker’s local dev URL in dev and
against r2.cloudflarestorage.com when deployed:
export const Uploads = Cloudflare.R2.Bucket("Uploads");
export default Cloudflare.Worker( "Api", { main: import.meta.url }, Effect.gen(function* () { const presignPut = yield* Cloudflare.R2.PresignPutObject(Uploads); return { fetch: Effect.gen(function* () { const url = yield* presignPut({ key: "uploads/a.png", contentType: "image/png" }); return yield* HttpServerResponse.json({ url }); }).pipe(Effect.orDie), }; }).pipe(Effect.provide(Cloudflare.R2.PresignPutObjectToken)),);Async Workers get the same thing through
Cloudflare.R2.S3Credentials(bucket, { access: "write" }), which
delivers an endpoint and scoped keys for any S3 client. The local S3
endpoint runs inside the Worker’s existing workerd process, and the dev
proxy now answers Expect: 100-continue, which took a 3 MB AWS SDK
PutObject from ~6 s to ~130 ms.
Docs: R2 presigned URLs.
Queue subscriptions from any resource
Section titled “Queue subscriptions from any resource”Cloudflare.Queues.Subscription accepts a resource or a .ref(...) as
its source (#1722,
#1505) — including
one owned by another stack:
export const BucketEvents = Effect.gen(function* () { const queue = yield* Cloudflare.Queues.Queue("Events"); const uploads = yield* Cloudflare.R2.Bucket.ref("Uploads", { stack: "storage", stage: "prod", }); return yield* Cloudflare.Queues.Subscription("BucketEvents", { source: uploads, events: ["bucket.created", "bucket.deleted"], queueId: queue.queueId, });});Sources include Images, KV, R2, Vectorize, Workers, Workflows, and two
new resources: AI.Model, a handle on a Workers AI catalog model, and
R2.SuperSlurperJob, a one-off migration into R2 from S3 or another
bucket. Thanks Alex for the Workflow
sources and Cloudflare.Workflow.ref!
Docs: Queues → Use resources as event sources.
ACME certificates
Section titled “ACME certificates”alchemy/ACME issues and renews certificates from Let’s Encrypt or any
ACME CA, independent of where they are used
(#1702).
Cloudflare.DNS.AcmeSolver answers DNS-01 challenges:
export const Site = Fly.App("Site");
export const Wildcard = Effect.gen(function* () { const account = yield* ACME.Account("LetsEncrypt", { ca: ACME.LetsEncrypt, termsOfServiceAgreed: true, }); const certificate = yield* ACME.Certificate("Wildcard", { account, identifiers: ["*.example.com"], solver: Cloudflare.DNS.AcmeSolver({ zoneId: Config.String("CLOUDFLARE_ZONE_ID"), }), }); return yield* Fly.Certificate("Upload", { app: Site, hostname: "*.example.com", kind: "custom", fullchain: certificate.chain, privateKey: certificate.privateKey, });});ACME.IssueCertificate binds an account into a Worker or Service for
runtime issuance, and Fly.WriteCertificates manages an App’s
certificates at runtime.
Docs: ACME · Getting started.
Kubernetes on any cluster
Section titled “Kubernetes on any cluster”Kubernetes.LocalCluster starts a kind cluster with a local image
registry, and any connection can now name a registry, so workloads
built from main or a Docker context deploy to GKE, AKS, k3s, or your
laptop (#1779):
export const Cluster = Effect.gen(function* () { const stage = yield* Alchemy.Stage; if (stage === "prod") { // any cluster kubectl can reach return Kubernetes.KubeConfig({ context: "prod", registry: { server: "ghcr.io/acme" }, architecture: "arm64", }); } return yield* Kubernetes.LocalCluster("Cluster", { name: "alchemy" });});Editing a Job or Deployment’s main program now redeploys it. A
new /kubernetes docs hub has a five-part tutorial that starts on
LocalCluster and ends on your own cluster.
Docs: Kubernetes · Tutorial · Local cluster.
Workflow tasks keep typed errors
Section titled “Workflow tasks keep typed errors”Cloudflare.Workflows.task accepts fallible Effects
(#1723). A failure
follows the task’s retry policy; once retries run out, catchTag sees
your error, including after Cloudflare replays the step:
class CardDeclined extends Data.TaggedError("CardDeclined")<{ reason: string;}> {}
const chargeCard = (orderId: string) => Effect.fail(new CardDeclined({ reason: `insufficient funds for ${orderId}` }));
export default class Checkout extends Cloudflare.Workflow<Checkout>()( "Checkout", Effect.gen(function* () { return Effect.fn(function* (input: { orderId: string }) { return yield* Cloudflare.Workflows.task("charge", chargeCard(input.orderId), { retries: { limit: 2, delay: "1 second", backoff: "exponential" }, }).pipe( Effect.catchTag("CardDeclined", (error) => Effect.succeed({ status: "declined" as const, reason: error.reason }), ), ); }); }),) {}Effect.orDie still makes a failure terminal. Workflows can also take
an explicit account-global workflowName
(#1460) — thanks
Odysseas Papadimas! A
retries-only step config no longer crashes the engine
(#1142) — thanks
apostoli! Each attempt gets its
own scope (#1701).
Docs: Workflows → Application errors across replay.
Vinext support
Section titled “Vinext support”Website.Vinext deploys vinext
apps on Cloudflare, AWS, Fly, Hetzner, Railway, and Prisma
(#1515) — thanks
Ray! Keep your ordinary
vinext() Vite config; no Alchemy plugin is needed.
export default Alchemy.Stack( "Web", { providers: Cloudflare.providers(), state: Cloudflare.state() }, Effect.gen(function* () { const site = yield* Cloudflare.Website.Vinext("Web", { rootDir: "./app", env: { GREETING: "Hello from vinext!" }, }); return { url: site.url }; }),);On Cloudflare it deploys a Worker with KV caching and prerender seeding,
without Wrangler or OpenNext. AWS runs it on streaming Lambda with an S3
cache behind CloudFront. Fly, Hetzner, Railway, and Prisma run vinext’s
Node server, with an in-memory cache by default and Redis through
REDIS_URL. Swap the provider namespace to move the same app:
export default Alchemy.Stack( "Web", { providers: Cloudflare.providers(), state: Cloudflare.state() }, { providers: AWS.providers(), state: AWS.state() }, Effect.gen(function* () { const site = yield* Cloudflare.Website.Vinext("Web", { const site = yield* AWS.Website.Vinext("Web", { rootDir: "./app", env: { GREETING: "Hello from vinext!" }, }); return { url: site.url }; }),);alchemy dev runs vinext’s native dev server with HMR, and
.pipe(Alchemy.remote()) deploys it for real.
Docs: Cloudflare · AWS · Fly · Hetzner · Railway · Prisma.
Next.js and Octane with less config
Section titled “Next.js and Octane with less config”Next.js on Cloudflare no longer needs open-next.config.ts, and
writable ISR is one prop
(#1747):
export const IncCache = Cloudflare.KV.Namespace("NextIncCache");export const TagCache = Cloudflare.KV.Namespace("NextTagCache");
export const Website = Cloudflare.Website.Nextjs("Website", { env: { NEXT_INC_CACHE_KV: IncCache, NEXT_TAG_CACHE_KV: TagCache, NEXT_CACHE_DO_QUEUE: Cloudflare.DurableObject("NEXT_CACHE_DO_QUEUE", { className: "DOQueueHandler", }), }, isr: { incrementalCache: IncCache, tagCache: TagCache },});Octane apps no longer need a hosting adapter in octane.config.ts on
any provider (#1747,
#1755,
#1756). SvelteKit
moves to 3.0.0-next.27, and the Node target now ships client assets
and prerendered pages
(#1709) — thanks
Cameron McEvenue! The Next.js Node
and Neon servers start from the built config, so a read-only deploy no
longer tries to download SWC
(#1805) — thanks
Aman Varshney!
Docs: Frontend frameworks · Next.js · Octane.
Local development
Section titled “Local development”- No credentials needed for local-only stacks (#1801). Cloudflare authentication is deferred until a cloud call needs it, so a stack of local Workers, KV, R2, and D1 runs without a profile.
- Provider credentials resolve on first use
(#1902). Loading
provider layers no longer requires credentials for every provider.
When a cloud operation needs missing or unreadable credentials, plan
and apply fail with
CredentialsRequired. - workerd restarts after a crash
(#1772) on the
same ports, so the dev URL keeps working. Raise the isolate’s heap with
ALCHEMY_WORKERD_V8_FLAGS=--max-old-space-size=4096(#1771), and the Vite module runner no longer retains every old module graph across HMR edits (#1770). Thanks Alex! - Private Websites answer service-binding calls in dev
(#1798) for
SvelteKit, Nuxt, Octane, Next.js, Astro, Waku, and Vocs, and a Vite
Worker with SPA fallback plus
runWorkerFirststarts again (#1799). Thanks Rahul Mishra!
Also in this release
Section titled “Also in this release”-
Provider requirements retain their types (#1895) — fixes prevent
anyandunknownfrom hiding required services in provider layers, with stronger validation for provider implementations. -
Provider and runtime fixes for Effect stable (#1899) — Worker preview creation reuses an existing preview after a conflict, and lifecycle fixes cover ACME, CloudFront KeyValueStore, ECS, IAM SAML providers, IVS stream keys, Cloudflare AI evaluations, and Railway.
-
AWS Client VPN (#1714) —
ClientVpnEndpoint,ClientVpnTargetNetworkAssociation,ClientVpnAuthorizationRule, andClientVpnRoute. -
AWS.EC2.DefaultSecurityGroup(#1630) manages the rules on a VPC’s default security group, and ECS Services deploy again after a failed rollout (#1650). Thanks Henning Pokriefke! -
RDS master secret policy and security drift —
masterUserSecretResourcePolicyonDBInstance(#1598), security settings converge from desired state (#1599), and a stopped or failed instance fails fast withDBInstanceReadinessBlocked(#1597). Thanks Bjorn Pagen! -
Least-privilege AWS bindings (#1357) — exact IAM actions per capability, versioned S3 reads and copies, and S3 notification reconciliation across Lambda, SQS, SNS, and EventBridge. SQS queues also reconcile adoption and Standard ↔ FIFO changes. Thanks Saatvik Arya!
-
Cloudflare.Access.McpServer(#1658) registers upstream MCP servers with Access. Thanks Dillion Verma! -
Container images shared across stages (#1692) — Container builds are cached by default, and
publish: { repository: "web" }reuses images and layers across stages. Thanks Dan van der Merwe! -
Cloudflare.Container.ref(#1750) — another stack can read a container application like any other resource. Thanks Cyberistic! -
Durable Object SQL migrations load at construction (#1736) —
yield* Cloudflare.SqlMigrations("./drizzle")replaces importing drizzle-kit’smigrations.js, and each migration commits atomically with its history row. -
Worker build input options (#1700) —
build.inputpasses Rolldown input options such asresolve.alias. -
Cloudflare fixes — full deploys keep
version.tagandversion.message(#1754, thanks Luke O’Malley!), prebuilt Workers keep explicit compatibility flags (#1725), Workers-for-Platforms uploads keep_headersand_redirects(#1802), and DNS records only treat a missing record as gone (#1800). -
Engine fixes — completed Actions reuse their outputs when inputs are unchanged, so downstream resources plan as no-ops (#1379, thanks Leonardo E. Dominguez!); changing a
Configvalue read during a Worker’s Init now plans an update (#1837); and destroy cleans up every generation left by an interrupted replacement (#1704). -
alchemy plan --adopt(#1811). Thanks Pedro Toledo! -
Railway
regionplaces replicas (#1766) onServiceandFunction. -
PlanetScale
withReplication(#1777) onPostgresRolefor logical-replication consumers. Thanks Makisuo! -
Better Auth tutorial (#1732) — a six-part walkthrough from an Auth service to GitHub sign-in, adapted from work by xofromthemoon.
-
Prisma fixes — dev commands default to
NODE_ENV=development(#1703) andalchemy/Prisma, Fly, Hetzner, Railway, and Neon import without@alchemy.run/frontend-frameworksinstalled (#1815). Thanks Aman Varshney, and Kristof Siket for live branch attachment coverage (#1359)! -
Smaller installs —
@effect/vitestis an optional peer again (#1816), and@aws-sdk/credential-providers,aws4fetch, andundiciare gone (#1665, thanks Michael K!). -
Runtime directories and process cleanup — a configured
.alchemydirectory is honored everywhere (#1726), andCommandwrappers getSIGTERMand a second to clean up (#1760). -
Declaration metadata (#1490) —
Api.LogicalIdkeeps its literal type, and Stack references exposestackName. -
Leaner preview packages (#1727, #1681) — a PR publishes only the packages it affects plus their dependencies, and fork PRs install from
pkg.alchemy.run/alchemy/pr:<number>:<sha>. The CLI ships plain JavaScript entrypoints with generated package exports (#1707). Thanks Rahul Mishra! -
Test runner tags and plans (#1773) —
alchemy-testgains--tagsexpressions, phased--planruns, and a--dry-runpreview. -
Faster API reference (#1767, #1774) — one page per service: 4,368 pages become 406, the docs build is 4× faster, and old URLs redirect.
-
SQL guides (#1737, #1744) — a database comparison and deployment guides for AWS (including Aurora and DSQL), Fly, Hetzner, Prisma, and Railway.
-
A new landing page (#1775, #1797) built around the dev → test → review → deploy loop, with a PR lifecycle benchmark you can run from
examples/cloudflare-preview-benchmark.