Developer Tooling

Every Foundation project ships with a fully reproducible development environment. No manual tool installation after the initial workspace build.


Toolchain Overview

ToolRole
DevboxNix-backed declarative package manager for all CLIs and runtimes
direnvAuto-activates the Devbox environment on cd into the project
process-composeOrchestrates all dev services in a single TUI terminal
CaddyLocal reverse proxy with auto-generated internal TLS certificates
Dev Container / CoderContainer image + lifecycle hooks for Nix/Devbox/dependency install
BiomeJS/TS linting + formatting (replaces ESLint + Prettier)
RuffPython linting + formatting (replaces flake8 + black + isort)
LefthookGit hooks (replaces Husky + lint-staged)
actRun GitHub Actions workflows locally via Docker

Devbox — devbox.json

Devbox provides a reproducible Nix-backed shell with all system-level tools:

Packages (Nix)

jq, yq, gnumake, direnv, lefthook, uv, nodejs_24, process-compose, ripgrep, caddy

Container capabilities such as GitHub CLI, Docker CLI/Compose, and zsh are installed by Dev Container features rather than duplicated in the project Nix closure. DevOps CLIs are installed only when needed.

Environment Variables

Dev-time defaults for DATABASE_URL (points at the developer's own Neon dev branch by default), API_URL, OTEL_EXPORTER_OTLP_ENDPOINT (the local otel-collector process — see Observability), and DEVBOX_COREPACK_ENABLED (enables pnpm/yarn via Corepack).

Shell Init Hook

Activates direnv for bash while remaining safe in non-bash shells:

[ -n "${BASH_VERSION:-}" ] && command -v direnv >/dev/null 2>&1 && eval "$(direnv hook bash)" 2>/dev/null || true

Scripts

ScriptPurpose
setupOne-command bootstrap: project deps, Oh My Zsh + pnpm plugin + zsh-autosuggestions + zsh-syntax-highlighting, direnv zsh hook, tmux alias, Lefthook hooks
devStart full local stack via process-compose up
dev:appStart the applications UI (Vite + TanStack) dev server only
dev:marketingStart the marketing site (Next.js) dev server only
dev:apiStart FastAPI dev server only
dev:dbStart the local Postgres fallback (docker/devbox) — offline use only; the default is a per-developer Neon dev branch
test:*Run tests per service
lint:*Run linters per service
format:*Run formatters per service
hooks:installInstall Lefthook git hooks
ci:localRun the ci.yml quality-gate workflow locally via act

The devbox.lock file must be committed to ensure identical Nix store paths for all developers.

Example devbox.json
{
  "$schema": "https://raw.githubusercontent.com/jetify-com/devbox/0.17.5/.schema/devbox.schema.json",
  "packages": [
    "jq@latest",
    "yq@latest",
    "gnumake@latest",
    "direnv@latest",
    "lefthook@latest",
    "uv@latest",
    "nodejs_24@latest",
    "process-compose@latest",
    "ripgrep@latest",
    "caddy@latest"
  ],
  "env": {
    "DATABASE_URL": "postgresql://<user>:<pass>@<your-neon-dev-branch>.neon.tech/app?sslmode=require",
    "API_URL": "http://localhost:8000",
    "OTEL_EXPORTER_OTLP_ENDPOINT": "http://localhost:4317",
    "DEVBOX_COREPACK_ENABLED": "true"
  },
  "shell": {
    "init_hook": [
      "# Activate direnv integration only for bash shells.",
      "# devbox run invokes init_hook via /usr/bin/sh, and direnv has no 'sh' hook target.",
      "[ -n \"${BASH_VERSION:-}\" ] && command -v direnv >/dev/null 2>&1 && eval \"$(direnv hook bash)\" 2>/dev/null || true",
      ""
    ],
    "scripts": {
      "setup": [
        "#!/usr/bin/env bash",
        "set -eu",
        "",
        "# ── Project dependencies & first-time setup ──────────────────────────────",
        "echo '==> Running project setup (deps, env files, git hooks)...'",
        "bash scripts/setup.sh"
      ],
      "hooks:install": "lefthook install",
      "dev": "process-compose up",
      "dev:app": "cd ui/web-app && pnpm dev",
      "dev:marketing": "cd ui/marketing && pnpm dev",
      "dev:api": "cd backend/api && uv run uvicorn --app-dir src main:app --reload --port 8000",
      "dev:db": "docker compose -f docker-compose.db.yml up",
      "test:api": "cd backend/api && uv run pytest",
      "test:app": "cd ui/web-app && pnpm test",
      "lint:app": "cd ui/web-app && pnpm dlx @biomejs/biome check ./src",
      "format:app": "cd ui/web-app && pnpm dlx @biomejs/biome format --write ./src",
      "lint:api": "cd backend/api && uv run ruff check src/",
      "ci:local": "act pull_request --container-architecture linux/amd64 -W .github/workflows/ci.yml"
    }
  }
}

📥 Download devbox.json template


Local Database — Neon dev branch (default)

Local development runs against a per-developer Neon dev branch, not a local database. Each developer gets a copy-on-write branch of the shared dev database, so schema and seed data match production shape without anyone running a Postgres server.

  • DATABASE_URL in devbox.json (and the git-ignored .env) points at the developer's own Neon branch. Hydrate secrets from Pulumi ESC — see Secret Management.
  • Schema, migrations, and RLS live in the top-level schema/ package, owned by Drizzle Kit; apply them to your branch with drizzle-kit push / migrate. The Python side reflects the schema — no Alembic. See Libraries.
  • Better Auth stores its tables in Neon, so your dev branch carries an isolated auth environment for free.

Offline fallback. When you need to work without network access, devbox run dev:db starts a local Postgres (via docker/devbox) and you point DATABASE_URL at localhost:5432. This is a fallback only — the Neon dev branch is the default and keeps local closest to production.


direnv — .envrc

eval "$(devbox generate direnv --print-envrc)"

This auto-activates the Devbox shell whenever a developer cds into the project directory. Committed to the repo.

Example .envrc
# Load devbox environment (generates shell env from devbox.json)
eval "$(devbox generate direnv --print-envrc)"
 
# ── Auto-bootstrap Python venv & deps for backend/api ────────────────────────
if [ ! -d backend/api/.venv ]; then
  echo "direnv: creating backend/api/.venv ..."
  (cd backend/api && uv venv --quiet)
fi
if [ -f backend/api/uv.lock ] && [ backend/api/uv.lock -nt backend/api/.venv/.direnv-synced ] \
   || [ ! -f backend/api/.venv/.direnv-synced ]; then
  echo "direnv: running uv sync in backend/api/ ..."
  (cd backend/api && uv sync --all-extras --quiet) && touch backend/api/.venv/.direnv-synced
fi
 
# ── Auto-bootstrap Node deps for ui/web-app ────────────────────────────────────
if [ -f ui/web-app/package.json ] && [ ! -d ui/web-app/node_modules ]; then
  echo "direnv: running pnpm install in ui/web-app/ ..."
  (cd ui/web-app && pnpm install --no-fund --reporter=silent)
fi
 
# ── Activate Python venv so pytest / CLI tools are on PATH ────────────────────
export VIRTUAL_ENV="$PWD/backend/api/.venv"
PATH_add "$VIRTUAL_ENV/bin"
 
# ── Add ui/web-app/node_modules/.bin so vitest / biome are on PATH ─────────────
PATH_add ui/web-app/node_modules/.bin
 
# ── Project-level environment variables ──────────────────────────────────────
export API_URL="${API_URL:-http://localhost:8000}"

📥 Download .envrc template (rename to .envrc after download)


process-compose — process-compose.yml

Orchestrates all development services:

ProcessCommandNotes
web-apppnpm dev (in ui/web-app/)Vite + TanStack dev server
marketingpnpm dev (in ui/marketing/)Next.js dev server
apiuv run uvicorn ... (in backend/api/)FastAPI dev server
otel-collectordocker compose -f docker-compose.otel.yml upLocal OpenTelemetry Collector — receives OTLP on 4317/4318; the target of the api (and worker) OTEL_EXPORTER_OTLP_ENDPOINT. Same collector + otel-collector-config.yml documented in Observability.
caddycaddy run --config Caddyfile --watchLocal TLS reverse proxy

Each process declares dependency ordering and readiness probes (HTTP/TCP health checks) so services start in the correct order.

One collector, one story. The OTel Collector that Observability describes for local development is the same service brought up here as the otel-collector process (docker-compose.otel.yml). The API's OTEL_EXPORTER_OTLP_ENDPOINT defaults to this local collector; in production the identical OTLP stream points at telemetry.googleapis.com.

There is no database process in the default stack — local dev talks to a per-developer Neon dev branch over the network. For offline work, bring up the local Postgres fallback separately with devbox run dev:db.

devbox run dev or process-compose up brings up the entire local stack in one command.

📥 Download process-compose.yml template


Caddy — Local Reverse Proxy

Caddy runs as a process-compose service and acts as the local TLS-terminating reverse proxy, routing traffic across all dev services behind a single HTTPS endpoint. It uses tls internal to auto-generate certificates via the Caddy local CA.

Trust the local CA — after the first caddy run, execute caddy trust once to add Caddy's root certificate to your OS/browser trust store. Without this step, browsers will show TLS warnings for the tls internal certificates.

Why default_bind tcp4/0.0.0.0?

The global default_bind tcp4/0.0.0.0 directive is required when running inside a Coder workspace. Coder's port-scanning infrastructure only discovers TCP4 sockets; without this directive Caddy binds to the dual-stack [::] address, which is invisible to Coder's port-forward scanner and prevents Coder Desktop from automatically detecting and forwarding the proxy ports.

Caddyfile

# Local dev reverse-proxy with auto-generated internal TLS certificates.
# Requires DNS: local.example.com & *.local.example.com → 127.0.0.1
# Also supports Coder Desktop via $CODER_WORKSPACE_NAME (e.g. myapp-local):
#   {CODER_WORKSPACE_NAME}.coder       → landing
#   app.{CODER_WORKSPACE_NAME}.coder   → app + API
{
	admin localhost:2020
	http_port 8080
	https_port 4443
 
	# REQUIRED for Coder Desktop port scanning: Coder's port-scanner only detects
	# TCP4 sockets. Without this, Caddy binds to dual-stack [::] and the proxy
	# ports are invisible to Coder's port-forward infrastructure.
	default_bind tcp4/0.0.0.0
}
 
# Landing page  –  local.example.com → Next.js marketing dev server
local.example.com {
	tls internal
	reverse_proxy localhost:3000 {
		header_up Host localhost:3000
	}
}
 
# App + API  –  app.local.example.com
app.local.example.com {
	tls internal
 
	# /api/* → FastAPI backend (path forwarded as-is)
	handle /api/* {
		reverse_proxy localhost:8000
	}
 
	# Everything else → Vite + TanStack app
	handle {
		reverse_proxy localhost:5173
	}
}
 
# Coder Desktop – landing  ({$CODER_WORKSPACE_NAME}.coder)
# Falls back to a no-op placeholder host when CODER_WORKSPACE_NAME is unset.
{$CODER_WORKSPACE_NAME:coder-workspace-placeholder}.coder {
	tls internal
	reverse_proxy localhost:3000 {
		header_up Host localhost:3000
	}
}
 
# Coder Desktop – app + API  (app.{$CODER_WORKSPACE_NAME}.coder)
app.{$CODER_WORKSPACE_NAME:coder-workspace-placeholder}.coder {
	tls internal
 
	# /api/* → FastAPI backend (path forwarded as-is)
	handle /api/* {
		reverse_proxy localhost:8000
	}
 
	# Everything else → Vite + TanStack app
	handle {
		reverse_proxy localhost:5173
	}
}
 
# Coder web port-forwarding catch-all ─────────────────────────────────────────
# Coder's browser-based port-forwarding (https://<port>--<workspace>.coder.domain/)
# terminates TLS at the Coder edge and forwards plain HTTP to the workspace port.
# Named site blocks above won't match the Coder-generated Host header, so this
# http:// catch-all handles those requests on port 8080 (the http_port).
# Use URL:  https://8080--main--<workspace>--<user>.coder.<domain>/
http:// {
	handle /api/* {
		reverse_proxy localhost:8000
	}
	handle {
		reverse_proxy localhost:5173
	}
}

Replace example.com with your project's actual domain. The Coder Desktop virtual hosts work without any DNS changes — Coder Desktop resolves *.coder locally.

📥 Download Caddyfile template


Lefthook — lefthook.yml

Pre-commit (parallel)

HookGlobCommand
biome-check*.{js,jsx,ts,tsx,json}pnpm biome check --apply {staged_files}
ruff-format*.pyruff format {staged_files}
ruff-lint*.pyruff check --fix {staged_files}

Pre-push

HookCommand
test-jspnpm turbo run test --filter=[HEAD^1] (each package runs vitest run)
test-pypytest --cov --cov-fail-under=80 in both backend/api and backend/worker (e.g. for d in backend/api backend/worker; do (cd "$d" && uv run pytest --cov --cov-fail-under=80); done)

📥 Download lefthook.yml template


Biome — biome.json

SettingValue
LinterEnabled: recommended + suspicious + correctness groups
FormatterindentStyle: "space", indentWidth: 2, lineWidth: 100
Import sortingorganizeImports.enabled: true
Ignorenode_modules, .next, dist, coverage, .expo

Biome replaces both ESLint and Prettier with a single, faster tool.

📥 Download biome.json template


Ruff — pyproject.toml

See Backend for the full Ruff configuration.

Ruff replaces flake8, isort, black, and bandit with a single tool. Rule sets: E, F, I, UP, B, S.


Dev Container & Coder Workspace

.devcontainer/devcontainer.json

SettingValue
Base imagemcr.microsoft.com/devcontainers/base:ubuntu-22.04
remoteUserroot (envbuilder compatibility)
FeaturesHost-socket Docker CLI, GitHub CLI/identity, Devbox, minimal Oh My Zsh
postCreateCommanddevbox run setup (logged to /tmp/postCreate.log)
remoteEnvDOCKER_BUILDKIT=1, SHELL=/bin/zsh

VS Code extensions pre-installed: Devbox, direnv, Ruff, Python/Pylance, Biome, Tailwind CSS, Copilot.

The base environment is intentionally feature-driven and has one project dependency path. The Devbox feature installs Nix/Devbox and prepares shell integration; devbox run setup owns package installation and repository-specific setup. Set installProjectPackages to false so the feature does not resolve the package graph before postCreateCommand.

Docker reuses the platform daemon through docker-outside-of-docker. Codespaces reuses the daemon on its VM, local VS Code reuses Docker Desktop/Engine, and the Coder template must mount the workspace host's /var/run/docker.sock. This avoids a privileged nested daemon, a second image cache, and a Docker startup wait in every workspace.

When a Compose service bind-mounts repository files, use ${LOCAL_WORKSPACE_FOLDER:-.} as the host-side prefix and expose LOCAL_WORKSPACE_FOLDER: "${localWorkspaceFolder}" through remoteEnv. The Docker daemon resolves host paths, not paths inside the development container.

Example devcontainer.json
{
  "name": "Foundation Project",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04",
  // Run all devcontainer lifecycle commands as root, matching the user that
  // envbuilder / Coder run as inside the container.
  "remoteUser": "root",
  "features": {
    // Reuse the platform daemon instead of starting Docker-in-Docker.
    "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
      "dockerDashComposeVersion": "latest",
      "installDockerBuildx": false
    },
    "ghcr.io/devcontainers/features/github-cli:1": {},
    "ghcr.io/h11h-io/devcontainer/git-identity-from-github:1": {},
    "ghcr.io/h11h-io/devcontainer/devbox:1": {
      "exportGlobalProfile": false,
      "installProjectPackages": false
    },
    "ghcr.io/h11h-io/devcontainer/oh-my-zsh:1": {
      "plugins": "git z zsh-autosuggestions zsh-syntax-highlighting"
    }
  },
  // postCreateCommand runs devbox run setup which installs pnpm/Python deps.
  // Output is tee'd to /tmp/postCreate.log; on failure the log is cat'd so it
  // appears in docker logs / envbuilder output for easy debugging.
  "postCreateCommand": "bash -c 'devbox run setup 2>&1 | tee /tmp/postCreate.log; exit ${PIPESTATUS[0]}'",
  "remoteEnv": {
    "COREPACK_ENABLE_DOWNLOAD_PROMPT": "0",
    "DIRENV_WARN_TIMEOUT": "2m",
    "DOCKER_BUILDKIT": "1",
    "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
    "PATH": "${containerWorkspaceFolder}/.devbox/nix/profile/default/bin:${containerEnv:PATH}",
    "SHELL": "/bin/zsh"
  },
  "customizations": {
    "vscode": {
      "settings": {
        "terminal.integrated.defaultProfile.linux": "zsh",
        "terminal.integrated.fontFamily": "'MesloLGS NF', 'Hack Nerd Font Mono', 'Courier New', monospace",
        "terminal.integrated.profiles.linux": {
          "zsh": { "path": "/bin/zsh", "args": ["-l"] },
          "bash": { "path": "/bin/bash", "args": ["-l"] }
        },
        "editor.formatOnSave": true,
        "python.defaultInterpreterPath": "${workspaceFolder}/backend/api/.venv/bin/python",
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff"
        },
        "[typescript]": {
          "editor.defaultFormatter": "biomejs.biome"
        },
        "[typescriptreact]": {
          "editor.defaultFormatter": "biomejs.biome"
        },
        "[javascript]": {
          "editor.defaultFormatter": "biomejs.biome"
        },
        "[javascriptreact]": {
          "editor.defaultFormatter": "biomejs.biome"
        },
        "[json]": {
          "editor.defaultFormatter": "biomejs.biome"
        }
      },
      "extensions": [
        "jetify-com.devbox",
        "mkhl.direnv",
        "charliermarsh.ruff",
        "ms-python.python",
        "ms-python.vscode-pylance",
        "biomejs.biome",
        "bradlc.vscode-tailwindcss",
        "GitHub.copilot",
        "GitHub.copilot-chat",
        "ms-vscode.makefile-tools",
        "redhat.vscode-yaml",
        "timonwong.shellcheck"
      ]
    },
    "codespaces": {
      "openFiles": ["README.md"]
    }
  },
  "forwardPorts": [3000, 5173, 8000, 5432, 8080, 4443],
  "portsAttributes": {
    "3000": { "label": "Next.js Marketing", "onAutoForward": "notify" },
    "5173": { "label": "Vite + TanStack App", "onAutoForward": "notify" },
    "8000": { "label": "FastAPI", "onAutoForward": "notify" },
    "5432": {
      "label": "Local Postgres (offline fallback)",
      "onAutoForward": "silent",
      "requireLocalPort": true
    },
    "8080": {
      "label": "Caddy HTTP",
      "onAutoForward": "silent"
    },
    "4443": {
      "label": "Caddy HTTPS",
      "onAutoForward": "silent"
    }
  }
}

📥 Download devcontainer.json template

Root Dockerfile (Devbox image)

Based on jetpackio/devbox:latest. Pre-populates the Nix store and optimizes layer size:

FROM jetpackio/devbox:latest
COPY devbox.json devbox.lock ./
RUN devbox run -- echo "Installed Packages."
RUN nix-store --gc && nix-store --optimise
CMD ["devbox", "shell"]

act — Local CI Testing

act runs GitHub Actions workflows locally inside Docker containers, giving you fast CI feedback without a push to GitHub.

Why act?

Without actWith act
Push → wait for GitHub Actions queueRun locally in seconds
Expensive feedback loop for CI changesIterate on workflow YAML offline
No offline supportWorks with no internet after images are pulled

Installation

act is included in the Devbox package list and is available automatically inside the Devbox shell. No separate install step is required.

Quick-start

# Run the full PR quality-gate workflow (pull_request trigger)
devbox run ci:local
 
# Equivalent manual invocation
act pull_request --container-architecture linux/amd64 -W .github/workflows/ci.yml
 
# Run a single job
act pull_request -j lint-js -W .github/workflows/ci.yml
 
# List all available jobs across all workflows
act --list

Always pass --container-architecture linux/amd64 on Apple Silicon (M-series) Macs. The Foundation CI runner images are built for linux/amd64; without this flag act will pull an incompatible arm64 image and jobs will fail.

Supplying secrets

act does not have access to GitHub repository secrets. Create a local secrets file (never commit it):

# .secrets  ← add to .gitignore
DATABASE_URL=...
NEON_API_KEY=...
GCP_PROJECT_ID=...

Then pass it to act:

act pull_request --secret-file .secrets -W .github/workflows/ci.yml

Alternatively, supply individual secrets via --secret NAME=VALUE flags.

Runner image

act defaults to a minimal node:16-buster-slim image. For workflows that rely on tools pre-installed on GitHub-hosted runners (e.g., docker, python), choose a fuller image:

# Medium image (~500 MB) — recommended for the Foundation CI workflow
act pull_request -P ubuntu-latest=catthehacker/ubuntu:act-22.04 \
    --container-architecture linux/amd64 \
    -W .github/workflows/ci.yml

Persist your preferred image in .actrc at the repo root (add to .gitignore):

-P ubuntu-latest=catthehacker/ubuntu:act-22.04
--container-architecture linux/amd64

📥 Download .actrc template (rename to .actrc after download)

.actrc vs committed config

.actrc is personal — it contains machine-specific flags and should be added to .gitignore. Shared workflow-level defaults belong in the workflow YAML itself.


Dependency Convention

Dependency TypeInstall Method
System CLIs & runtimesdevbox.json packages (nixpkgs)
Python packagesuv sync (via devbox run setup)
Node.js packagespnpm install (via devbox run setup)

Never install tools globally on the host machine. The Devbox shell (auto-activated by direnv) is the single entry point for all tooling.