Skip to content

Local Development ​

Tamer does not run your app. It writes gitignored Wrangler config; you run wrangler dev, vite dev, and Vitest.

--env local makes no Cloudflare API calls, writes no tamer-state rows, and needs no CLOUDFLARE_* credentials. Remote deploy is a separate track (Quickstart, Greenfield Setup).

Agent checklist ​

Do this in a consumer (downstream) stack repo, not inside the Tamer monorepo.

  1. Install @dragonmastery/tamer + wrangler as devDependencies. Bun must be on PATH (the tamer bin has a bun shebang).
  2. Author tamer/project.config.ts (see Project Config).
  3. Gitignore generated and local-only files (below).
  4. Generate the wrangler pair, migrate local D1, then start Wrangler yourself.
  5. Point Vitest at wrangler.vitest.json, not wrangler.json.
  6. Put local secrets in {workerDir}/.dev.vars, not the Tamer vault.
  7. Optional: tamer types --env local for worker-configuration.d.ts.
bash
# Stack workers (workers.* in project config)
tamer wrangler --env local          # writes wrangler.json + wrangler.vitest.json
tamer migrate --env local           # Miniflare SQLite (also refreshes the pair)
tamer seed --env local              # if you declared seedDir
wrangler dev                        # cwd = worker dir; uses wrangler.json
# bun test                          # wranglerConfigPath: ./wrangler.vitest.json

# WFP tenant templates (wfp.namespaces.*.workers.*) — different command
tamer wfp tenant wrangler --env local --workspace localdev
tamer wfp tenant migrate --env local --workspace localdev
# then the repo's bun run dev / bun test

--env defaults to local for these commands, so tamer wrangler and tamer migrate are the same as passing --env local.

First-time layout ​

my-stack/
├── tamer/
│   └── project.config.ts      # source of truth — not wrangler.toml
├── workers/api/
│   ├── src/
│   ├── .dev.vars              # local secrets — gitignored
│   ├── wrangler.json          # generated — gitignored
│   └── wrangler.vitest.json   # generated — gitignored
├── .env                       # CLOUDFLARE_* only; optional for local
├── .gitignore
└── package.json

Suggested .gitignore entries:

.env
**/.dev.vars
**/wrangler.json
**/wrangler.vitest.json
**/.wrangler/

Suggested scripts (Tamer generates; Wrangler serves):

json
{
  "scripts": {
    "tamer:wrangler": "tamer wrangler --env local",
    "dev:api": "wrangler dev --config workers/api/wrangler.json"
  }
}

Do not hand-author wrangler.json / wrangler.jsonc. If a jsonc still exists, Tamer warns and still writes the pair — delete the jsonc when you cut over.

Durable local wrangler pair ​

Any durable --env local emit writes two files in each worker directory:

FileUse
wrangler.json (or wranglerOutFile)wrangler dev, local D1 migrate/seed
wrangler.vitest.json (sibling)@cloudflare/vitest-pool-workers only

The primary may include remote: true (e.g. shared corpus R2 under a local overlay). The Vitest sibling keeps the same binding names and synthesized IDs but strips remote so unit tests stay offline against Miniflare.

ts
// vitest / @cloudflare/vitest-pool-workers
wranglerConfigPath: "./wrangler.vitest.json"

Commands that refresh the pair:

CommandAlso does
tamer wrangler --env localGenerate only (prefer this)
tamer apply --env localSame emit; does not create Cloudflare resources
tamer migrate / seed / typesRefresh pair, then their own work
tamer wfp tenant wrangler --env localTenant templates only

Binding IDs in the pair are synthesized from derived names (database_id = database_name, KV id = namespace name, shard group index 0 only). There is no tamer-state row for local.

Stack workers: generate, then run Wrangler ​

bash
tamer wrangler --env local              # every stack worker
tamer wrangler --env local --worker api
# then, in the worker directory (or via a package.json script):
wrangler dev                            # default port 8787

tamer wrangler --env dev is rejected. Remote wrangler.json is written by tamer apply --env <env> / tamer deploy.

