Skip to content

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.

Impact: HIGH one path for humans and agents, safe to re-run

  • pnpm install, then pnpm run setup once, then pnpm run dev every time. Nothing prompts, so an agent and a human run the same commands.
  • setup is idempotent. It checks Node against .node-version, copies .env.example to .env only when .env is absent, brings the database up, migrates and seeds. “Run it again” is always a safe answer.
  • dev migrates before it starts the server, in the deploy job’s order. The server keeps requireApplied and never migrates. An edited unmerged migration stops with LedgerDiverged; db:reset is 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.yaml runs Postgres of production’s major, on port 5499. 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. migrate uses DATABASE_OWNER_URL; the server and the seed use DATABASE_URL, the DML-only role. DDL that slips into application code fails on a laptop.
  • compose.yaml holds 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 only
services:
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 app

Source: 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-version holds an exact version (24.18.0), not a major. The laptop, CI’s setup-node and the production image read the same bytes. pnpm is pinned in devEngines.
  • No second copy: no engines.node, no devEngines.runtime, no .nvmrc, no mise.toml, no flake, no devcontainer version. The repo does not pick a version manager; setup refuses a mismatch.
  • Bun profile: .bun-version, read by CI and the image. Worker profile: .node-version covers it.
  • .vscode/settings.json is committed: the workspace compiler (the patched one, see TypeScript config) and oxfmt on save.

❌ Incorrect — a range, copied into three files:

.nvmrc 24
package.json engines "node": ">=24"
.devcontainer "version": "24.11"

✅ Correct — one exact file, and the editor on the workspace compiler:

24.18.0
// .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

  • prepare runs git config core.hooksPath .githooks on every pnpm 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 check is the guarantee (see linting and formatting). --no-verify is allowed. The production image installs with --ignore-scripts, so prepare never 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:

Terminal window
oxfmt $(git diff --cached --name-only)
git update-index --again # commits edits that were never staged

✅ Correct — skip a partially staged file:

.githooks/pre-commit
#!/bin/sh
for 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; fi
done
[ -z "$files" ] && exit 0
node_modules/.bin/oxfmt $files && git add -- $files

Source: 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.com emails, no credentials, fixed literal ids and timestamps. An id in a bug report stays valid on every machine.
  • Every INSERT ends with ON CONFLICT DO NOTHING, which Postgres, SQLite and D1 all accept. That is what makes setup idempotent.
  • 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.ts applies it twice and asserts the second run changes no row count. On D1, alchemy’s importFiles applies it in the dev stage.
  • Accepted cost: the seed bypasses domain code, so a row can be valid SQL and still fail a Schema check when the app reads it.

❌ Incorrect — a copy of real data that fails on a second run:

Terminal window
pg_dump "$PROD_URL" --data-only > seed.sql # someone's real data
psql "$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 dev stage is accepted only when ALCHEMY_DEV is "true", and it uses Alchemy.localState(). alchemy deploy --stage dev fails.
  • No Alchemy.remote() in the dev stage; every binding is emulated. The local D1 provider applies the migrations on each alchemy dev.
  • Reset is deleting .alchemy/ with dev stopped. alchemy dev reads .env itself 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 drift
Alchemy.Stack("app", { providers, state: remoteState }, program) // every laptop writes here

✅ Correct — local state, decided by ALCHEMY_DEV, not by the stage name:

infra/alchemy.run.ts
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.ts and isolate.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 once

Source: notes/11-repo-operations/local-dev-setup.md · Decision 7

  • Isolation for two checkouts running dev at 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-version and devEngines do 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 that consolePretty does not show.