Developer Tooling

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)

git, curl, wget, jq, yq, gnumake, direnv, lefthook, uv, nodejs_22, gh, process-compose, docker, docker-compose, ripgrep, fd, bat, eza, zsh, pulumi-bin, google-cloud-sdk, caddy, netcat-openbsd, iputils, dnsutils, traceroute, openssl, unzip, htop, tree, file, vim

act is not in packages — it is installed at init_hook time via the official curl installer so the binary survives in /usr/local/bin across devbox shell restarts.

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 (guarded for non-bash shells), sources NVM, and installs act via the official curl installer if not present:

[ -n "${BASH_VERSION:-}" ] && command -v direnv >/dev/null 2>&1 && eval "$(direnv hook bash)" 2>/dev/null || true
export NVM_DIR="${HOME}/.nvm"
[ -s "${NVM_DIR}/nvm.sh" ] && . "${NVM_DIR}/nvm.sh" --no-use || true
 
# act installed via curl installer so it persists at /usr/local/bin/act
if ! command -v act >/dev/null 2>&1; then
  curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | bash -s -- -b /usr/local/bin
fi

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.13.7/.schema/devbox.schema.json",
  "packages": [
    "git@latest",
    "curl@latest",
    "wget@latest",
    "jq@latest",
    "yq@latest",
    "gnumake@latest",
    "direnv@latest",
    "lefthook@latest",
    "uv@latest",
    "nodejs_22@latest",
    "gh@latest",
    "process-compose@latest",
    "docker@latest",
    "docker-compose@latest",
    "ripgrep@latest",
    "fd@latest",
    "bat@latest",
    "eza@latest",
    "zsh@latest",
    "pulumi-bin@latest",
    "google-cloud-sdk@latest",
    "caddy@latest",
    "netcat-openbsd@latest",
    "iputils@latest",
    "dnsutils@latest",
    "traceroute@latest",
    "openssl@latest",
    "unzip@latest",
    "htop@latest",
    "tree@latest",
    "file@latest",
    "vim@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",
      "",
      "# Source NVM so that node/pnpm from nvm is available in devbox shells",
      "# || true is required: under set -e (prepended by devbox to hooks.sh), a",
      "# failing [ -s ] && . exits the script if NVM isn't installed yet.",
      "export NVM_DIR=\"${HOME}/.nvm\"",
      "[ -s \"${NVM_DIR}/nvm.sh\" ] && . \"${NVM_DIR}/nvm.sh\" --no-use || true",
      "",
      "# Install act (local GitHub Actions runner) via the official installer if not present.",
      "# act is not in nixpkgs because the Nix build sandbox conflicts with Docker-in-Docker;",
      "# the curl installer places the binary at /usr/local/bin/act which survives devbox shell restarts.",
      "if ! command -v act >/dev/null 2>&1; then",
      "  echo '==> Installing act (local GitHub Actions runner)...'",
      "  curl -fsSL https://raw.githubusercontent.com/nektos/act/master/install.sh | bash -s -- -b /usr/local/bin >/dev/null 2>&1 && echo '  → act installed.' || echo '  → Warning: act install failed.'",
      "fi"
    ],
    "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",
        "",
        "# ── Oh My Zsh ────────────────────────────────────────────────────────────",
        "echo '==> Configuring Oh My Zsh...'",
        "",
        "if [ ! -d \"${HOME}/.oh-my-zsh\" ]; then",
        "  echo '  → Installing Oh My Zsh...'",
        "  if ! RUNZSH=no CHSH=no sh -c \"$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\"; then",
        "    echo '  → Warning: Oh My Zsh install failed; continuing setup'",
        "  fi",
        "fi",
        "",
        "ZSH_CUSTOM=\"${ZSH_CUSTOM:-${HOME}/.oh-my-zsh/custom}\"",
        "",
        "if [ ! -d \"${ZSH_CUSTOM}/plugins/zsh-autosuggestions\" ]; then",
        "  echo '  → Installing zsh-autosuggestions...'",
        "  git clone --depth=1 https://github.com/zsh-users/zsh-autosuggestions \"${ZSH_CUSTOM}/plugins/zsh-autosuggestions\"",
        "fi",
        "",
        "if [ ! -d \"${ZSH_CUSTOM}/plugins/zsh-syntax-highlighting\" ]; then",
        "  echo '  → Installing zsh-syntax-highlighting...'",
        "  git clone --depth=1 https://github.com/zsh-users/zsh-syntax-highlighting \"${ZSH_CUSTOM}/plugins/zsh-syntax-highlighting\"",
        "fi",
        "",
        "if [ ! -d \"${ZSH_CUSTOM}/plugins/pnpm\" ]; then",
        "  echo '  → Installing oh-my-zsh pnpm plugin...'",
        "  git clone --depth=1 https://github.com/ntnyq/omz-plugin-pnpm \"${ZSH_CUSTOM}/plugins/pnpm\"",
        "fi",
        "",
        "# ── Configure .zshrc ─────────────────────────────────────────────────────",
        "ZSHRC=\"${HOME}/.zshrc\"",
        "[ ! -f \"${ZSHRC}\" ] && touch \"${ZSHRC}\"",
        "",
        "if grep -q '^ZSH_THEME=' \"${ZSHRC}\" 2>/dev/null; then",
        "  sed -i 's/^ZSH_THEME=.*/ZSH_THEME=\"agnoster\"/' \"${ZSHRC}\"",
        "else",
        "  printf 'ZSH_THEME=\"agnoster\"\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "if grep -q '^plugins=' \"${ZSHRC}\" 2>/dev/null; then",
        "  sed -i 's/^plugins=.*/plugins=(git docker python node pnpm history direnv zsh-autosuggestions zsh-syntax-highlighting)/' \"${ZSHRC}\"",
        "else",
        "  printf 'plugins=(git docker python node pnpm history direnv zsh-autosuggestions zsh-syntax-highlighting)\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "if ! grep -q '# foundation-devbox-zsh' \"${ZSHRC}\" 2>/dev/null; then",
        "  printf '\\n# foundation-devbox-zsh\\n' >> \"${ZSHRC}\"",
        "  printf 'export NVM_DIR=\"$HOME/.nvm\"\\n' >> \"${ZSHRC}\"",
        "  printf '[ -s \"$NVM_DIR/nvm.sh\" ] && . \"$NVM_DIR/nvm.sh\"\\n' >> \"${ZSHRC}\"",
        "  printf 'ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE=\"fg=60\"\\n' >> \"${ZSHRC}\"",
        "  printf 'ZSH_AUTOSUGGEST_STRATEGY=(history completion)\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "if ! grep -q '# foundation-direnv-zsh-hook' \"${ZSHRC}\" 2>/dev/null; then",
        "  printf '\\n# foundation-direnv-zsh-hook\\n' >> \"${ZSHRC}\"",
        "  printf 'command -v direnv >/dev/null 2>&1 && eval \"$(direnv hook zsh)\"\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "if ! grep -q '# foundation-tmux' \"${ZSHRC}\" 2>/dev/null; then",
        "  printf '\\n# foundation-tmux\\n' >> \"${ZSHRC}\"",
        "  printf '# Use repo .tmux.conf when TMUX_CONF is set (populated by .envrc).\\n' >> \"${ZSHRC}\"",
        "  printf 'if [ -n \"${TMUX_CONF:-}\" ]; then\\n' >> \"${ZSHRC}\"",
        "  printf \"  alias tmux='tmux -f \\\"\\$TMUX_CONF\\\"'\\n\" >> \"${ZSHRC}\"",
        "  printf 'fi\\n' >> \"${ZSHRC}\"",
        "fi",
        "",
        "echo '==> Zsh configuration complete!'"
      ],
      "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)
