Developer Tooling
Every Foundation project ships with a fully reproducible development environment. No manual tool installation after the initial workspace build.
Toolchain Overview
| Tool | Role |
|---|---|
| Devbox | Nix-backed declarative package manager for all CLIs and runtimes |
| direnv | Auto-activates the Devbox environment on cd into the project |
| process-compose | Orchestrates all dev services in a single TUI terminal |
| Caddy | Local reverse proxy with auto-generated internal TLS certificates |
| Dev Container / Coder | Container image + lifecycle hooks for Nix/Devbox/dependency install |
| Biome | JS/TS linting + formatting (replaces ESLint + Prettier) |
| Ruff | Python linting + formatting (replaces flake8 + black + isort) |
| Lefthook | Git hooks (replaces Husky + lint-staged) |
| act | Run 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 || trueScripts
| Script | Purpose |
|---|---|
setup | One-command bootstrap: project deps, Oh My Zsh + pnpm plugin + zsh-autosuggestions + zsh-syntax-highlighting, direnv zsh hook, tmux alias, Lefthook hooks |
dev | Start full local stack via process-compose up |
dev:app | Start the applications UI (Vite + TanStack) dev server only |
dev:marketing | Start the marketing site (Next.js) dev server only |
dev:api | Start FastAPI dev server only |
dev:db | Start 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:install | Install Lefthook git hooks |
ci:local | Run 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_URLindevbox.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 withdrizzle-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:
| Process | Command | Notes |
|---|---|---|
web-app | pnpm dev (in ui/web-app/) | Vite + TanStack dev server |
marketing | pnpm dev (in ui/marketing/) | Next.js dev server |
api | uv run uvicorn ... (in backend/api/) | FastAPI dev server |
otel-collector | docker compose -f docker-compose.otel.yml up | Local 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. |
caddy | caddy run --config Caddyfile --watch | Local 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.
Lefthook — lefthook.yml
Pre-commit (parallel)
| Hook | Glob | Command |
|---|---|---|
biome-check | *.{js,jsx,ts,tsx,json} | pnpm biome check --apply {staged_files} |
ruff-format | *.py | ruff format {staged_files} |
ruff-lint | *.py | ruff check --fix {staged_files} |
Pre-push
| Hook | Command |
|---|---|
test-js | pnpm turbo run test --filter=[HEAD^1] (each package runs vitest run) |
test-py | pytest --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
| Setting | Value |
|---|---|
| Linter | Enabled: recommended + suspicious + correctness groups |
| Formatter | indentStyle: "space", indentWidth: 2, lineWidth: 100 |
| Import sorting | organizeImports.enabled: true |
| Ignore | node_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
| Setting | Value |
|---|---|
| Base image | mcr.microsoft.com/devcontainers/base:ubuntu-22.04 |
remoteUser | root (envbuilder compatibility) |
| Features | Host-socket Docker CLI, GitHub CLI/identity, Devbox, minimal Oh My Zsh |
postCreateCommand | devbox run setup (logged to /tmp/postCreate.log) |
remoteEnv | DOCKER_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 act | With act |
|---|---|
| Push → wait for GitHub Actions queue | Run locally in seconds |
| Expensive feedback loop for CI changes | Iterate on workflow YAML offline |
| No offline support | Works 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 --listAlways 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.ymlAlternatively, 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.ymlPersist 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 Type | Install Method |
|---|---|
| System CLIs & runtimes | devbox.json packages (nixpkgs) |
| Python packages | uv sync (via devbox run setup) |
| Node.js packages | pnpm 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.