boring

Code is a thinking medium.

boring turns any repo into a shared scratch pad your whole team can think inside — engineers, marketers, managers, the person with the great idea. Open the container, prompt Claude, and turn “what if the buying-guide page had inline product comparisons?” into a working visual you can put on a screen in a meeting. The output isn’t always a mergeable PR. Sometimes it’s a wireframe. A mockup. A pitch.

v0.3 in progress — v1.0 ~3-4 months out Profile-driven dev containers for any stack Claude inside the box, audit-logged macOS · Linux

This is the thesis — not yet validated by external users. Tom is the only user today. Validated on a production Shopify theme and a Django + React + Postgres app: same schema, same single command, two unrelated stacks.

The problemMixed teams can’t think in code together.

A marketer has an idea for the buying-guide page. A PM wants to sketch a new checkout flow. A founder wants to show investors what the v2 dashboard could look like. The fastest path from idea to “here’s a working draft” runs straight through the codebase — but the codebase is locked behind “clone the repo, install Postgres, configure your env, get the API keys, restore a backup, install Redis, hope it works.”

So instead the team mocks it up in Figma, writes it up in a doc, argues about it in Slack, and an engineer eventually builds something that’s close to the intent but not quite. Two weeks have passed. The idea got smaller along the way.

boring closes that gap. One command opens an isolated container with the real app, real-shape data, and Claude already at the keyboard. The marketer prompts the change. The page renders. The screenshot goes in the deck. The engineer reviews it Monday.

How it worksThe same six steps for any stack.

