Skip to content

State Store

Alchemy persists resource state between deploys so it can compute diffs — comparing the desired state in your code against the current state of your infrastructure.

Each resource’s state is keyed by its fully qualified name (FQN), which includes the namespace path and logical ID. State is scoped by stack name and stage, so different stacks and environments are fully isolated.

A resource’s persisted state includes:

  • Resource type — e.g. Cloudflare.R2.Bucket
  • Input properties — the props you passed when creating it
  • Output attributes — the values returned after creation
  • Instance ID — a unique identifier for this instance
  • Lifecycle status — created, updating, deleting, etc.
  • Bindings — data attached by policies and event sources

By default, state is stored on disk in the .alchemy/ directory:

.alchemy/
state/
MyApp/
dev_sam/
Bucket.json
Worker.json

Add .alchemy/ to your .gitignore:

Terminal window
echo ".alchemy/" >> .gitignore

Local state works for solo development. Each developer gets their own state via their default stage (dev_$USER).

For teams and CI, configure a remote state store so all deploys share the same state. Alchemy includes a Cloudflare-backed store:

Alchemy.Stack(
"MyApp",
{
providers: Cloudflare.providers(),
state: Cloudflare.state(),
},
Effect.gen(function* () {
// ...
}),
);

Cloudflare.state() is the recommended state store for projects already running on Cloudflare. It persists state in a Worker backed by a Durable Object with embedded SQLite, with the auth token and encryption key kept in your account’s Secrets Store.

The first time you run alchemy deploy, plan, or dev against a stack configured with Cloudflare.state(), Alchemy can’t find the state-store Worker on your account and deploys it, once, with its supporting resources:

~/my-appDEPLOY
$

What gets created:

  • Api — the state-store Worker itself (a Durable Object with embedded SQLite, enabled on a workers.dev subdomain)
  • AlchemyStateStoreToken — bearer token used by the CLI to authenticate to the Worker, stored in your Secrets Store
  • StateStoreEncryptionKey — symmetric key used to encrypt resource state at rest inside the Durable Object’s SQLite
  • StateStoreSecrets — the Secrets Store that holds the two secrets above, scoped to your Cloudflare account
  • StateStoreAuthTokenValue / StateStoreEncryptionKeyValue — the generated random values bound into the secrets above

These resources are reused across every stack and stage that uses Cloudflare.state() on this account — no duplication per project. Subsequent runs detect the existing Worker and use it.

In CI (CI=true) a missing store fails the run instead: a deploy there should find its store provisioned, not create one as a side effect. Provision it once ahead of time. Without --backend, the command provisions the store your alchemy.run.ts configures:

Terminal window
alchemy state bootstrap --profile <your-ci-profile>

AWS.state() behaves the same way: it creates its DynamoDB table on first use, except in CI, where alchemy state bootstrap --backend aws provisions it. See AWS state store.

GCP.state() does the same with a Cloud Storage bucket in your project, alchemy-state-<project>. In CI, provision it with alchemy state bootstrap --backend gcp.

After the bootstrap, Alchemy writes the state-store URL and bearer token to a credentials file under your Alchemy profile directory (~/.alchemy/profiles/<profile>/state-stores/cloudflare.json). On CI, set CI=true; the credentials are resolved from the Secrets Store on every run via a short-lived edge-preview Worker, so nothing needs to be persisted to disk.

By default the state-store Worker is named alchemy-state-store. Pass workerName for a separate store, for example one per team. It gets its own Worker, its own secrets in the Secrets Store, and its own cached credentials:

state: Cloudflare.state({ workerName: "team-a-state" }),

Address it from the CLI with --name, which takes the Worker name:

Terminal window
alchemy state list --backend cloudflare --name team-a-state

AWS.state({ name }) does the same with a separate DynamoDB table, alchemy-state-<name>; pass tableName to choose the full table name yourself. GCP.state({ name }) uses alchemy-state-<name>-<project>.

The store lives in the Cloudflare account your profile resolves. If your credentials reach several accounts, pin it, so logging in to a different one fails instead of reading or deploying the store there:

state: Cloudflare.state({ accountId: "c8eeff0e..." }),

The store answers on its workers.dev URL. Some networks can’t reach workers.dev, so give it a hostname in one of your zones instead. Alchemy attaches it to the store’s Worker as a custom domain and uses it from then on:

state: Cloudflare.state({ domain: "alchemy-state.example.com" }),

Commands that don’t load the stack file take the same hostname as --domain, so they reach the store where deploys do:

Terminal window
alchemy state list --backend cloudflare --domain alchemy-state.example.com

AWS.state() keeps state in one on-demand DynamoDB table per account and region, alchemy-state. A deploy loads a stage’s records with one query instead of reading an object per resource.

Alchemy.Stack(
"MyApp",
{
providers: AWS.providers(),
state: AWS.state(),
},
Effect.gen(function* () {
// ...
}),
);

