Local development setup
A contributor runs three commands, and local runs production’s code, SQL dialect and migration order. This page answers what the first run looks like, which database runs locally, where tool versions are pinned, what the seed is, and how local may differ from production.
Run install, setup once, then dev
Section titled “Run install, setup once, then dev”Impact: HIGH one path for humans and agents, safe to re-run
pnpm install, thenpnpm run setuponce, thenpnpm run devevery time. Nothing prompts, so an agent and a human run the same commands.setupis idempotent. It checks Node against.node-version, copies.env.exampleto.envonly when.envis absent, brings the database up, migrates and seeds. “Run it again” is always a safe answer.devmigrates before it starts the server, in the deploy job’s order. The server keepsrequireAppliedand never migrates. An edited unmerged migration stops withLedgerDiverged;db:resetis the fix.- Accepted cost: Docker is a prerequisite on the default profile, and one dev server runs per checkout (fixed ports and compose project).
❌ Incorrect — a code path that exists only locally:
// package.json — the server migrates itself, but only when APP_ENV is development"dev": "pnpm --dir apps/server exec node --watch src/bin.ts"✅ Correct — the same order as the deploy job, from root scripts:
// package.json (root), Postgres profile"prepare": "effect-tsgo patch && git config core.hooksPath .githooks","setup": "node scripts/setup.ts","dev": "docker compose up --detach --wait && pnpm run migrate && pnpm --dir apps/server exec node --watch src/bin.ts","migrate": "pnpm --dir apps/server exec node ../../packages/db/src/migrate.ts","seed": "pnpm --dir apps/server exec node ../../packages/db/src/seed.ts","db:reset": "docker compose down --volumes && pnpm run setup"Source: notes/11-repo-operations/local-dev-setup.md · Decision 1
Run production’s dialect, and a real server where production has one
Section titled “Run production’s dialect, and a real server where production has one”Impact: HIGH what the embedded test engine hides shows up on a laptop
- Postgres profile:
compose.yamlruns Postgres of production’s major, on port5499. SQLite profile: a gitignored file,apps/server/.data/app.sqlite. Worker profile: alchemy’s local D1. Tests do not use any of these; they run an embedded database, fresh per test. - The two-role split exists locally.
migrateusesDATABASE_OWNER_URL; the server and the seed useDATABASE_URL, the DML-only role. DDL that slips into application code fails on a laptop. compose.yamlholds the database and nothing else. A service joins it in the PR that adds the first code needing it. Local credentials are public placeholders.- Accepted cost: the volume outlives branch switches, and the init script runs only on an empty
volume, so an edited migration or a role change needs
db:reset.
❌ Incorrect — a different engine than production, and every service up front:
services: postgres: { image: postgres:latest } # whatever major, owner role only redis: { image: redis } # no code needs it yet minio: { image: minio/minio }✅ Correct — production’s major, two roles, the database only:
# compose.yaml — local development onlyservices: postgres: image: postgres:17-alpine # production's major ports: ["5499:5432"] # not 5432, so a host Postgres keeps its port environment: { POSTGRES_USER: owner, POSTGRES_PASSWORD: owner, POSTGRES_DB: app } volumes: - postgres-data:/var/lib/postgresql/data - ./infra/local/postgres-init.sql:/docker-entrypoint-initdb.d/01-roles.sql:ro # CREATE ROLE appSource: notes/11-repo-operations/local-dev-setup.md · Decision 2
Pin versions in .node-version and devEngines, nowhere else
Section titled “Pin versions in .node-version and devEngines, nowhere else”Impact: MEDIUM every second copy of a version drifts
.node-versionholds an exact version (24.18.0), not a major. The laptop, CI’ssetup-nodeand the production image read the same bytes. pnpm is pinned indevEngines.- No second copy: no
engines.node, nodevEngines.runtime, no.nvmrc, nomise.toml, no flake, no devcontainer version. The repo does not pick a version manager;setuprefuses a mismatch. - Bun profile:
.bun-version, read by CI and the image. Worker profile:.node-versioncovers it. .vscode/settings.jsonis committed: the workspace compiler (the patched one, see TypeScript config) and oxfmt on save.
❌ Incorrect — a range, copied into three files:
.nvmrc 24package.json engines "node": ">=24".devcontainer "version": "24.11"✅ Correct — one exact file, and the editor on the workspace compiler:
// .vscode/settings.json{ "js/ts.tsdk.path": "node_modules/typescript/lib", "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": true}Source: notes/11-repo-operations/local-dev-setup.md · Decision 3
Install the hook with core.hooksPath, not a dependency
Section titled “Install the hook with core.hooksPath, not a dependency”Impact: LOW no dependency, and no commit of an unstaged hunk
preparerunsgit config core.hooksPath .githookson everypnpm install. No dependency installs the hook, so there is nothing to allow in the install scripts list.- The hook formats and re-stages fully staged files only. A file that also has unstaged edits is skipped with a message, so the hook never commits a hunk nobody staged.
- The hook is a convenience and
checkis the guarantee (see linting and formatting).--no-verifyis allowed. The production image installs with--ignore-scripts, sopreparenever runs there. - Accepted cost: about fifteen lines of POSIX shell are ours, and a file name with a space breaks the loop.
❌ Incorrect — format everything staged and re-stage it, partial files included:
oxfmt $(git diff --cached --name-only)git update-index --again # commits edits that were never staged✅ Correct — skip a partially staged file:
#!/bin/shfor f in $(git diff --cached --name-only --diff-filter=ACMR -- '*.ts' '*.json' '*.md'); do if git diff --quiet -- "$f"; then files="$files $f" else echo "pre-commit: $f is partially staged and was not formatted" >&2; fidone[ -z "$files" ] && exit 0node_modules/.bin/oxfmt $files && git add -- $filesSource: notes/11-repo-operations/local-dev-setup.md · Decision 4
Seed from one synthetic .sql file that is safe to re-apply
Section titled “Seed from one synthetic .sql file that is safe to re-apply”Impact: MEDIUM the same data on every laptop and preview
- Synthetic only: invented names,
example.comemails, no credentials, fixed literal ids and timestamps. An id in a bug report stays valid on every machine. - Every
INSERTends withON CONFLICT DO NOTHING, which Postgres, SQLite and D1 all accept. That is what makessetupidempotent. - Two orgs, a member with each role in each, and rows per tenant table in each org, so tenant isolation is visible locally. A PR that adds a tenant table adds its seed rows.
- The seed program runs as the DML-only role, in one transaction, and refuses
DEPLOYMENT_ENVIRONMENT=production.seed.test.tsapplies it twice and asserts the second run changes no row count. On D1, alchemy’simportFilesapplies it in the dev stage. - Accepted cost: the seed bypasses domain code, so a row can be valid SQL and still fail a
Schemacheck when the app reads it.
❌ Incorrect — a copy of real data that fails on a second run:
pg_dump "$PROD_URL" --data-only > seed.sql # someone's real datapsql "$DATABASE_URL" -f seed.sql # duplicate key on re-run✅ Correct — synthetic, fixed, and a no-op when already applied:
-- packages/db/seed.sql. Synthetic only: fixed ids, invented names, reserved domains.INSERT INTO orgs (id, name, created_at) VALUES ('0190a000-0000-7000-8000-000000000001', 'Acme', '2026-01-01T00:00:00Z'), ('0190a000-0000-7000-8000-000000000002', 'Globex', '2026-01-01T00:00:00Z')ON CONFLICT DO NOTHING;Source: notes/11-repo-operations/local-dev-setup.md · Decision 5
Develop Workers with alchemy dev --stage dev and local state
Section titled “Develop Workers with alchemy dev --stage dev and local state”Impact: MEDIUM one program, no drift, no shared state from laptops
- The same alchemy program serves dev and deploy. The
devstage is accepted only whenALCHEMY_DEVis"true", and it usesAlchemy.localState().alchemy deploy --stage devfails. - No
Alchemy.remote()in the dev stage; every binding is emulated. The local D1 provider applies the migrations on eachalchemy dev. - Reset is deleting
.alchemy/withdevstopped.alchemy devreads.envitself and the file wins over the shell, so set a variable in one place, never both. - Accepted cost: a Cloudflare login is still needed locally, and workerd’s D1 simulator is not D1. Container profiles do not run alchemy locally.
❌ Incorrect — a second definition of the bindings, or dev runs in the shared state store:
// wrangler.jsonc next to alchemy.run.ts — the two driftAlchemy.Stack("app", { providers, state: remoteState }, program) // every laptop writes here✅ Correct — local state, decided by ALCHEMY_DEV, not by the stage name:
type Stage = { kind: "prd" } | { kind: "stg" } | { kind: "dev" }const isDevServer = process.env.ALCHEMY_DEV === "true" // set by `alchemy dev`
export default Alchemy.Stack("app", { providers, state: isDevServer ? Alchemy.localState() : remoteState,}, Effect.gen(function* () { /* … */ }))Source: notes/11-repo-operations/local-dev-setup.md · Decision 6
Differ from production only in values and machine limits
Section titled “Differ from production only in values and machine limits”Impact: MEDIUM a bug found in staging reproduces on a laptop
- The same locally and in production:
bin.ts,main.tsandisolate.ts, the SQL driver and dialect, the migration files and order,requireApplied, the two roles, the config decoding path and every variable name, the Node version. - Local differs only in configuration values (
APP_ENV=development,SHUTDOWN_DELAY=0 seconds) and in what one machine cannot have (replicas, TLS, real data, OTLP export). Staging catches those gaps; local does not simulate them. - A new difference is added to the fidelity table in the PR that introduces it. No difference comes from branching code on an environment name (see deployment and environments).
- Accepted cost: a bug that needs two or more replicas reproduces in neither local nor staging.
❌ Incorrect — code that behaves differently by environment name:
const environment = yield* Config.String("DEPLOYMENT_ENVIRONMENT")if (environment !== "production") return yield* signInWithoutIdp // a local-only path✅ Correct — the same code, a different value:
| value | production | local || APP_ENV | production | development | JSON lines vs consolePretty| SHUTDOWN_DELAY | 5 seconds | 0 seconds | --watch restarts at onceSource: notes/11-repo-operations/local-dev-setup.md · Decision 7
Deferred
Section titled “Deferred”- Isolation for two checkouts running
devat once (port offset, compose project per checkout) — trigger: the first time two checkouts need a dev server at the same time. - A devcontainer or Nix devShell for the rest of the host toolchain — trigger: a setup failure
traced to a host tool that
.node-versionanddevEnginesdo not pin. - A generated seed with volume, from a seeded RNG — trigger: the first feature whose behaviour a developer has to see at volume locally.
- No Cloudflare login for the Worker profile — trigger: the alchemy pin moves to 2.0.0-beta.80 or later.
- A local trace viewer in
compose.yaml— trigger: the first bug whose local diagnosis needed span attributes thatconsolePrettydoes not show.