tamer apply --env local is the same emit. Prefer tamer wrangler when you only need the files.

Pointing an SPA at the local API ​

For a stack with an API worker and a SPA worker (see SPA with API):

  1. tamer wrangler --env local --worker api, then wrangler dev in the API worker directory → http://127.0.0.1:8787.
  2. Run the SPA's own dev server (Tamer does not run vite dev — see SPA Build). Point it at the local API via your base vars:
ts
// tamer/workers/spa/base.ts
vars: {
  VITE_API_CLIENT_URL: "http://127.0.0.1:8787/v1",  // base = local values
},

vite dev reads VITE_* from process.env or your SPA's .env/.env.local — that's Vite's responsibility, not Tamer's. Tamer only bakes vars when it spawns the build (tamer deploy), not during vite dev.

Local D1 migrations ​

tamer migrate (no --env, or --env local) targets wrangler's local SQLite cache — no --remote flag. It refreshes the wrangler pair first, then migrates. Iterate on schema locally, then run tamer migrate --env dev for the real dev D1:

bash
tamer migrate                              # local SQLite
tamer migrate --env dev                    # remote dev D1

After squashing migration files locally, reset the local database without touching Cloudflare:

bash
tamer reset --env local --target d1:app-db

See Migrations.

Local Postgres (PGlite socket) ​

When a worker (stack defineWorker or WFP template) declares postgres + bind-only hyperdrive[], --env local does not start a database. Tamer writes the Hyperdrive triple onto the wrangler pair; you start PGLiteSocketServer on the URL in localConnectionString.

ts
hyperdrive: [{
  binding: "HYPERDRIVE",
  // dummy 32-hex id is filled in when omitted on --env local
  localConnectionString: "postgres://postgres:postgres@127.0.0.1:54329/postgres",
}],
postgres: { migrationsDir: "apps/tenant/drizzle/migrations" },
bash
# consumer: listen on 127.0.0.1:54329 (do not use localhost)
tamer wrangler --env local                 # or: wfp tenant wrangler --env local
tamer migrate --env local                  # stack: schema public / defaultSchema
tamer wfp tenant migrate --env local --workspace demo   # tenant: public
wrangler dev                               # env.HYPERDRIVE → that socket
  • Local schema is public. Tamer does not CREATE SCHEMA or set TAMER_PG_SCHEMA on local.
  • Local migrate uses the emitted localConnectionString. Nothing listening → nothing listening on 127.0.0.1:54329 — start the consumer PGLite server.
  • reset --kind postgres is SQL against the socket (drop/recreate public). It does not rm -rf the consumer data dir.
  • pglite-socket defaults to maxConnections: 1, which drops Hyperdrive’s pool. The consumer sets the number (AAT uses 32 with 0.1.4 mux).
  • Tenant unit tests may inject in-process PGlite and skip HYPERDRIVE. Tamer does not treat wrangler.vitest.json as a SQL surface.

Remote Hyperdrive ids and per-workspace schemas are documented on Single-Product Multi-Tenant.

WFP tenant local D1 ​

Stack tamer wrangler / tamer migrate / tamer reset --env local only cover D1s on workers.*. Per-tenant product Workers live on WFP templates (wfp.namespaces.*.workers.*).

Tamer’s job locally is to generate the gitignored wrangler pair from that template. Your repo runs bun run dev / Vitest / wrangler — Tamer does not wrap tenant dev.

bash
# Generate (or refresh) wrangler.json + wrangler.vitest.json
tamer wfp tenant wrangler --env local --workspace demo

# Schema / data (also regenerates the pair first; D1 uses the primary)
tamer wfp tenant migrate --env local --workspace demo
tamer wfp tenant seed --env local --workspace demo
tamer wfp tenant reset --env local --workspace demo --kind d1

# Then your scripts:
bun run dev   # wrangler.json (may remote-proxy corpus R2)
bun test      # wrangler.vitest.json (always offline Miniflare)

Wipe / migrate / seed use cwd = the template worker directory so Miniflare SQLite matches what wrangler dev uses:

