Skip to content

Local development

One command runs your whole app locally:

$ pnpm infra dev example/guestbook/app.ts
example/guestbook/app.ts, locally (state in .infra/dev, beside it):
guestbook http://guestbook.localhost:8787/
and http://127.0.0.1:8787/ goes to guestbook
✓ applied migration 1 to D1 database guestbook-book

That’s workerd, the runtime Cloudflare runs Workers in, through Miniflare, the engine under Cloudflare’s own local tools. Not a simulation in Node: your bundle, as it would deploy, with a local database behind it.

infra dev reads the same declarations a ship does, and wires the same things, locally:

  • KV, D1 and R2, each a local one, kept on disk.

  • Queues and their consumers: send a message, and the consumer gets it.

  • Calls between Workers, over service bindings, as RPC, with the same encoding as on Cloudflare.

  • Durable Objects, in the Worker that hosts them, WebSockets included.

  • Workflows of the app, run in workerd.

  • Files (Assets.Directory), served before the Worker, as Cloudflare serves them, and variables, with the environment’s value.

  • Rate limits and Analytics Engine.

  • Tail Workers of the app: a request to one Worker reaches its Tail Worker.

  • Containers, in Docker: each Dockerfile image is built as a ship builds it, and each object starts its own container from it. They’re removed when dev stops.

  • Secrets, from your shell’s environment, by binding name:

    Terminal window
    STRIPE_KEY=sk_test_123 pnpm infra dev app.ts

Each Worker that serves HTTP answers at its own host: http://<worker>.localhost:8787/. Most browsers, and curl, resolve *.localhost to your machine, so there’s nothing to set up. The first Worker also answers at http://127.0.0.1:8787/ (and localhost:8787).

A Worker with no fetch (one only other Workers call, say) has no URL. It’s reached through its bindings, as it is when deployed.

--port picks another port. --env pr-7 runs it with that environment’s names and values: still local, but a variable chosen by a function gets pr-7’s value.

Local data lives in .infra/dev/ beside your app module: databases, buckets, namespaces, queues, objects. Stop and start infra dev, and the guestbook’s entries are still there. Delete the folder to start fresh. (It’s in .gitignore already.)

A change to any file in the app’s folder bundles it again, and the Workers are reloaded with their data kept. Each reload prints the time and reloaded, or not reloaded with the reason, such as a bundle that failed. A typo doesn’t take down what was running.

A D1 database’s migrations are applied before infra dev answers a single request, so your code never meets a database without its tables. A new migration, added while it runs, is applied on the next reload.

Each of your crons fires when its schedule says, in UTC, read as Cloudflare reads it (1 is Sunday; L, W and # work). The first check is at the top of the next minute, and each firing prints the time, the Worker, the cron, and how it went. Leave infra dev running over lunch and your hourly sweep runs.

Some things only exist on Cloudflare, and infra dev won’t pretend. Each is said with a ! when it starts, so a failing call is never a mystery:

  • Workers AI and Vectorize run only on Cloudflare: left unset.
  • The Secrets Store isn’t read: reading a store secret fails.
  • Sending email and mTLS certificates: left unset.
  • Other apps’ Workers, Durable Objects and workflows: a call through one of those bindings fails. A resource another app owns gets a local one of its own instead, apart from the owner’s.
  • A Tail Worker outside the app isn’t run.
  • A Hyperdrive config needs a local connection string to connect.
  • Workflow schedules don’t fire yet (a Worker’s crons do).

For those, test the handlers in memory with their stand-ins (Ai.memory, Vectorize.memory, SecretsStore.memory, a contract’s .memory(...)): see Testing.

dev is also a function, from @forge-effect/infra/deploy, so a test can start the whole app in workerd and call it. See Testing.

Prefer Cloudflare’s Vite plugin, or cf? infra config writes one Worker of your app as a cloudflare.config.ts, which they read:

$ pnpm infra config example/guestbook/app.ts
wrote …/example/guestbook/cloudflare.config.ts
cloudflare.config.ts
// Generated by @forge-effect/infra from app.ts: the Worker guestbook. Don't edit it; change app.ts, and emit it again.
// KV namespaces are named by @forge-effect/infra when it deploys (none here); a config can't name them.
import { defineConfig } from '@cloudflare/config';
import * as entrypoint from './.infra/guestbook.ts' with { type: 'cf-worker' };
export default defineConfig({
worker: {
name: "guestbook-guestbook",
entrypoint,
compatibilityDate: "2026-10-01",
env: {"BOOK":{"type":"d1","name":"guestbook-book"},"SIGNING":{"type":"rate-limit","namespace":"1483869538","simple":{"limit":3,"period":60}}},
},
});

Its entry, .infra/guestbook.ts, is three lines: your Worker, served as workerd calls it. With @cloudflare/vite-plugin and its experimental.newConfig, vite dev serves the Worker under workerd with local KV and D1, and vite build writes a Build Output that ship deploys with --prebuilt.

  • An app with several Workers: pick one with --worker <name>. A config holds one.
  • It overwrites only files it generated. A cloudflare.config.ts of your own is left alone, and it says so.
  • A config can’t name a KV namespace, so deployed from the config, a namespace gets ship’s default name (visits-api-cache, after the Worker), where infra ship names it after the app (visits-cache).

For most work, infra dev is the better fit: it runs every Worker at once, and wires the calls between them.

  • Locally, a Tail Worker’s events have no script name.
  • workerd has no jurisdictions, so under infra dev a jurisdiction’s names are kept apart instead.
  • infra dev needs Miniflare 5 (in beta), a dev dependency of the repo already, and Docker for an app with containers.
  • A container whose image is in Cloudflare’s registry, or Cloudflare’s own Debian image (Container.debian), doesn’t run locally: dev doesn’t pull the one, and Cloudflare keeps the other to itself.
  • Checked: vite dev on an emitted config counted visits in local KV and D1 through an Effect layer.