Skip to content

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.

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:

src/resources.ts
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", {});

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

The API writes each order to Firestore and announces it on the topic:

src/Api.ts
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]),
),
) {}

The Fulfillment service subscribes to the topic and marks each order fulfilled. consumeTopicMessages creates the push subscription and the route it delivers to:

src/Fulfillment.ts
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:

alchemy.run.ts
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:

src/resources.ts
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 };
});
src/api.ts
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:

alchemy.run.ts
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, typed Object<T> seed values, Read/Write/ReadWrite bucket and object bindings, and presigned GET/PUT URLs.
  • Auth — managed Better Auth with AuthOAuthProvider, AuthTrustedDomain, and a ConnectAuth binding for the Auth and JWKS URLs.
  • Data API — DataApi configures PostgREST; QueryDataApi forwards the caller’s JWT so row-level security applies.
  • AI Gateway — QueryAIGateway plus makeLanguageModelLayer for Effect AI’s LanguageModel.
  • Events — CronEventSource and BucketEventSource register 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.

alchemy.run.ts
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:

src/api.ts
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).

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.

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

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 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.

Define the contract with Prisma’s own builders:

src/prisma/contract.ts
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:

src/api.ts
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)),
) {}

Keep contract.psl authoritative:

src/prisma/contract.psl
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):

prisma.config.ts
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.

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:

src/users.ts
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.ts
export 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.

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.

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.

--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:

Terminal window
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/**"] });
}),
);

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.

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:

rpcs.ts
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:

counter.ts
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:

worker.ts
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:

client.ts
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.

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:

room.ts
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 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.

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.

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.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.

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.

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 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.

  • 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 runWorkerFirst starts again (#1799). Thanks Rahul Mishra!
  • Provider requirements retain their types (#1895) — fixes prevent any and unknown from 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, and ClientVpnRoute.

  • 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 — masterUserSecretResourcePolicy on DBInstance (#1598), security settings converge from desired state (#1599), and a stopped or failed instance fails fast with DBInstanceReadinessBlocked (#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’s migrations.js, and each migration commits atomically with its history row.

  • Worker build input options (#1700) — build.input passes Rolldown input options such as resolve.alias.

  • Cloudflare fixes — full deploys keep version.tag and version.message (#1754, thanks Luke O’Malley!), prebuilt Workers keep explicit compatibility flags (#1725), Workers-for-Platforms uploads keep _headers and _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 Config value 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 region places replicas (#1766) on Service and Function.

  • PlanetScale withReplication (#1777) on PostgresRole for 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) and alchemy/Prisma, Fly, Hetzner, Railway, and Neon import without @alchemy.run/frontend-frameworks installed (#1815). Thanks Aman Varshney, and Kristof Siket for live branch attachment coverage (#1359)!

  • Smaller installs — @effect/vitest is an optional peer again (#1816), and @aws-sdk/credential-providers, aws4fetch, and undici are gone (#1665, thanks Michael K!).

  • Runtime directories and process cleanup — a configured .alchemy directory is honored everywhere (#1726), and Command wrappers get SIGTERM and a second to clean up (#1760).

  • Declaration metadata (#1490) — Api.LogicalId keeps its literal type, and Stack references expose stackName.

  • 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-test gains --tags expressions, phased --plan runs, and a --dry-run preview.

  • 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.