A DynamoDB item holds at most 400 KB. A record larger than overflowThreshold (300 KiB by default) is written to an S3 overflow bucket, alchemy-state-overflow-<account>-<region>-an, and its item keeps the object’s key.

The store creates its table, with deletion protection, and the overflow bucket on first use, except in CI. Provision them ahead of time with:

Terminal window
alchemy state bootstrap --backend aws --profile <your-ci-profile>

AWS.state({ name: "team-a" }) uses a separate table, alchemy-state-team-a. kmsKeyId encrypts the table and the overflow bucket with your own KMS key.

The credentials that deploy need DynamoDB access to the table and S3 access to the overflow bucket. The first deploy of a stage also lists the old S3 store’s bucket, to check the stage isn’t still there; migrateFrom: false skips that.

Earlier versions kept AWS state in an S3 bucket, alchemy-state-<account>-<region>-an. A stage still there fails to deploy until you move it. Run this in the project, so it migrates the store your entrypoint configures:

Terminal window
alchemy state migrate --profile <your-profile>

The command moves every stage, or the ones --stack and --stage select. It closes each moved stage’s S3 objects in the bucket policy, so every older alchemy, say in a CI job you haven’t upgraded, fails at its first read instead of deploying from the old copy. The S3 objects stay where they are. A stage you create in DynamoDB afterwards is locked in S3 too, so a teammate still on the S3 store can’t create it a second time.

A stage another deploy is running is skipped and reported. Run the command again once that deploy finishes; it picks up where it stopped, including after a crash.

To go back to an older alchemy, move the stages back first:

Terminal window
alchemy state migrate --rollback

Roll back with the version you migrated with, before downgrading. State written by a newer version may use fields an older one can’t read.

AWS.state({ backend: "s3" }) keeps using the S3 bucket. So does any config that passes bucketName, prefix or encryption, which only the S3 store takes. Bootstrap it with --backend aws-s3.

postgresState() persists state in a Postgres database you already run, and it locks: each (stack, stage) holds a session advisory lock for the duration of a run, so two concurrent deploys of the same stage cannot interleave.

import { postgresState } from "@alchemy.run/sql/PostgresState";
import * as Config from "effect/Config";
Alchemy.Stack(
"MyApp",
{
providers: Prisma.providers(),
state: postgresState({ url: Config.Redacted("STATE_DATABASE_URL") }),
},
Effect.gen(function* () {
// ...
}),
);

It is imported from @alchemy.run/sql/PostgresState rather than the State barrel because it depends on the optional @effect/sql-pg driver, which must stay out of bundled worker code. Install @effect/sql-pg to use it.

State transitions track the lifecycle of each resource:

Status Meaning
creating Create operation in progress
created Resource exists and is healthy
updating Update operation in progress
updated Resource was updated successfully
deleting Delete operation in progress
replacing Resource is being replaced (new one created, old pending delete)

These intermediate states (creating, updating, deleting) exist because state is persisted before the cloud operation completes. If the process crashes mid-operation, Alchemy uses the intermediate state to recover on the next deploy.

For tests, Alchemy provides an in-memory state store so tests don’t touch the filesystem:

import * as TestState from "@alchemy.run/test/TestState";
// Seed with existing resource state
const state = TestState.state({
Bucket: {
/* ... */
},
});

The test harness (@alchemy.run/test/Bun and @alchemy.run/test/Vitest) handles state setup automatically.

A provider’s store is reachable from your profile alone, so scripts, CI jobs and one-off tools can read and manage state with no alchemy.run.ts. From the CLI, pass --backend to any state command:

Terminal window
alchemy state bootstrap --backend cloudflare --profile prod # create or upgrade it
alchemy state list --backend cloudflare --profile prod
alchemy state logs --backend cloudflare --name team-a --tail

From code, use the provider source of Alchemist.State:

import * as Alchemist from "alchemy/Alchemist";
import * as Effect from "effect/Effect";
const source = { type: "provider", backend: "cloudflare", profile: "prod" } as const;
const program = Effect.gen(function* () {
yield* Alchemist.State.bootstrap(source);
const state = yield* Alchemist.State.store(source);
return yield* state.listStacks();
}).pipe(Effect.provide(Alchemist.layer()), Effect.scoped);

Alchemist.State.layer(source) provides the same store as a State layer. The backend is the provider’s name: cloudflare, aws or gcp. Next to it go the store’s options, for what the profile can’t tell it, the same ones the CLI takes as flags: name for a named store, and domain for a Cloudflare store on a custom domain:

const source = {
type: "provider",
backend: "cloudflare",
name: "team-a-state",
domain: "alchemy-state.example.com",
} as const;

Options set in the stack file aren’t read, such as a pinned account; use the configured source (the CLI default, without --backend) when they matter.

A state store is just an Effect Layer that provides the State service — back it with Postgres, S3, Redis, DynamoDB, or any other backend. See Custom State Store for the full walkthrough.