Compare
Alchemy vs existing IaC solutions
Most existing IaC solutions wrap terraform or are managed services in some way. Alchemy ships its own IaC engine in TypeScript, opening the door for more fully integrated features: testing, engine, bindings, permissions, and local dev.
At a glance
| Alchemy | SST | Pulumi | Terraform | AWS CDK | |
|---|---|---|---|---|---|
| Language | TypeScript + Effect | TypeScript | TypeScript, Python, Go, C#, Java | HCL | TypeScript, Python, Java, C#, Go |
| Built on | Pure TypeScript, end to end | Pulumi, mostly bridged Terraform providers | A Go engine, many providers bridged from Terraform | A Go engine and Go provider plugins | CloudFormation, a managed engine inside AWS |
| App code in the same program | Yes, as typed Effects and Layers | No | No | No | No |
| Permissions | Derived from the bindings your code uses | Granted by link | Unenforced | Unenforced | Granted by grant*() calls |
| Runs locally | Emulated on your machine | No, it deploys to the cloud | No | No | No |
| Tests against real infrastructure | Deploy, assert, destroy from bun test or Vitest | No | No | No | No |
A complete iteration loop for agents
Alchemy Test deploys a real stack, runs tests on that deployment, then tears it down all from the test runner you already use. All agent's have to do to test against real infrastructure is make a code change, then run a test file; this keeps the loop extremely tight and lets ai agents move fast without breaking. Our preview benchmark takes 17 seconds for 1 full loop, no competitor even has a full loop.
const stack = beforeAll(deploy(Stack)); afterAll(destroy(Stack)); test("stores a session", Effect.gen(function* () { const { url } = yield* stack; const res = yield* HttpClient.post(`${url}/session`); expect(res.status).toBe(200); }));
# 1. deploy the stage yourself sst deploy --stage test # 2. run tests with linked resources sst shell --stage test -- vitest run # 3. tear it down yourself, even on failure sst remove --stage test
const stack = await LocalWorkspace.createOrSelectStack({ stackName: "test", projectName: "api", program, }); const { outputs } = await stack.up(); // pass outputs to your tests yourself… await runTests(outputs.url.value); // …and clean up yourself, even when they fail await stack.destroy();
run "creates_table" { command = apply assert { condition = ( aws_dynamodb_table.sessions.billing_mode == "PAY_PER_REQUEST" ) error_message = "wrong billing mode" } } # asserts on resource attributes only; # nothing here calls your running app
One test file closes the loop
beforeAll(deploy(Stack)) stands up real infrastructure, the test calls it, and afterAll(destroy(Stack)) tears it down, even when a test fails.
Tests can't deploy & destroy
sst shell can give tests access to resources via link, but deploying and tearing down the stack still need to be done outside of the test runner.
Make your own test runner
Pulumi exposes and automation api that can deploy and destroy, but passing outputs to tests and cleaning up properly after failures means you need to write your own testing harness.
Tests must be entirely decoupled
Terraform test can assert what resources are deployed, but it doesn't verify whats running in those resources is correct
- part of the loop
- possible, you build it
- can't do what the loop needs
Its own engine, not a wrapper
Every competitor has a core and providers have to talk to that core over a bridge, in some cases even 2. Alchemy just imports providers as typescript and runs them directly, this means alchemy can just deploy resources instead of waiting on events.
1 runtime · TypeScript
3 layers · TypeScript → Go
2 layers · gRPC between them
2 layers · HCL → Go
One line does three jobs
To read a table, a function needs three things that usually live in three different places: a permission, the table's name, and a client. In Alchemy the function's code asks for the capability, and that one line produces all three: its statement in the IAM policy, the table's name, and a typed client.
src/api.tsexport default class Api extends Lambda.Function<Api>()( "Api", { main: import.meta.url }, Effect.gen(function* () { const sessions = yield* DynamoDB.Table("Sessions"); const getSession = yield* DynamoDB.GetItem(sessions); const saveSession = yield* DynamoDB.PutItem(sessions); return { fetch: Effect.gen(function* () { const { Item } = yield* getSession({ Key }); yield* saveSession({ Item: next }); }), }; }),) {}# deployed config for ApiRole: Policy: Version: "2012-10-17" Statement: - Effect: Allow Action: dynamodb:GetItem Resource: arn:…:table/api-sessions-8f2c - Effect: Allow Action: dynamodb:PutItem Resource: arn:…:table/api-sessions-8f2cEnvironment: Variables: Sessions_tableName: api-sessions-8f2c// runtime: typed clientsconst getSession: ( request: GetItemRequest,) => Effect<GetItemOutput, GetItemError>const saveSession: ( request: PutItemRequest,) => Effect<PutItemOutput, PutItemError>GetItemgrants read and returnsgetSessionPutItemgrants write and returnssaveSession
One line asks for read access. It grants exactlydynamodb:GetItem on this table, injects the table's name, and returns a typed client.
Add PutItem and its statement, its env var, and its client all follow. Remove a line and all three go with it.
sst.config.tsconst sessions = new sst.aws.Dynamo("Sessions", { … });new sst.aws.Function("Api", { handler: "src/api.handler", link: [sessions],});src/api.tsimport { Resource } from "sst";const client = new DynamoDBClient({});export const handler = async () => { const TableName = Resource.Sessions.name; const { Item } = await client.send( new GetItemCommand({ TableName, Key })); await client.send( new PutItemCommand({ TableName, Item: next }));};# deployed config for ApiRole: Policy: Version: "2012-10-17" Statement: - Effect: Allow # every DynamoDB action, DeleteTable too Action: "dynamodb:*" Resource: - arn:…:table/app-sessions - arn:…:table/app-sessions/*Environment: Variables: SST_RESOURCE_Sessions: '{"name":"app-sessions",…}'// runtimeconst TableName: string; // typed by link// no client definition: new DynamoDBClient()// and send() any command, allowed or not- generated by
link - more access than the code uses
- written by hand, unchecked
link writes the policy for you, but it grants dynamodb:* on the table, DeleteTable included, while the handler only calls GetItem and PutItem. There's no client definition, so the SDK calls are yours to keep in sync.
index.tsconst sessions = new aws.dynamodb.Table("sessions", { … });const role = new aws.iam.Role("api", { assumeRolePolicy: … });new aws.iam.RolePolicy("api", { role: role.id, policy: sessions.arn.apply((arn) => JSON.stringify({ Version: "2012-10-17", Statement: [{ Effect: "Allow", Action: ["dynamodb:GetItem", "dynamodb:PutItem"], Resource: arn, }], })),});new aws.lambda.Function("api", { role: role.arn, environment: { variables: { TABLE_NAME: sessions.name }, },});# deployed config for apiRole: Policy: Version: "2012-10-17" Statement: - Effect: Allow Action: [dynamodb:GetItem, dynamodb:PutItem] Resource: arn:…:table/sessions-4d1aEnvironment: Variables: TABLE_NAME: sessions-4d1a// runtime: no client definition// the handler builds its own SDK calls// against process.env.TABLE_NAME- IAM policy: every line is yours to write
- env var: yours to wire
Pulumi deploys exactly the policy you define: each statement, action, and ARN in the config traces back to a line you wrote. There's no client definition, so nothing checks your handler's SDK calls against either.
main.tfresource "aws_dynamodb_table" "sessions" { … }resource "aws_iam_role" "api" { assume_role_policy = … }resource "aws_iam_role_policy" "api" { role = aws_iam_role.api.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = ["dynamodb:GetItem", "dynamodb:PutItem"] Resource = aws_dynamodb_table.sessions.arn }] })}resource "aws_lambda_function" "api" { role = aws_iam_role.api.arn environment { variables = { TABLE_NAME = aws_dynamodb_table.sessions.name } }}# deployed config for apiRole: Policy: Version: "2012-10-17" Statement: - Effect: Allow Action: [dynamodb:GetItem, dynamodb:PutItem] Resource: arn:…:table/sessionsEnvironment: Variables: TABLE_NAME: sessions// runtime: no client definition// the handler builds its own SDK calls// against process.env.TABLE_NAME- IAM policy: every line is yours to write
- env var: yours to wire
Terraform deploys exactly the policy you define: each statement, action, and ARN in the config traces back to a line you wrote. There's no client definition, so nothing checks your handler's SDK calls against either.
Principle of least privilege
In Alchemy permission policies are created from your code. This allows least privilege security policies to be inferred on the type level. SST provides link to automate common policy usecases, but are one size fits all, not lease privilege. Pulumi & terraform offer no enforcement, so you have to manually maintain permission policies yourself.
DynamoDB.GetItem(table)grants dynamodb:GetItem on table
Exact
link: [table]grants dynamodb:* on table
Close
a policy you writegrants often dynamodb:* on *
Unenforced
- what the code needs
- granted, scoped by the tool
- granted by a policy you maintain
True emulation for faster dev loops
alchemy dev runs your stack entirely on your machine, or in the cloud, or in a hybrid of both, you get to choose. Our competitors lock you into one testing model and force you to accept the compromises they chose.
Or in some cases they don't offer any dev tooling and you just deploy every time like a sucker stuck in the past.
- a trip across the network
And a few more things
Providers built from the spec
Cloudflare, AWS, Fly, PlanetScale, Prisma, Axiom, Stripe, GitHub, Docker, and more. We are constantly adding more providers.
Runs anywhere TypeScript runs
Alchemy is just typescript, it can run on your machine, inside a cloudflare worker, or even in a browser.
Bring your own provider
A provider is just TypeScript: declare the resource's props, then write its create, update, and delete as plain functions. No Go plugin or bridge to build. Write one.