Whether the repo is a Shopify theme, a Django + React app, a Next.js + Mongo project, a FastAPI service, or a polyglot data pipeline, the loop is the same. Every field in the profile maps to a docker-compose or devcontainer primitive — boring is glue, not magic.

  1. Install once. brew install devcontainer jq yq, install Orbstack or Docker Desktop, install boring via the curl one-liner. boring doctor tells you what’s missing — including which optional secret-resolver CLIs (op, vault, security, aws) your profile happens to need, written in language that means something to a non-engineer.
  2. Drop a profile into the repo. .boring/profile.yaml declares: the base image (a curated preset, or your own stack.dockerfile:), any sidecars (services: — postgres, redis, mongo, kafka, anything compose accepts), any host mounts, any forwarded ports, any env, any secret URIs, any first-run setup commands, any guardrails. Use as few fields as your project needs. Reviewable in a PR.
  3. Open it. boring open . parses + validates the profile, resolves secret:// URIs from your existing store in memory, generates .devcontainer/docker-compose.yml (with healthcheck-aware depends_on auto-wired between sidecars and the dev service), and brings everything up via the standard devcontainer CLI.
  4. Setup runs once, automatically. Any setup: commands (migrations, dependency installs, seeding, build steps — anything your project needs on first up) run after sidecars report healthy. boring writes a success marker; if it’s missing post-up, boring re-runs the chain. Belt-and-suspenders against the “hook half-failed and nobody noticed” case.
  5. Think in code with Claude in the loop. Your forwarded ports are live on localhost. Edit in VS Code (attached via the standard Dev Containers extension — nothing boring-specific). Claude inside the container is project-scoped: this project’s MCP servers, this project’s memory, this profile’s tool allowlist. Prompt the change you want to see, take the screenshot, send it. Resolved secrets are visible to processes but never written to .env, the compose file, or disk.
  6. Decide what becomes a PR. Sometimes the output is a mockup that goes in a deck. Sometimes it’s a wireframe attached to a Linear ticket. Sometimes it’s a real branch: commit from inside the container, gh pr create, your engineers review. The profile is the trust anchor — in-container agents can’t modify .boring/*, can’t disable the audit hooks, can’t push to forbidden branches.

Auto-clone via boring open <git-url> lands in v0.3. Headless boring run "<claude-prompt>" --profile X lands in v0.6 — fresh container per invocation, Claude prompts only (the shell-command equivalent is just devcontainer exec).

Why boringThe boring choices, finally.

Most “make dev easier” tools cheat by skipping the hard parts — isolation, real data, what the AI inside the box is actually allowed to do — and you find out at the worst possible moment. boring is built around three priorities, in this order:

Security first The threat model is keeping non-engineers and AI from accidentally damaging prod systems, not preventing a malicious insider from exfiltrating data. So: secrets live in your existing store (1Password, Keychain, Vault, AWS Secrets Manager, dbx vault) and are resolved at container start — never written to disk. The profile is the trust anchor: in-container agents can’t modify .boring/*, ~/.claude/settings.json, or the audit-hook scripts (Claude deny rules + system-wide git pre-commit hook). Guardrail codegen lands in v0.3 (ARD-0009); egress allowlist enforcement lands in v0.4 with --learn-mode for authoring it (ARD-0011). Framing: ARD-0005.
Practicality second One CLI. Stateless. Compose sidecars under the hood — standard docker compose, not a custom runtime. The profile lives in your repo as .boring/profile.yaml — reviewable in a PR, versioned with your code, the single source of truth. No drift across teammates’ laptops. Output is a standard devcontainer.json — teammates who don’t install boring still get a working dev container via VS Code or Codespaces.
Quality over speed “I want this to just work” is the bar. First open ~60-120 seconds depending on the preset and sidecar set. Subsequent opens in ~5 seconds — container stays warm. Tom personally helps team members install during the early adoption phase; the goal is “you don’t notice the tool, you notice that the thing you wanted to try works.” Source code lives in a bind-mount so host-side and container-side git are the same git. Orbstack on Mac means near-native file performance.

Under the hoodDesign calls worth explaining.

Every interesting decision is recorded as an ARD in the repo. The seven below are the ones that drive the daily experience.

Presets are polyglot, not per-technology

preset: django-node — one curated image with Python and Node, not two presets you stitch together. Because FROM picks one base image: you can’t merge a django Dockerfile and a node Dockerfile into a third one cleanly.

So the unit is “kind of project,” not “tool.” v1.0 ships five: python, node, node-postgres, django-node, shopify. Each is one directory under templates/: Dockerfile, README, defaults. Versions are parameterized via build ARGs + a preset_version: map. ARD-0007 + ARD-0014.

Secrets never touch disk

The profile declares URIs: OPENROUTER_API_KEY: secret://op://MyTeam/…. At container-start time, boring shells out to op read (or security find-generic-password, or vault kv get, etc.), captures the value in memory, and passes it to devcontainer up --remote-env KEY=VALUE.

Resolved values are never written to docker-compose.yml or devcontainer.json — even though those files are gitignored. On-disk secrets are a backup-and-sync exfil channel boring won’t open. ARD-0002.

Profile schema is versioned

Every profile declares profile_version: "1". Missing → warning. Unknown future version → hard error with an upgrade hint. Major-only (no semver) so the cognitive cost is small.

A deprecation table in lib/profile.sh handles renames as soft deprecations: theme:preset: warns and rewrites in-memory, today. v2 will hard-remove. Renames never break anyone’s in-the-wild profile. ARD-0007.

Lifecycle hooks are belt-and-suspenders

setup: → emitted as postCreateCommand in devcontainer.json. Devcontainer-native: fires for boring open and for VS Code “Reopen in Container.” The chain writes /var/lib/boring/setup-complete on success.

boring open re-verifies that marker after devcontainer up returns. If it’s missing (the failure mode where bootstrap_data raced the Postgres healthcheck and exited 1), boring re-runs the chain via devcontainer exec. Silence isn’t success. ARD-0007.

The profile is the trust anchor

.boring/profile.yaml defines what the container is allowed to do: mounts, ports, secret URIs, allowed Claude tools, branch guardrails, audit visibility. So the in-container agent must not be allowed to edit it — or the audit hooks, or its own settings. Same principle as Kubernetes RBAC or AWS IAM: the policy is not modifiable by the actor.

Enforced two ways: Claude Code deny rules covering /workspace/.boring/**, ~/.claude/settings.json, and the audit-hook scripts — plus a system-wide git pre-commit hook (core.hooksPath /etc/boring/git-hooks) that refuses commits touching any of them. Holds even when the agent is jailbroken. ARD-0006 (extended by ARD-0009 + ARD-0010).

Audit + prompt tracing built in

Every guardrail violation, restore, and egress block writes to a FIFO that a host-side collector drains and persists tamper-resistant. Security events are profile-shared and always logged — the team sees what the team did.

Prompt content is per-user by default (engineers see only their own prompts) with an opt-in audit.prompts: shared for team-learning use cases. Wired through Claude’s native hooks; the in-container agent can’t disable it because the hook scripts are write-protected. Lands in v0.3. ARD-0010.

Compose existing tools; reimplement nothing

boring is glue. dbx for backups and the vault. @devcontainers/cli for container lifecycle. docker compose for sidecars. op, security, vault, aws secretsmanager for secret resolution. Claude Code for the in-box agent.

boring owns zero secret storage. boring runs no container loop. boring doesn’t reimplement anyone else’s tool. When dbx fixes a Postgres regression, boring users benefit by upgrading dbx — no boring release needed.

For your teamThe collaborative scratch pad.

boring is built for mixed teams — the engineer, the marketer, the manager, and the founder sitting at the same codebase, using code as a thinking medium together. The container is shared, the AI is shared, the conversation is shared. What anyone ships out of it is up to them.

Marketers · content leads

Sketch the page change directly.

You don’t need to know what Postgres is. boring open the shop, prompt Claude with “add inline product comparisons to the buying-guide template,” reload the preview, screenshot the result. Drop it in the doc you were going to write anyway — only now it’s a real page, not a Figma frame.

PMs · designers · founders

Pitch with a working draft, not a mockup.

Wireframes are persuasion theater. A working version of your idea, running with the real-shape data, navigable in a browser, is the actual thing. Build it in an afternoon, show it on Monday, decide whether to invest the eng cycles for real. Skip the “is this what you meant?” loop.

Tech leads

Stop being the dev environment helpdesk.

One profile in the repo replaces twelve Notion pages, four Slack threads, and the recurring “how do I run this locally” interrupt. The non-engineers on your team can think in code without breaking anything — agents are sandboxed, branches are gated, audit logs are tamper-resistant. The dev environment is a PR like anything else.

Engineers on mixed teams

Reviewable artifacts instead of vibes.

When the marketer says “I want this,” they hand you a branch and a screenshot of the rendered result, not a paragraph. The data shape is real. The change actually works in the container. You decide whether it’s mergeable as-is, needs tightening, or was a useful prototype to throw away. Either way, the conversation skips three meetings.

Anatomy of a profile.boring/profile.yaml — every field, what it unlocks.

Use as many or as few fields as your project needs. Each field maps to a docker-compose or devcontainer primitive — no boring-specific magic, no “configuration as code” framework to learn. Empty fields are fine. Omit fields entirely when they don’t apply.

# .boring/profile.yaml
profile_version: "1"           # schema version; missing → warn, unknown → error w/ upgrade hint
name: your-app                 # slug; becomes the compose project name

# --- Base image: pick ONE of three paths ---
preset: django-node            # curated preset; v1.0 ships: python, node, node-postgres, django-node, shopify
preset_version:                 # override the preset's default language versions (build ARGs)
  python: "3.12"                #   default is 3.14; pin per-project as needed
  node: "20"                     #   default is 20; bump per-project as needed
# OR:
# stack:
#   dockerfile: ./Dockerfile.dev   # YOUR Dockerfile, anywhere in the repo
# OR:
#   base_image: node:20-bookworm-slim   # an existing registry image, no build

# --- Sidecars: any image compose accepts ---
services:
  - name: postgres                       # compose service name + DNS hostname on the network
    image: postgres:17
    env: { POSTGRES_DB: app, POSTGRES_PASSWORD: dev }
    volumes: [postgres-data:/var/lib/postgresql/data]
    healthcheck: { test: ["CMD", "pg_isready", "-U", "postgres"], interval: 5s }
  - name: redis
    image: redis:7
  # … or mongo, mysql, kafka, clickhouse, minio — anything in a registry.
  # dev.depends_on is auto-wired: service_healthy when there's a healthcheck,
  # service_started otherwise.

volumes: [postgres-data]                  # top-level named volumes (referenced by services[].volumes)

# --- Mounts: share host dirs into the container ---
mounts:
  - ~/.config/gh:/home/dev/.config/gh         # for browser-OAuth tools (gh, shopify, gcloud, firebase, …)
  - ~/.aws:/home/dev/.aws:ro                  # :ro is supported

# --- Ports: simple integer list, forwarded host↔container ---
forward_ports: [8000, 5173]

# --- Env: literal values + secret URIs side by side ---
env:
  DJANGO_DEBUG: "True"                     # literal — written to compose's environment block

  # Secrets — resolved at container start, never written to disk.
  # SEVEN URI schemes; pick whichever matches your team's secret store.
  OPENROUTER_API_KEY: secret://op://Personal/OpenRouter/api-key   # 1Password
  STRIPE_KEY:         secret://keychain:com.stripe/test-key      # macOS Keychain / Linux libsecret
  VAULT_TOKEN:        secret://vault://secret/data/app/token       # HashiCorp Vault
  AWS_API_KEY:        secret://aws-sm:prod/app/api-key             # AWS Secrets Manager
  DBX_SECRET:         secret://dbx-vault:app-secret                # dbx vault
  FROM_HOST_ENV:      secret://env:MY_LOCAL_VAR                    # host env (CI escape hatch)
  FROM_FILE:          secret://file:/run/secrets/api-key           # local file (Docker secrets, k8s mounts)

# --- Setup: any shell-command list, runs once after first up ---
setup:
  - uv sync --dev
  - uv run python manage.py migrate
  - (cd frontend && npm install)              # subshelled — cd does NOT bleed into the next command
  - ./scripts/seed.sh

# --- Guardrails (schema parsed today; codegen ships in v0.3) ---
guardrails:
  forbid_branches: [main, production]
  forbid_commands: ["gh pr merge", "kubectl apply"]
  allowed_claude_tools: [read, edit, grep, bash]

# --- Audit (v0.3): tiered visibility for events vs. prompt content ---
audit:
  events: shared                            # guardrail violations, restores, egress blocks: team-wide, always logged
  prompts: per_user                          # engineers see their own prompts only; set "shared" to opt into team learning

# --- Restore (v0.5): real-shape data into sidecars, sanitized via dbx ---
restore:                                    # piped through dbx restore --transform, never on disk unsanitized
  - source: dbx://prod/app-postgres         #   backup to draw from
    target: postgres                          #   which sidecar service to restore into
    transform: ./scripts/sanitize.sql          #   the dbx --transform script that strips PII
    when: on_first_up                          #   on_first_up | on_request | never

data_sensitivity: internal                # internal | sanitized | public (gates ephemeral volumes; v0.5)

egress:                                    # declarative today; enforcement ships v0.4 with iptables-in-container
  allow: [api.anthropic.com, github.com, registry.npmjs.org, openrouter.ai]

claude:
  mcp: []                                  # project-scoped MCP servers (Linear, Sentry, etc.)

Two real profiles in the wild today: a production Shopify theme (host bind-mounts for the CLI’s OAuth tokens, no setup hooks, no sidecars) and a Django + React + Postgres app (preset + secret URIs + setup chain + sidecar). Both use the same schema above. ARDs for every design call.

“Code is the highest-resolution medium we have for thinking about software. The trick is letting the whole team think in it, not just the people who learned to clone a repo.”

CapabilitiesWhat boring does, what ships when.

Honest tags. today = working in the current dev build (v0.2 shipped; v0.3 in progress). v0.3 through v1.0 = on the roadmap below, scheduled, not yet shipping.

today
Profile-in-repo

.boring/profile.yaml is the single source of truth. Sharing the profile is sharing the repo — no export/import dance, no drift across laptops. Schema validated, overlay merge for user-local profile.overlay.yaml escape hatches.

today
Curated presets or BYO Dockerfile

Use a preset for instant defaults, or point stack.dockerfile: at any Dockerfile in your repo, or set stack.base_image: to any registry image. v1.0 ships five presets: python, node, node-postgres, django-node, shopify. shopify and django-node ship today.

today
Any image as a sidecar

Postgres, Redis, MongoDB, MySQL, Kafka, ClickHouse, MinIO, Elasticsearch — if compose accepts it, boring emits it. Top-level named volumes, healthchecks, per-service env, sidecar-to-sidecar depends_on. dev.depends_on auto-wires with healthcheck-aware conditions.

today
Secrets from your existing store

Seven URI schemes: op://, keychain:, vault://, aws-sm:, dbx-vault:, env:, file:. Resolved at container start, passed via --remote-env, never written to .env or any generated file. boring stores nothing.

today
First-run lifecycle hooks

setup: is any shell-command list. Migrations, dependency installs, seed data, build steps. Emitted as devcontainer’s postCreateCommand (works for VS Code “Reopen in Container”) AND re-verified by boring via a success marker. Belt-and-suspenders.

today
Project-scoped AI

Claude lives inside the container, with project-scoped MCP servers, memory, and history. A poisoned file in one project can’t read another’s notes. The profile is the trust anchor — in-container agents can’t modify .boring/*, ~/.claude/settings.json, or the audit-hook scripts.

today
Schema versioning + soft deprecations

profile_version: "1" declares the schema you authored against. Renames ship as warnings, not breaking changes; deprecated fields are rewritten in-memory. Your profile keeps working when boring evolves.

today
VS Code & Codespaces compatible

boring generates a standard devcontainer.json. Your team can open the project with VS Code’s Dev Containers extension — or in GitHub Codespaces — with no boring CLI installed on their end. boring is the authoring tool; devcontainer is the runtime.

v0.3
Guardrails codegen

The guardrails: block (forbidden branches, forbidden commands, allowed Claude tools) is parsed today. v0.3 codegens in-container pre-push hooks, command wrappers, and the Claude tool allowlist — so accidental pushes to main or invocations of gh pr merge are mechanically impossible.

v0.3
Audit log + prompt tracing

FIFO inside the container; host-side collector drains and persists tamper-resistant. Security events (guardrail violations, restores, egress blocks) are shared and always logged. Prompt content is per-user by default; audit.prompts: shared opts in to team learning. Wired through Claude native hooks.

v0.3
curl install + public repo + URL clone

One-line install via curl | bash, the repo flips public (currently invite-only), and boring open <git-url> clones-and-opens in one step. Plus a non-engineer-shaped boring doctor that explains what’s missing in language that means something.

v0.4
Egress enforcement + --learn-mode

iptables-in-container enforcement of the egress.allow: list, shipped together with --learn-mode for observing a session and proposing the allowlist from what your app actually called. Enforcement without an authoring tool is unshippable; they ship in the same slice.

v0.5
dbx restore into sidecars

Sidecars today carry empty schemas seeded by your setup: hook. v0.5 adds the restore: profile field (source/target/transform/when) and pipes prod-shape data through dbx restore --transform=<script> into the running sidecar — sanitized at stream time, ephemeral, never on disk unsanitized. Pending dbx-side PRs.

v0.6
Headless boring run

boring run "<claude-prompt>" --profile X spins up a fresh container, runs Claude with the profile’s sandbox shape, and exits. Input is a Claude prompt only — the shell-command equivalent is just devcontainer exec, and boring doesn’t need to be a fancier version of that.

v1.0
Five-preset polish + final pass

All five presets (python, node, node-postgres, django-node, shopify) shipped with preset_version: parameterization. Marketing site final pass. The bar is “I want this to just work.” (bun as a separate opt-in preset, plus brew/winget formulas, deferred to v1.x.)

RoadmapWhere it goes from here.

Total calendar from here to v1.0: ~3-4 months. v0.3 lands on early team members for phased feedback; v1.0 is the public release. Full release plan + thesis evolution: ARD-0008.

v0.1

Single-service profiles

Shipped. Profile parser, schema validator, single-service compose, Dockerfile presets (Shopify first), secret URI resolver as a library, boring doctor. End-to-end validated on a production Shopify theme.

v0.2

Multi-service + secrets + lifecycle

Shipped. Multi-service compose with auto-wired sidecars, secret URI resolution at container start (in memory only), setup: hooks with marker verification, schema versioning + soft deprecations, second curated preset (django-node). End-to-end validated on a production Django + React + Postgres app.

v0.3

Trust + observability layer

4-6 weeks. Guardrails codegen (pre-push hook + command wrappers + Claude tool allowlist). Audit log infrastructure (FIFO + host collector, tamper-resistant). Prompt tracing via Claude native hooks. curl install one-liner. Public-repo flip. Non-engineer-shaped boring doctor. This marketing site rewrite. Trust + observability complete at handoff. ARD-0009 + ARD-0010.

v0.4

Egress enforcement + --learn-mode

2-3 weeks. iptables-in-container egress allowlist enforcement, shipped together with --learn-mode for proposing the right allowlist from observation. Enforcement and authoring ship together — one without the other is unshippable. ARD-0011.

v0.5

dbx restore + real-shape data

2 weeks, pending dbx-side PRs. restore: profile schema (source / target / transform / when). Real-shape data piped into sidecars via dbx restore --transform at stream time. Sanitized, ephemeral, never on disk unsanitized. data_sensitivity gates ephemeral volumes. ARD-0012.

v0.6

Headless boring run

2 weeks. boring run "<claude-prompt>" --profile X. Fresh container per invocation. Claude prompt input only (shell-command equivalent is devcontainer exec, and boring isn’t a fancier version of that). Same sandbox shape, vault, and guardrails as interactive. ARD-0013.

v1.0

Polish + five presets + public release

2 weeks. Five presets complete (python, node, node-postgres, django-node, shopify) with preset_version: parameterization. Final marketing pass. Quality bar: “I want this to just work.” brew formula and a bun preset are v1.x. winget later. ARD-0014.

v2

Team telemetry

Optional opt-in metrics: first-open duration, build times, failure stages, which prompts led to a merged PR. So you can actually measure whether the dev experience is improving instead of guessing.

Get startedToday — the early-adopter path.

The repo is private during v0 development — access is by invite while Tom personally helps the first team members install. The v0.3 slice flips the repo public and ships the one-line curl installer. brew deferred to v1.x.

# prerequisites: docker (Orbstack on Mac), then:
$ brew install devcontainer jq yq    # yq must be the mikefarah Go variant
$ git clone git@github.com:steig/boring.git ~/code/boring
$ export PATH="$HOME/code/boring:$PATH"
$ boring doctor

# in any boring-enabled repo (one with .boring/profile.yaml):
$ boring open .

# coming in v0.3 — the one-line install:
# $ curl -fsSL https://boring.steig.io/install.sh | bash

Want access, or to chat about whether boring fits your team? Reach tom@steig.io. Architecture and decisions are all in docs/ards/ — designed in the open, recorded as ARDs, evolved as we learn.