This guide walks through setting up a complete local development environment from scratch.
For deployment to Azure, see Infrastructure Deployment.
| Tool | Version | Purpose |
|---|---|---|
| Node.js | ≥ 26.0.0 | Runtime for API and build tools |
| pnpm | ≥ 10.23.0 | Package manager |
| Docker | Any recent | Local PostgreSQL database |
| direnv | Any | Automatic .envrc loading (recommended) |
| Azure CLI | ≥ 2.59.0 | Required for file upload features and deployments |
| Git | Any | Version control |
Verify your installation:
node --version # Must be >= 26.0.0
pnpm --version # Must be >= 10.23.0
docker --version
az --version # For Azure featuresInstall pnpm (if not installed):
npm install -g pnpm
# or
corepack enable && corepack prepare pnpm@latest --activategit clone https://github.com/undp/carbon-footprint-program.git
cd carbon-footprint-programpnpm installThis installs dependencies for all apps and packages in the monorepo simultaneously.
Copy the root template and fill in the required values:
cp .envrc.template .envrcEdit .envrc with your configuration. See Environment Variables for a full reference.
Minimum required values for local development:
# Database (set after starting the local DB in Step 4)
export DATABASE_URL="postgresql://testuser:testpass@localhost:5432/testdb"
# API
export NODE_ENV="development"
export LOG_LEVEL="debug"
export AUTH_PROVIDER="forced-user"
export FORCED_USER_EMAIL="dev@example.com"
export FORCED_USER_IDP_ID="local-dev-user"Load environment variables:
Option A — Using direnv (recommended):
direnv allowVariables are automatically reloaded whenever .envrc changes.
Option B — Manual:
source .envrc
⚠️ Never commit.envrcor.envfiles. They are listed in.gitignore.
A working local stack needs a database, and — for a full authenticated flow — an Identity Provider and object storage. Bring up what your configuration requires before running pnpm dev.
cd packages/database
docker compose up -dThis starts a PostgreSQL container using the configuration in packages/database/docker-compose.yml.
Verify the container is running:
docker psWith AUTH_PROVIDER=forced-user (Step 3) you don't need an IdP — the API auto-authenticates every request. To exercise a real browser login locally without an Azure tenant, run the bundled Keycloak overlay. It needs no configuration (admin/admin and its DB defaults are baked into the compose files) — bring it up straight from the repo root:
docker compose --project-directory . -f compose/keycloak-db.yaml -f compose/keycloak.dev.yaml up -d(Running the api/web in Docker too, instead of pnpm dev? Combine the overlay with the base stack in one invocation — see Keycloak Setup → Quick Setup for that variant.)
- Admin console: http://localhost:18080 — bootstrap admin
admin/admin. - On first boot the realm
huellaand clienthuella-webare imported automatically, so the OIDC login works out of the box.
Then switch the API to AUTH_PROVIDER=jwks and set the JWKS_* variables (see Authentication below). Full walkthrough: Keycloak authentication setup.
File-upload features need object storage. When STORAGE_PROVIDER=minio (the local default), start MinIO in its own opt-in compose file:
docker compose -f docker-compose.minio.yml up -d- Console: http://localhost:9001 — default credentials
minioadmin/minioadmin. - Set
MINIO_ENDPOINT=http://localhost:9000when the API runs on the host viapnpm dev.
See File Storage for the full reference (and Azure Blob as an alternative — see File Upload below).
From the packages/database directory:
# Apply all pending migrations
pnpm dev:migrate
# Generate the Prisma client (TypeScript types + query builder)
pnpm dev:generateThen, from the root directory:
# Seed the database with initial data
pnpm db:seedOr do everything in one command from the root directory:
pnpm db:restoreThis resets the database, applies all migrations, and runs seeds in one command.
Seeding is skipped when the database already has data.
pnpm db:seedonly seeds a fresh database — if any data already exists it logsDatabase already contains dataand exits without changes. To reseed, reset the database first withpnpm db:restore.
The local DB uses a named Docker volume (
postgres_datainpackages/database/docker-compose.yml), so it survivesdocker compose downand machine restarts. On a machine that ran the stack before, the database is not fresh:pnpm dev:migratereports "Already in sync" andpnpm db:seedskips with "Database already contains data" — even though it may look empty for your current work. To get a clean, fully-seeded database, runpnpm db:restore(reset + migrate + seed).
From the root directory:
pnpm devThis starts all services concurrently:
| Service | URL |
|---|---|
| API | http://localhost:8080 |
| Web (frontend) | http://localhost:5173 |
| Swagger UI | http://localhost:8080/api/docs |
Both servers support hot reload — code changes are automatically reflected without manual restarts.
Start only a specific app:
pnpm dev:api # API only
pnpm dev:web # Frontend only- Open http://localhost:5173 — you should see the application UI
- Open http://localhost:8080/api/docs — you should see the Swagger documentation
- Check the API health:
curl http://localhost:8080/health
With AUTH_PROVIDER=forced-user, the API automatically authenticates all requests as the configured user. No browser login is required for local development.
# Run all API tests
pnpm test
# Run a specific test file
pnpm test --filter=api -- /getOrganizationById/integration.test.ts --coverage=false
# Run all tests for a domain
pnpm test --filter=api -- /organizations --coverage=falseTests use Testcontainers — they spin up real PostgreSQL and Azurite (Azure Storage emulator) containers automatically.
⚠️ Docker must be running for tests to work.
# Lint all code (must pass with zero warnings)
pnpm lint
# Type-check all TypeScript
pnpm type-check
# Format all code
pnpm format
# Check formatting without making changes (used in CI)
pnpm format:check# Build all apps and packages
pnpm build
# Build a specific app
pnpm --filter api build
pnpm --filter web buildpnpm cleanNote: Run these commands from the
packages/databasedirectory, or usepnpm --filter=@repo/database <command>from the root. Exceptions:pnpm db:seed,pnpm db:provision,pnpm db:restore, andpnpm db:drop:worktreeare root-level scripts.
| Command | Description |
|---|---|
pnpm dev:migrate |
Apply pending migrations |
pnpm dev:generate |
Regenerate Prisma client after schema changes |
pnpm dev:studio |
Open Prisma Studio (visual DB browser) at http://localhost:5555 |
pnpm db:seed |
Run database seed scripts (from root, via @repo/seed) |
pnpm db:provision |
Create + migrate + seed a database (from root; non-destructive) |
pnpm db:restore |
Reset + re-seed (from root, |
pnpm db:drop:worktree |
Drop THIS worktree's private database (from root; see below) |
Need to keep multiple git worktrees of this repo running at the same time? By default they'd all fight over API port 8080 and the same database. Opt-in per-worktree isolation gives each its own API port and database (and lets OIDC redirects follow the actual web port). A single checkout needs none of this and keeps the defaults (API 8080, web 5173).
See Running several git worktrees at once for the full guide — turning it on, first-time provisioning, ADE automation, login/OIDC behavior, and common pitfalls.
After modifying packages/database/src/prisma/schema.prisma:
cd packages/database
pnpm dev:migrate
# Enter a name for the migration when prompted (e.g., "add_new_field")
pnpm dev:generateAlways regenerate the client after schema changes so TypeScript types stay in sync.
File upload requires STORAGE_PROVIDER to be set — there is no default, and the API refuses to boot without it. Local dev defaults to minio (the value in infra/.envrc.template); set the MINIO_* vars and point the API at a MinIO/S3-compatible server.
To use Azure Blob Storage locally instead, set STORAGE_PROVIDER=azure_blob_storage. The simplest local path is to sign in with the Azure CLI and let DefaultAzureCredential pick up that identity — no secrets in your .envrc:
# Sign in once; DefaultAzureCredential resolves your az login identity locally
az login
# Set storage variables in .envrc
export STORAGE_PROVIDER="azure_blob_storage"
export AZURE_STORAGE_ACCOUNT_NAME="your-storage-account-name"
export AZURE_STORAGE_CONTAINER_NAME="files"Your signed-in principal needs the Storage Blob Data Contributor role on the storage account (or container).
DefaultAzureCredential resolves a Managed Identity when hosted on Azure and falls back to your az login identity locally. Providing an explicit Service Principal is optional — set all three variables only when you can't use the CLI (e.g. CI, or a no-CLI host):
# Optional: explicit Service Principal (only used when ALL THREE are set)
export AZURE_STORAGE_TENANT_ID="..."
export AZURE_STORAGE_CLIENT_ID="..."
export AZURE_STORAGE_CLIENT_SECRET="..."See File Storage for the full reference.
The recommended local auth mode is forced-user, which bypasses real authentication:
export AUTH_PROVIDER="forced-user"
export FORCED_USER_EMAIL="dev@example.com"
export FORCED_USER_IDP_ID="local-dev-user-001"To test with real Azure Entra ID authentication locally, switch to AUTH_PROVIDER=jwks and configure the JWKS_* variables (derived from your IdP/tenant). See Environment Variables and Azure Entra authentication setup.
To run a full OIDC login locally without an Azure tenant, use the bundled Keycloak IdP (compose overlay from Step 4) — see Keycloak authentication setup.
⚠️ If the API runs on the host (pnpm dev), uselocalhost:18080forJWKS_URI;keycloak:8080only resolves inside compose. See Keycloak authentication setup → The Issuer vs JWKS Host Split.
⚠️ Switching auth providers locally is unsupported against an existing DB. User identity is keyed on the IdP subject (idpUserId), andAUTH_PROVIDER/IdP (e.g. Keycloak → Azure Entra) and then sign in with an email that already exists in the DB from the previous provider, the new IdP subject won't match the stored one and login fails for that account. Either reset the DB (pnpm db:restore) or sign in with a fresh email.
pnpm install fails:
- Ensure Node.js ≥ 26.0.0:
node --version - Try clearing the pnpm store:
pnpm store prune
Database connection refused:
- Check Docker is running:
docker ps - Check the database container is up:
docker ps | grep postgres - Verify
DATABASE_URLmatches thedocker-compose.ymlcredentials
Prisma client not found / type errors after schema change:
cd packages/database && pnpm dev:generatePort already in use:
- API default port is
8080. Override withAPI_PORT=8081in.envrc(pick a port outside the stack's default ports) - Web default port is
5173. Vite will auto-increment if taken
Tests fail with "Docker not available":
- Ensure Docker daemon is running
- On Linux:
sudo systemctl start docker - On macOS/Windows: start Docker Desktop
401 on every API call / JWKS fetch failure (host pnpm dev with Keycloak):
- When the API runs on the host (not in compose), it can't resolve the in-compose
http://keycloak:8080/...host. SetJWKS_URI=http://localhost:18080/realms/huella/protocol/openid-connect/certs. - Keep
JWKS_ISSUER=http://localhost:18080/realms/huella(the browser-facing host). Detail: Keycloak authentication setup → The Issuer vs JWKS Host Split.
Database looks empty but migrations/seed do nothing (reused machine):
- The local Postgres uses a named Docker volume (
postgres_data), so it persists across restarts. On a machine that ran the stack before,pnpm dev:migratereports "Already in sync" andpnpm db:seedskips with "Database already contains data". - For a clean, fully-seeded DB, run
pnpm db:restore(reset + migrate + seed).
Broken image/file links after changing STORAGE_PROVIDER:
- Switching
STORAGE_PROVIDERafter data exists leaves previously-stored files (badges, uploads) pointing at the old backend → broken links. Reseed (pnpm db:restore) or migrate the objects to the new backend. See File Storage → Switching storage providers.