onCreateCommand.devcontainer/on-create.sh
postCreateCommanddevbox run setup (logged to /tmp/postCreate.log)
postStartCommandRun .devcontainer/post-start.sh (restart Nix daemon, wait for Docker)
remoteEnvDOCKER_BUILDKIT=1, SHELL=/bin/zsh

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

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",
  // NOTE: docker-in-docker is intentionally omitted.
  // envbuilder does not support the `privileged` or `init` feature properties
  // that docker-in-docker requires. Docker CE is installed by on-create.sh to
  // provide the daemon; Docker CLI + Compose may come from Devbox or Docker CE.
  // In Coder workspaces the Docker socket (/var/run/docker.sock) should be
  // mounted from the host via the workspace template.
  // Installs nix (single-user, sandbox disabled for Docker), devbox, and all
  // project packages. Nix installation is required; failures will stop the build.
  "onCreateCommand": "bash .devcontainer/on-create.sh",
  // 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]}'",
  // postStartCommand runs every time the container starts (including restarts).
  // It runs the post-start script, which restarts nix-daemon and waits for
  // Docker. Docker must be provided by a host-mounted socket.
  "postStartCommand": "bash -lc 'bash .devcontainer/post-start.sh'",
  "remoteEnv": {
    "DOCKER_BUILDKIT": "1",
    "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

.devcontainer/on-create.sh

Idempotent bootstrap script:

  1. Ensure Docker is installed — uses get.docker.com convenience installer (Codespaces-aware: skips install if already present, warns without failing if daemon unreachable)
  2. Initialize git submodules (non-fatal if credentials unavailable)
  3. Install zsh if absent
  4. Install tmux via apt (host-OS integration; not from Nix)
  5. Configure Nix: sandbox = false, filter-syscalls = false (heredoc append, idempotent)
  6. Install Nix (Determinate Systems: --init none, --no-confirm)
  7. Start Nix daemon (sleep 3 for socket readiness)
  8. Install Devbox
  9. devbox install
  10. Append Nix/Devbox PATH to ~/.bashrc + ~/.zshrc

Docker-in-Docker: envbuilder does not support privileged/init. on-create.sh installs Docker CE via the official convenience installer (get.docker.com) and starts dockerd; this is skipped in Codespaces where Docker is already provided. In Coder workspaces the Docker socket (/var/run/docker.sock) should be mounted from the host via the workspace template.

Example on-create.sh
#!/usr/bin/env bash
# .devcontainer/on-create.sh
# Runs as the devcontainer user (root) during onCreateCommand.
# Idempotent — safe to re-run.
set -euo pipefail
 
IS_CODESPACES="${CODESPACES:-}"
 
# ── 0. Ensure Docker is available early for lifecycle commands ───────────────
# Codespaces already provides Docker; avoid trying to start nested dockerd.
if ! command -v docker &>/dev/null; then
  if [ -n "$IS_CODESPACES" ]; then
    echo "WARNING: docker CLI is missing in Codespaces; skipping Docker bootstrap"
  else
    echo "→ Installing Docker CE..."
    curl -fsSL https://get.docker.com | sh
  fi
fi
 
if ! docker info &>/dev/null 2>&1; then
  if [ -n "$IS_CODESPACES" ]; then
    echo "WARNING: Docker daemon not reachable in Codespaces during on-create; continuing"
  elif command -v dockerd &>/dev/null; then
    echo "→ Starting dockerd..."
    dockerd \
      --host=unix:///var/run/docker.sock \
      --data-root=/var/lib/docker \
      >/tmp/dockerd.log 2>&1 &
 
    for i in $(seq 1 30); do
      docker info &>/dev/null 2>&1 && break
      sleep 1
    done
 
    if docker info &>/dev/null 2>&1; then
      echo "→ Docker daemon ready"
    else
      echo "WARNING: dockerd did not become ready within 30s (see /tmp/dockerd.log)"
    fi
  fi
fi
 
# ── 1. Initialize git submodules ──────────────────────────────────────────────
# NOTE: This runs before the Coder agent starts, so git credential helpers are
# not yet available. Private submodules requiring HTTPS auth may fail here.
# We make this non-fatal; the postStartCommand (or manual re-run) can complete
# it once the agent is running and git auth forwarding is active.
echo "→ Updating git submodules..."
git -C "$(dirname "$0")/.." submodule update --init --recursive \
  || echo "WARNING: submodule update failed (git credentials not yet available — re-run on-create.sh once the workspace is ready)"
 
# ── 2. Ensure zsh is installed ────────────────────────────────────────────────
if ! command -v zsh &>/dev/null; then
  echo "→ Installing zsh..."
  apt-get update -qq && apt-get install -y zsh
fi
 
# ── 2b. Ensure tmux is installed (apt, not nix, for host-OS integration) ─────
if ! command -v tmux &>/dev/null; then
  echo "→ Installing tmux..."
  apt-get update -qq && apt-get install -y tmux
fi
 
# ── 3. Configure nix to work without sandboxing ──────────────────────────────
# Most Docker environments (even privileged) can't run the nix build sandbox
# because they lack user-namespace support. Disabling sandbox + syscall filter
# allows nix/devbox to build and fetch packages normally.
mkdir -p /etc/nix
if [ ! -f /etc/nix/nix.conf ] || ! grep -q 'sandbox = false' /etc/nix/nix.conf; then
  cat >> /etc/nix/nix.conf <<'EOF'
sandbox = false
filter-syscalls = false
EOF
fi
 
# ── 4. Install nix (single-user, no daemon) if needed ────────────────────────
# The Determinate Systems installer handles Docker containers reliably.
if ! command -v nix &>/dev/null; then
  echo "→ Installing nix..."
  curl --proto '=https' --tlsv1.2 -sSf -L https://install.determinate.systems/nix \
    | sh -s -- install linux \
        --init none \
        --no-confirm \
        --extra-conf "sandbox = false" \
        --extra-conf "filter-syscalls = false"
fi
 
# Source the nix profile for the remainder of this script
if [ -f /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh ]; then
  # shellcheck disable=SC1091
  . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
fi
 
# ── 5. Start the nix daemon ──────────────────────────────────────────────────
# The Determinate Systems installer does not start the daemon automatically
# (--init none). We start it here for this shell, and rely on postStartCommand
# in devcontainer.json to restart it on subsequent workspace restarts.
/nix/var/nix/profiles/default/bin/nix-daemon --daemon &>/tmp/nix-daemon.log &
NIX_DAEMON_PID=$!
echo "→ nix-daemon started (pid $NIX_DAEMON_PID)"
sleep 3  # wait for the daemon socket to be ready
 
# ── 6. Install devbox if needed ───────────────────────────────────────────────
if ! command -v devbox &>/dev/null; then
  echo "→ Installing devbox..."
  # -f = non-interactive (skip the "Install to /usr/local/bin?" prompt)
  curl -fsSL https://get.jetify.com/devbox | bash -s -- -f
fi
 
# ── 7. Install project packages ───────────────────────────────────────────────
echo "→ Running devbox install..."
devbox install
 
# ── 8. Ensure shell init is set up for both bash and zsh ─────────────────────
add_to_shell() {
  local marker="$1" text="$2" file="$3"
  touch "$file"
  grep -qF "$marker" "$file" || printf '\n%s\n' "$text" >> "$file"
}
 
# Source nix in all shells (devbox adds its own wrapper around this too)
NIX_INIT='. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh 2>/dev/null || true'
add_to_shell 'nix-daemon.sh' "$NIX_INIT" "$HOME/.bashrc"
add_to_shell 'nix-daemon.sh' "$NIX_INIT" "$HOME/.zshrc"
 
# devbox adds itself to PATH via its own init; make sure devbox shim is reachable
DEVBOX_PATH_INIT='export PATH="${HOME}/.local/share/devbox/global/default/.devbox/nix/profile/default/bin:${PATH}"'
add_to_shell 'devbox' "$DEVBOX_PATH_INIT" "$HOME/.zshrc"
add_to_shell 'devbox' "$DEVBOX_PATH_INIT" "$HOME/.bashrc"
 
echo "✓ on-create complete"

📥 Download on-create.sh template

.devcontainer/post-start.sh

Runs on every container start (including restarts). Keeps idempotent tasks that don't survive a stop/start cycle:

  1. Restart nix-daemon (no systemd in devcontainers; daemon doesn't survive stop/start)
  2. Wait for the Docker daemon to be reachable (timeout configurable via DOCKER_WAIT_SECONDS) — Docker is needed for act and the optional local Postgres fallback
Example post-start.sh
#!/usr/bin/env bash
# .devcontainer/post-start.sh
# Runs on every container start. Keeps lightweight tasks idempotent.
set -euo pipefail
 
# Derive repo root dynamically to work across different workspace layouts.
ROOT="${ROOT:-${WORKSPACE_FOLDER:-}}"
if [ -z "${ROOT}" ]; then
  # Assume this script lives in .devcontainer/ under the repo root.
  SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd -P)"
  ROOT="$(cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd -P)"
fi
# Final fallback to the current directory if derivation failed for some reason.
ROOT="${ROOT:-${PWD}}"
 
LOG_FILE="/tmp/postStart.log"
DOCKER_WAIT_SECONDS="${DOCKER_WAIT_SECONDS:-60}"
 
log() {
  printf '%s\n' "$*" | tee -a "$LOG_FILE"
}
 
# Restart nix-daemon for this container session (no systemd in devcontainers).
if [ -x /nix/var/nix/profiles/default/bin/nix-daemon ]; then
  /nix/var/nix/profiles/default/bin/nix-daemon --daemon &>/tmp/nix-daemon.log &
  nix_daemon_pid=$!
  sleep 2
  if ! kill -0 "$nix_daemon_pid" 2>/dev/null; then
    log "post-start: failed to start nix-daemon; see /tmp/nix-daemon.log"
    exit 1
  fi
  # Verify daemon is responsive
  if command -v nix >/dev/null 2>&1; then
    for _ in {1..5}; do
      nix store ping >/dev/null 2>&1 && break
      sleep 1
    done
  fi
fi
 
# Wait for Docker to be reachable (needed by act and the optional local
# Postgres fallback). Local dev otherwise talks to a remote Neon dev branch,
# so there are no database images to pre-pull.
log "post-start: waiting for Docker daemon (timeout ${DOCKER_WAIT_SECONDS}s)..."
for _ in $(seq 1 "$DOCKER_WAIT_SECONDS"); do
  if docker info >/dev/null 2>&1; then
    log "post-start: Docker is ready"
    break
  fi
  sleep 1
done

📥 Download post-start.sh 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.