{workerDir}/.wrangler/state/v3/d1/...

Template local / env overlays ​

WFP templates accept the same style of overlays as stack workers:

  • local — merged when generating local wrangler / local migrate-seed-reset
  • env.dev / env.production — merged on remote provision/reupload

Put local-only bindings under local (e.g. shared corpus R2 with remote: true for real caa-data during wrangler dev). Do not put that on the base template used for remote deploy. Vitest uses the generated wrangler.vitest.json sibling (same binding names, no remote). Secrets stay in {workerDir}/.dev.vars, not local.vars.

ts
// tamer/wfp/base.ts (sketch)
{
  main: "apps/tenant/src/index.ts",
  path: "apps/tenant",
  d1: [/* … */],
  shardGroups: [/* … */],
  local: {
    vars: { ENVIRONMENT: "local" }, // non-secrets only
    r2_buckets: [
      { binding: "MIGRATION_SOURCE", bucket_name: "caa-data", remote: true },
    ],
  },
}

Local has no WFP dispatch — SPA → tenant Worker directly. wfp tenant provision / destroy / add-shard stay remote-only.

Local secrets (.dev.vars) ​

Local worker secrets live in a plain .dev.vars file in the worker's project directory — not in the Tamer vault. Wrangler reads it automatically during wrangler dev. This is intentionally separate from the encrypted vault (.dev.vars is local-only; the vault is for dev/prod deploys).

workers/api/.dev.vars          # local only — gitignored
STRIPE_API_KEY=sk_test_...
JWT_SECRET=local-dev-secret

See Secrets for the vault workflow that takes over for non-local envs.

What local generate does not do ​

  • No resource provisioning — local resources are miniflare/local SQLite, not created via the Cloudflare API. tamer apply --env local does not create real D1/R2/KV.
  • No state DB writes — there is no tamer-state row for local.
  • No route expansion — local envs get no routes (no *.workers.dev preview, no custom domains). Reach the worker via http://127.0.0.1:<port>.
  • No vite dev — SPA HMR is the consumer's script.
  • Cross-stack ${tamer:import:…} is empty on local. Put local-only values in the worker local overlay (or base vars) instead of importing sibling stack outputs.

Paste into a consumer AGENTS.md ​

Downstream agents should not invent Wrangler config. Drop this in the stack repo (not this monorepo):

md
# Tamer (local)

- Source of truth: `tamer/project.config.ts` — not `wrangler.toml`.
- Stack generate: `tamer wrangler --env local`
  (writes gitignored `wrangler.json` + `wrangler.vitest.json`).
- WFP tenant generate: `tamer wfp tenant wrangler --env local --workspace <ws>`
  (do **not** use stack `tamer wrangler` for template workers).
- Gitignore: `.env`, `**/.dev.vars`, `**/wrangler.json`,
  `**/wrangler.vitest.json`, `**/.wrangler/`.
- Run: `wrangler dev` against `wrangler.json`. Vitest:
  `wranglerConfigPath: "./wrangler.vitest.json"`.
- Local D1: `tamer migrate --env local` (stack) or
  `tamer wfp tenant migrate --env local --workspace <ws>`.
- Secrets: `{workerDir}/.dev.vars`. No `CLOUDFLARE_*` for `--env local`.
- `tamer wrangler --env dev` is rejected. Remote files come from
  `tamer apply` / `tamer deploy`.
- `tamer apply --env local` does **not** provision Cloudflare.
- `${tamer:import:…}` is empty on local — use the worker `local` overlay.
- Full loop: docs/guide/local-development.md (or
  https://tamer.dragonmastery.com/guide/local-development).
  • Quickstart — install + first remote deploy
  • Environments — the local env, ephemeral PR envs
  • Migrations — local SQLite vs remote D1
  • SPA Build — why vite dev is outside Tamer's scope
  • Secrets — .dev.vars (local) vs vault (deployed)
  • CLI — tamer wrangler / wfp tenant wrangler

Released under the Tamer Evaluation License.