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.
How state is stored
Section titled “How state is stored”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
Local state
Section titled “Local state”By default, state is stored on disk in the .alchemy/ directory:
.alchemy/ state/ MyApp/ dev_sam/ Bucket.json Worker.jsonAdd .alchemy/ to your .gitignore:
echo ".alchemy/" >> .gitignoreLocal state works for solo development. Each developer gets their own
state via their default stage (dev_$USER).
Remote state
Section titled “Remote state”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 store
Section titled “Cloudflare state store”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.
First-run bootstrap
Section titled “First-run bootstrap”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:
What gets created:
Api— the state-store Worker itself (a Durable Object with embedded SQLite, enabled on aworkers.devsubdomain)AlchemyStateStoreToken— bearer token used by the CLI to authenticate to the Worker, stored in your Secrets StoreStateStoreEncryptionKey— symmetric key used to encrypt resource state at rest inside the Durable Object’s SQLiteStateStoreSecrets— the Secrets Store that holds the two secrets above, scoped to your Cloudflare accountStateStoreAuthTokenValue/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:
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.
Where credentials live
Section titled “Where credentials live”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.
More than one store per account
Section titled “More than one store per account”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:
alchemy state list --backend cloudflare --name team-a-stateAWS.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>.
Pin the account
Section titled “Pin the account”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..." }),Serve it on your own domain
Section titled “Serve it on your own domain”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:
alchemy state list --backend cloudflare --domain alchemy-state.example.comAWS state store
Section titled “AWS state store”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:
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.
Migrating from the S3 store
Section titled “Migrating from the S3 store”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:
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:
alchemy state migrate --rollbackRoll back with the version you migrated with, before downgrading. State written by a newer version may use fields an older one can’t read.
Keeping state in S3
Section titled “Keeping state in S3”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.
Postgres state store
Section titled “Postgres state store”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 and lifecycle
Section titled “State and lifecycle”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.
In-memory state (testing)
Section titled “In-memory state (testing)”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 stateconst state = TestState.state({ Bucket: { /* ... */ },});The test harness (@alchemy.run/test/Bun and @alchemy.run/test/Vitest)
handles state setup automatically.
Use a store without a stack file
Section titled “Use a store without a stack file”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:
alchemy state bootstrap --backend cloudflare --profile prod # create or upgrade italchemy state list --backend cloudflare --profile prodalchemy state logs --backend cloudflare --name team-a --tailFrom 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.
Writing your own state store
Section titled “Writing your own state store”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.
Where next
Section titled “Where next”- Custom State Store — implement the
StateServiceinterface end-to-end. - Testing Providers — the in-memory store in practice.
- References — reading another stack’s outputs.