2.0.0-beta.74 - AWS Local Dev, Hetzner & Fly.io
beta.74 brings alchemy dev to AWS: your Lambda, S3, DynamoDB,
SQS, ECS, and website stack runs entirely on your machine —
no AWS account, no credentials, hot reload in ~100–500ms, and
each website’s framework runs its own dev server with native
HMR. A new
Hetzner Cloud provider ships 15 resources plus an
Effect-native Service platform that deploys your program to a
server over SSH. A new Fly.io provider brings Machines,
Services, Sprites, and managed Postgres, Redis, and Tigris object
storage. And the six web frameworks that landed on Cloudflare in
beta.70 now deploy to AWS — S3 + CloudFront + streaming
Lambda — with the same interface.
alchemy depends on effect@rc
Section titled “alchemy depends on effect@rc”effect 4.0 has moved from beta to release candidates, and alchemy
now depends on effect’s rc dist-tag
(#1245). The
minimum peer is >=4.0.0-rc.110; install effect and the platform
packages from the rc tag:
bun add alchemy@latest effect@rc @effect/platform-bun@rc @effect/platform-node@rcAWS local dev
Section titled “AWS local dev”alchemy dev now runs your AWS stack on your machine
(#1154) using
our fork of
floci, an MIT LocalStack-style emulator. No
stored profile, no env vars, no config — the emulator starts
automatically in Docker (one shared alchemy-floci container
across projects and sessions) and your stack deploys into it in
seconds:
bun alchemy dev# no AWS credentials — using the local emulatorTake a Lambda Function that owns a bucket and a queue and writes to both:
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]), ),) {}The dev deploy’s outputs all point at the emulator:
Api.functionUrl http://b71ff5c0….lambda-url.us-east-1.localhost:4566/Jobs.queueUrl http://localhost:4566/000000000000/jobs-dev-x7k2Files.bucketName files-dev-x7k2Everything runs locally on your machine: the Lambda executes in
a Docker container behind a working Function URL, SQS→Lambda
event source mappings pump messages, S3 notifications fire,
EventBridge routes, and SNS subscriptions deliver. The bindings
need no configuration — putObject above lands in the local
bucket, because distilled honors the official SDKs’
AWS_ENDPOINT_URL override that floci injects
(#1192,
#1210).
Save the file and the running function serves the new code in ~100–500ms — no redeploy, no restart. The dev session watches your code, rebuilds, and swaps it under the live Function URL.
Mix in the real cloud per resource with Alchemy.remote():
// everything else is local; this one hits real AWSconst secret = yield* AWS.SecretsManager.Secret("ProdSecret", { ... }) .pipe(Alchemy.remote());If a remote resource needs credentials you don’t have,
alchemy dev tells you before touching anything, and switching a
resource between local and live plans a replacement — dev
state never silently becomes cloud state. Resources without local
emulation run against the real cloud as before; stacks that mix
the two work unchanged.
Local emulation covers Lambda (hot reload, Function URLs, event source mappings), S3, DynamoDB, SQS, SNS, EventBridge, Secrets Manager, SSM, IAM, Cognito, Step Functions, Glue, ACM, and more.
That includes the CloudFront edge: an AWS.Website.Router
deploys into the emulator and serves requests on your machine.
Its routing logic is a real CloudFront Function — floci executes
it per request, it reads routes from the local KeyValueStore, and
cf.updateRequestOrigin() forwards to local bucket and Function
URL origins.
We forked floci so that alchemy’s live AWS test suites, which
normally run against real AWS, can run against the emulator and
drive an agentic pipeline that patches it. Every fidelity gap a
test surfaces becomes a fix in the fork instead of a workaround
in alchemy, and hundreds of live tests now run green against the
emulator
(#1250). The
examples/aws-dev suite drives the real alchemy dev CLI end
to end: bindings, queue consumers, and mid-session hot reloads
(#1192).
Alongside this release we published Looping the Generation of Local Emulators, a dedicated post on the fork and the pipeline that drives it. Read that for the full story.
Docs: AWS local development.
ECS tasks run as local containers
Section titled “ECS tasks run as local containers”alchemy dev runs your ECS tasks and services as real Docker
containers on your machine, with hot reload
(#1185):
// bundled Effect program — built into an image, pushed through// a local registry, running as a real container seconds laterexport default class Web extends AWS.ECS.Task<Web>()( "Web", { main: import.meta.url, port: 8080 }, Effect.gen(function* () { return { fetch: Effect.gen(function* () { return HttpServerResponse.text("hello from a local container"); }), }; }),) {}A dev deploy puts a real container on your machine:
$ docker psfloci-ecs-…-web Up 4 seconds 0.0.0.0:8080->8080/tcpBring-your-own-Dockerfile containers work too:
AWS.ECS.Service("Api", { context: "./api", port: 3000 }) builds
your image and runs it the same way.
The whole live pipeline runs unchanged against the emulator:
image build, docker push through a real local OCI registry,
task definitions, RunTask, and services that converge exactly
like AWS. Save a file and the running container swaps in ~6
seconds — bundled tasks watch the deploy’s exact module graph,
Dockerfile tasks watch the build context, and services roll
their tasks onto the new image revision.
An ECS dev stack is now fully local
(#1210): the
VPC/subnet/ALB scaffolding and the event-source glue (SNS
subscriptions, Lambda permissions, EventBridge, Scheduler) run
against the emulator too — previously a dev apply could create a
real VPC and ALB that could never route to local containers. The
emulated ALB serves its listeners locally, so
aws ecs wait services-stable and your load-balanced routes work
without an AWS account.
Hetzner
Section titled “Hetzner”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.
Fly.io
Section titled “Fly.io”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 Redisconst 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")) }; }),) {}Web frameworks on AWS
Section titled “Web frameworks on AWS”The full Website family — plain Vite SPAs plus Next.js, Astro, Nuxt, SvelteKit, Waku, and Octane — now deploys to AWS (#1140), mirroring the Cloudflare architecture: assets and prerendered pages live in a private S3 bucket, the framework server (when there is one) runs on a streaming Lambda Function URL, and a CloudFront Function routes each request at the edge via a KeyValueStore manifest.
const site = yield* AWS.Website.Vite("Web", { domain: { name: "app.example.com", hostedZoneId: zone.hostedZoneId },});A Vite SPA deploys assets-only, with no Lambda at all. The SSR frameworks load your framework’s own config natively; Next.js deploys the full OpenNext serverless topology — streaming server Lambda, image-optimization Lambda, SQS FIFO revalidation queue, DynamoDB tag cache, ISR cache bucket — with IAM wired through the binding channel.
The interface is deliberately identical to Cloudflare’s
(#1158): the
same { name, aliases, redirects } domain shape (standalone or
through a shared Router), the same dev config, and the same
urls/url output contract — custom domain first, CloudFront
default last. Under alchemy dev, each framework’s own dev
server runs with native HMR and no cloud resources; every
cloudflare-website-* example now has an aws-website-* mirror
running the same app
(#1189).
Docs: AWS frontends.
One migration format for SQL databases
Section titled “One migration format for SQL databases”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.
Protect Workers with Access
Section titled “Protect Workers with Access”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.
Removal policies persist
Section titled “Removal policies persist”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.74 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.
Also in this release
Section titled “Also in this release”-
nuke --localclears the emulator (#1287) —alchemy unsafe nuke --localenumerates 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.userDatamerges 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+BucketAccessKeyresources withReadBucket/WriteBucket/ReadWriteBucketbindings, aState/PostgresStatebackend with per-stack advisory locks, andDeployment.triggersto 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
contentprop, with the shape discriminated bytype. 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 (#1245), made process cleanup explicit and scoped (#1175), and improved platform runtime detection (#1197)!
-
Nested Worker env plans converge (#1273) — a Worker bound to an already-yielded Worker no longer plans
updateforever on identical deploys. Thanks Odysseas Papadimas! -
alchemy devsurvives 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.
-
Dependency security bumps (#1255) — the vulnerable
extract-zipand libvips advisories are out of the runtime’s dependency closure. -
Worker.domainpins its zone (#1241) —zoneId/zone/zoneNameondomain, 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
envbecomes 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
zipCodearchives 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/vitestmoves 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
.makeLayer names itself (#1055) — yielding a bare-tag resource without its implementation Layer dies with aMissingImplementationErrorexplaining exactly what to provide, instead of aTypeErrordeep in a provider. Thanks apostoli! -
Circular Worker layers typecheck (#1199) —
Worker.makeno longer leaks the other Worker’s tag intoLayer.mergeAllrequirements. Thanks Patrik Duksin!