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
| 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)
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
fiScripts
| 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.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_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) |
onCreateCommand | .devcontainer/on-create.sh |
postCreateCommand | devbox run setup (logged to /tmp/postCreate.log) |
postStartCommand | Run .devcontainer/post-start.sh (restart Nix daemon, wait for Docker) |
remoteEnv | DOCKER_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:
- Ensure Docker is installed — uses
get.docker.comconvenience installer (Codespaces-aware: skips install if already present, warns without failing if daemon unreachable) - Initialize git submodules (non-fatal if credentials unavailable)
- Install
zshif absent - Install
tmuxvia apt (host-OS integration; not from Nix) - Configure Nix:
sandbox = false,filter-syscalls = false(heredoc append, idempotent) - Install Nix (Determinate Systems:
--init none,--no-confirm) - Start Nix daemon (
sleep 3for socket readiness) - Install Devbox
devbox install- 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:
- Restart
nix-daemon(no systemd in devcontainers; daemon doesn't survive stop/start) - Wait for the Docker daemon to be reachable (timeout configurable via
DOCKER_WAIT_SECONDS) — Docker is needed foractand 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 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.