Local Development Setup
From a fresh clone to a running, seeded local stack — prerequisites, environment, and your first health check.
This page takes you from a fresh clone to a running local stack. Budget about 15 minutes once the prerequisites are installed.
Prerequisites
| Tool | Version | Notes |
|---|---|---|
| Node.js | 22 (.nvmrc); >=20 required | nvm install 22 && nvm use 22 |
| pnpm | 10.33.2 (packageManager in root package.json) | npm install -g pnpm@10.33.2 |
| Docker + Compose | any recent | Runs Postgres, Redis, and Meilisearch locally |
| Git | any recent | — |
You do not need to install Postgres, Redis, or Convex binaries — Docker Compose and the Convex CLI handle local infrastructure.
The canonical sequence
# 1. Install dependencies (one-time)
pnpm install
# 2. Generate .env from Infisical (the secret SSOT)
pnpm env:pull development
# 3. Create your per-machine override layer
cp .env.local.example .env.local
# 4. Start local infra (Postgres + Redis + Meilisearch)
pnpm db:start
# 5. Apply the schema and seed baseline data
pnpm db:migrate
pnpm seed:bootstrap # default org + admin user + roles
pnpm seed:dev # test users, orgs, and demo data
# 6. Configure the local Convex deployment (real-time backend)
pnpm convex:local
# 7. Launch a dev profile
pnpm dev consumer # account (3002) + consumer (3007) + backend (3006) + convexRun pnpm dev --list to see all available profiles.
How environment loading works
Local config is a two-layer, first-wins stack:
.env.local ← per-machine overrides (git-ignored, you create it)
.env ← generated base (from `pnpm env:pull`, mirrors Infisical)Both the backend loader and the turbo dev scripts read .env.local before
.env, so any key you set locally wins. The schemas themselves are a
folder-based SSOT in packages/env/src/ (one schema per deploy target), exposed
to apps as @emofy/env. See Deployment & environments.
The localhost cookie footgun
Pin AUTH_COOKIE_DOMAIN= (empty) in .env.local. The generated .env
may carry a value like .dev.emofy.net; the browser rejects that parent-domain
cookie on localhost and sign-in silently fails. An empty value forces
host-only cookies. This blank pin is sanctioned for local dev.
The other values a localhost stack needs (already present in
.env.local.example):
| Variable | Local value | Why |
|---|---|---|
BETTER_AUTH_URL | http://localhost:3002 | Auth issuer; trusted origins derive from it |
NEXT_PUBLIC_AUTH_URL | http://localhost:3002 | Client redirect target |
NEXT_PUBLIC_BACKEND_URL | http://localhost:3006 | Client → local NestJS API |
BACKEND_URL | http://localhost:3006 | Server-side backend bridge |
AUTH_COOKIE_DOMAIN | (empty) | Host-only cookies on localhost |
Dev processes read env at boot. After changing .env or .env.local,
restart the dev stack (Ctrl+C, then re-run pnpm dev <profile>).
Verify the stack is up
The backend exposes three health routes:
# Liveness — responds even when dependencies are down
curl http://localhost:3006/health/liveness
# → { "status": "ok", "timestamp": "2026-06-17T12:00:00.000Z" }
# Readiness — fails if any dependency is unhealthy
curl http://localhost:3006/health/readiness
# → { "status": "up", "details": {
# "postgres_core": { "status": "up" },
# "postgres_auth": { "status": "up" },
# "redis": { "status": "up" } } }Then open the apps:
- Account (sign-in + JWKS):
http://localhost:3002 - Consumer:
http://localhost:3007 - This docs site:
http://localhost:3004
Ports at a glance
| App / service | Port |
|---|---|
landing | 3000 |
account | 3002 |
admin | 3003 |
docs | 3004 |
template | 3005 |
backend | 3006 |
consumer | 3007 |
marketing | 3008 |
| Storybook | 6006 |
| Drizzle Studio (auth / core) | 4983 / 4984 |
| Postgres / Redis / Meilisearch | 5432 / 6379 / 7700 |
| Convex (local) | auto-assigned by the CLI |
Common reset recipes
pnpm dev:reset # soft: clear test data, keep schema, re-seed
pnpm dev:reset:hard # drop + recreate tables, re-seed
pnpm dev:cold-start -- --yes # destroy Docker volumes and rebuild from scratch
pnpm clean:ports # kill stragglers holding dev portsFootguns worth knowing early
- Remote ops need
.env.localmoved aside. Commands that target remote infra (e.g. seeding/migrating against staging) use the same dotenv injection, so rename.env.local→.env.local.bakfirst, then restore. Destructive remote targets are additionally gated by ADR-014. - Native is a separate env pipeline.
apps/nativeloads its own.env*via@expo/env, not the root layering. - Search is optional locally. Leave
SEARCH_ENABLED=falseunless you are working on search; the endpoints degrade gracefully.