Compare

More than just Infrastructure as Code

Terraform, Pulumi, SST, and the CDK deploy your infrastructure and stop there. Alchemy is built on its own Infrastructure as Code engine in TypeScript, and builds your application on top of it with Effects and Layers. That opens the door to fully integrated features: app code, testing, engine, bindings, permissions, and local dev.

At a glance

AlchemySSTPulumiTerraformAWS CDK
LanguageTypeScript + EffectTypeScript + GoTypeScript, Python, Go, C#, JavaHCLTypeScript, Python, Java, C#, Go
Built onPure TypeScript, end to endPulumi, mostly bridged Terraform providersA Go engine, many providers bridged from TerraformA Go engine and Go provider pluginsCloudFormation, a managed engine inside AWS
Infrastructure and app code togetherYes, as typed Effects and LayersNoNoNoNo
PermissionsDerived from the bindings your code usesGranted by linkUnenforcedUnenforcedGranted by grant⁠*⁠() calls
Runs locallyEmulated on your machineNo, it deploys to the cloudNoNoNo
Tests against real infrastructureDeploy, assert, destroy from bun test or VitestNoNoNoNo

Infrastructure and app code, together

Other IaC tools only cover your infrastructure. The code that runs on it is a separate program that finds the Bucket through an environment variable, so the two halves meet at one name and every feature has to change in both. In Alchemy the Worker is an Effect that asks for the Bucket itself: one line gives it access when you deploy and a typed client while it runs. Layers then let a service bring its own infrastructure. What is Alchemy?

Other IaC tools: two programs

infra.ts
export const Uploads = Cloudflare.R2.Bucket("Uploads");

export const Api = Cloudflare.Worker("Api", {
  main: "./src/api.ts",
  env: { Uploads },
});
src/api.ts
export default {
  async fetch(request: Request, env: Env) {
    const obj = await env.Uploads.get("hello.txt");
    return obj
      ? new Response(await obj.text())
      : new Response("Not found", { status: 404 });
  },
};

Alchemy: Effects and Layers

src/api.ts
export const Uploads = Cloudflare.R2.Bucket("Uploads"); export default Cloudflare.Worker(  "Api",  { main: import.meta.url },  Effect.gen(function* () {    const bucket = yield* Cloudflare.R2.ReadWriteBucket(Uploads);     return {      fetch: Effect.gen(function* () {        const obj = yield* bucket.get("hello.txt");        return obj          ? HttpServerResponse.text(yield* obj.text())          : HttpServerResponse.text("Not found", { status: 404 });      }),    };  }).pipe(Effect.provide(Cloudflare.R2.ReadWriteBucketBinding)),);

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.

Compare the testing loop
deploy~12sby handtest<1sdestroy~4sby handedit~17sper loopmanualoutside the testDIYbuild the harnessinfra onlyapp never tested
test/api.test.ts
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);
}));
terminal
# 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
test/harness.ts
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();
tests/api.tftest.hcl
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.

Alchemy
alchemy.run.ts
AlchemyTypeScript + Effect
Cloud API

1 runtime · TypeScript

SST
sst.config.ts
SSTGo CLI
Pulumi engineGo
Terraform providerGo, through a bridge
Cloud API

3 layers · TypeScript → Go

Pulumi
index.ts
Pulumi engineGo
Provider pluginoften a bridged Terraform provider
Cloud API

2 layers · gRPC between them

Terraform
main.tf
Terraform coreGo
Provider pluginGo
Cloud API

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.

Show how each tool wires the function
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>
  • GetItem grants read and returns getSession
  • PutItem grants write and returns saveSession

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

Alchemy
neededDynamoDB.GetItem(table)

grants dynamodb:GetItem on table

Exact

SST
neededlink: [table]

grants dynamodb:* on table

Close

Pulumi & Terraform
neededa policy you write

grants 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 on your machine, in the cloud, or a mix of both, and you choose. Other tools pick one model for you, and some have no dev mode at all, so every change waits on a deploy.

Alchemy0 round tripsRuns offline. Edits reload in place.
YOUR MACHINECLOUDnetworkbrowserLambda · DockerDynamoDB · flocistays on your machine
SST3 round tripsPer request, through a deployed stage. Needs a connection.
YOUR MACHINECLOUDnetworkbrowserAPI GatewayLambda stubyour codeDynamoDB
Pulumi & TerraformDeploy firstNo local runtime. Every change is a redeploy.
YOUR MACHINECLOUDnetworkeditdeployLambdaDynamoDB
  • 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.