Skip to content

Environments, state and cleanup

Production is the app you ship with no flags. Any other name makes a complete second copy, with its own Workers and its own resources:

$ pnpm infra ship example/guestbook/app.ts --env pr-7
1.69s ✓ created D1 database guestbook-pr-7-book
2.18s ✓ applied migration 1 to D1 database guestbook-pr-7-book
3.43s ✓ created Worker guestbook-pr-7-guestbook, live
5.15s ✓ smoke test passed: 3 × GET /, answered by the new Worker
5.46s ✓ shipped guestbook-pr-7-guestbook: https://guestbook-pr-7-guestbook.<you>.workers.dev

And when the pull request closes:

$ pnpm infra destroy example/guestbook/app.ts --env pr-7 --yes
✓ deleted worker guestbook-pr-7-guestbook
✓ deleted d1 guestbook-pr-7-book

Gone, all of it, with nothing left behind. This page is how that works, and how forge-effect keeps track of what’s yours.

Every command takes --env <name>: plan, ship, destroy, prune, state, check, secret put, even dev. Leave it out and it’s production. A name is lowercase letters, digits and single hyphens, starting with a letter: pr-7, staging, ada-test.

Everything is named after the app, the environment (unless it’s production), and the name you declared:

Declared production --env pr-7
the app guestbook’s Worker guestbook guestbook-guestbook guestbook-pr-7-guestbook
its D1 database book guestbook-book guestbook-pr-7-book

So two environments never share a resource by accident, and you can tell at a glance in the dashboard what’s whose.

By default, nothing but the names: a preview is the same app, empty. Where you want a difference, a declaration takes a function of the environment’s name:

A binding to another app points at that app’s copy in the same environment, so pr-7 of your shop calls pr-7 of your accounts app. To call production’s instead, say so: see Workers calling Workers.

The first ship makes a small D1 database in your account, forge-effect-state. It records, for each app and environment:

  • Each resource it owns, by its Cloudflare id, and whether the tool made it. The next ship binds it by that id, not by looking for the name.
  • What happened: each ship, failure, prune and teardown.
  • A lock, so two deploys of the same app and environment never race.
  • The versions it deployed and the contracts it shipped, so a later check notices a deploy from elsewhere, and a contract change that would break callers.

What exists is always read from Cloudflare. The state only says what’s the app’s. plan reads it without writing, so a first plan works before there’s any state at all.

That small record buys some lovely guarantees.

Say there’s already a D1 database called guestbook-book in your account, made by hand last year. Your app declares book, which would be guestbook-book. Without a record, a tool would happily bind it, and your guestbook would write into someone’s old data.

Here, a name that exists but isn’t recorded as the app’s is name-taken. The plan marks it with !, saying whose it is (another app’s, or not made by this tool), and a ship stops before changing anything. Rename the resource or the app, or delete the old one if it’s no one’s.

If someone deletes the app’s database in the dashboard, the next plan says it’ll be made again, as it was deleted outside the tool, and the ship does that and records it anew. infra check --account reports it as drift, along with any Worker running a version the tool didn’t deploy.

Stop declaring a resource, and the next ship doesn’t delete it. It unbinds it, keeps it, and marks it orphaned:

  • the plan says it’s no longer declared, and kept until prune,
  • infra prune deletes it when you’re sure.
Terminal window
pnpm infra prune app.ts cache # delete the orphan named cache
pnpm infra prune app.ts --allow-data-loss # delete every orphan

prune with no names refuses, naming the orphans it would delete, unless you pass --allow-data-loss. An orphan the tool didn’t make is only forgotten, never deleted. A removed Durable Object is pruned by shipping its host Worker with Cloudflare’s tombstone for the class, which deletes its objects; see Durable Objects.

Each ship, prune and destroy holds the app and environment’s lock. A second one stops at once, saying who holds it and since when, and asks you to try again when it’s done. A lock left by a deploy that died (a laptop closed mid-ship) is taken over after 30 minutes.

Terminal window
pnpm infra state app.ts [--env pr-7] [--json]

lists the resources the state records (each with its name in the account, its id, whether it’s active or orphaned, and whether the tool made it), then each ship, failure, prune and teardown, with when, and what. A failure’s line carries the reason it failed.

infra destroy deletes an environment’s Workers and resources:

$ pnpm infra destroy example/guestbook/app.ts --env pr-7 --yes
✓ deleted worker guestbook-pr-7-guestbook
✓ deleted d1 guestbook-pr-7-book
  • It asks for --yes. Without it, it says it would delete the app’s Workers and resources in that environment, and stops.

  • Production is guarded. Its data is real, so it wants one more flag:

    $ pnpm infra destroy example/guestbook/app.ts --yes
    stopped: tearing down production deletes its data; pass --allow-data-loss if that's meant
  • It goes in the order Cloudflare needs. R2 buckets are emptied first (Cloudflare deletes only an empty bucket), a thousand objects a request, before anything is deleted, so a bucket that can’t be emptied stops the teardown with nothing gone. Then routes and domains are detached, buckets’ notifications cleared, queue consumers removed, and the Workers deleted, callers first, before the resources they used.

  • It won’t pull the rug out. If another app still calls one of this app’s Workers, or uses one of its resources, destroy refuses and names it. --allow-breaking overrides that, when you mean it.

  • Only what’s recorded. It deletes what the state says is the app’s in that environment. A Secrets Store secret, or a resource you bound by its name with .external(...), was never the app’s, so it stays.

The history survives the teardown, so infra state still tells the story afterwards.

  • Checked live: a plan before any state; a ship that made the state database, the resources and their records; a second plan and ship that bound them by id and made nothing; production’s teardown refused without --allow-data-loss, then done, with the history kept.
  • An R2 bucket that still holds something after its objects are deleted (an unfinished multipart upload, or a bucket lock) stops a teardown, with what to do about it.
  • Previews cost what their resources cost. A D1 database per preview adds up on an account’s database limit: a test account hit it once, and that ship was undone.