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.
- Install
@dragonmastery/tamer+wrangleras devDependencies. Bun must be onPATH(thetamerbin has abunshebang). - Author
tamer/project.config.ts(see Project Config). - Gitignore generated and local-only files (below).
- Generate the wrangler pair, migrate local D1, then start Wrangler yourself.
- Point Vitest at
wrangler.vitest.json, notwrangler.json. - Put local secrets in
{workerDir}/.dev.vars, not the Tamer vault. - Optional:
tamer types --env localforworker-configuration.d.ts.
# 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.jsonSuggested .gitignore entries:
.env
**/.dev.vars
**/wrangler.json
**/wrangler.vitest.json
**/.wrangler/Suggested scripts (Tamer generates; Wrangler serves):
{
"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:
| File | Use |
|---|---|
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.
// vitest / @cloudflare/vitest-pool-workers
wranglerConfigPath: "./wrangler.vitest.json"Commands that refresh the pair:
| Command | Also does |
|---|---|
tamer wrangler --env local | Generate only (prefer this) |
tamer apply --env local | Same emit; does not create Cloudflare resources |
tamer migrate / seed / types | Refresh pair, then their own work |
tamer wfp tenant wrangler --env local | Tenant 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
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 8787tamer 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):
tamer wrangler --env local --worker api, thenwrangler devin the API worker directory →http://127.0.0.1:8787.- Run the SPA's own dev server (Tamer does not run
vite dev— see SPA Build). Point it at the local API via your basevars:
// 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:
tamer migrate # local SQLite
tamer migrate --env dev # remote dev D1After squashing migration files locally, reset the local database without touching Cloudflare:
tamer reset --env local --target d1:app-dbSee 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.
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" },# 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 notCREATE SCHEMAor setTAMER_PG_SCHEMAon local. - Local migrate uses the emitted
localConnectionString. Nothing listening →nothing listening on 127.0.0.1:54329 — start the consumer PGLite server. reset --kind postgresis SQL against the socket (drop/recreatepublic). It does notrm -rfthe consumer data dir.pglite-socketdefaults tomaxConnections: 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 treatwrangler.vitest.jsonas 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.
# 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-resetenv.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.
// 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 — gitignoredSTRIPE_API_KEY=sk_test_...
JWT_SECRET=local-dev-secretSee Secrets for the vault workflow that takes over for non-local envs.
What local generate does not do
- No resource provisioning —
localresources are miniflare/local SQLite, not created via the Cloudflare API.tamer apply --env localdoes not create real D1/R2/KV. - No state DB writes — there is no
tamer-staterow forlocal. - No route expansion —
localenvs get no routes (no*.workers.devpreview, no custom domains). Reach the worker viahttp://127.0.0.1:<port>. - No
vite dev— SPA HMR is the consumer's script. - Cross-stack
${tamer:import:…}is empty onlocal. Put local-only values in the workerlocaloverlay (or basevars) 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):
# 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).Related docs
- Quickstart — install + first remote deploy
- Environments — the
localenv, ephemeral PR envs - Migrations — local SQLite vs remote D1
- SPA Build — why
vite devis outside Tamer's scope - Secrets —
.dev.vars(local) vs vault (deployed) - CLI —
tamer wrangler/wfp tenant wrangler