Skip to content

Anatomy of a Profile

.boring/profile.yaml is the single source of truth for what a repo's dev container looks like — base image, sidecars, mounts, ports, env, secrets, setup commands, guardrails, audit, restore, egress, AI tooling.

It lives at the root of the repo under .boring/profile.yaml. It's reviewable in a PR. It's the same file your teammates use. It's the file the in-container agent is NOT allowed to modify (ARD-0006).

This page documents every field with an example. Each field maps to a docker-compose or devcontainer.json primitive — there's no boring-specific magic, no configuration framework to learn. Use as many or as few fields as your project needs. Omit fields entirely when they don't apply.

If you're new, Getting Started is the operational walkthrough; the smallest-useful profile lives there. This page is the field-by-field reference.

Schema at a glance

# .boring/profile.yaml — every field, in declaration order.
profile_version: "1"           # schema version
name: your-app                 # slug → compose project name

# Base image: pick ONE of preset / stack.dockerfile / stack.base_image
preset: django-node
preset_version: { python: "3.12", node: "20" }

# OR:
# stack:
#   dockerfile: ./Dockerfile.dev
# OR:
# stack:
#   base_image: node:20-bookworm-slim

services:                      # sidecars (any compose-compatible image)
  - name: postgres
    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 }

volumes: [postgres-data]       # top-level named volumes

mounts:                        # host:container bind mounts
  - ~/.config/gh:/home/dev/.config/gh
  - ~/.aws:/home/dev/.aws:ro

forward_ports: [8000, 5173]    # host↔container forwarding

env:                           # literal + secret URIs side by side
  DJANGO_DEBUG: "True"
  OPENROUTER_API_KEY: secret://op://MyTeam/OpenRouter/api-key

setup:                         # one-time post-up commands
  - uv sync --dev
  - uv run python manage.py migrate

tasks:                         # long-running processes, tmux-supervised (ARD-0023)
  - name: api
    run: uv run python manage.py runserver
  - name: web
    run: npm run dev
    depends_on: [api]

guardrails:                    # codegen lands in v0.3
  forbid_branches: [main, production]
  forbid_commands: ["gh pr merge"]
  allowed_claude_tools: [read, edit, grep, bash]

audit:                         # v0.3
  events: shared
  prompts: per_user

restore:                       # v0.5 — real-shape data via dbx
  - source: dbx://prod/app-postgres
    target: postgres
    transform: ./scripts/sanitize.sql
    when: first_up

data_sensitivity: internal     # internal | sanitized | public (v0.5)

egress:                        # outbound allowlist (enforced; metadata floor always on)
  allow: [api.anthropic.com, github.com, registry.npmjs.org]
  sni: false                   # true = SNI-aware :443 proxy; enables wildcards (ARD-0034)

extensions:                    # VS Code extensions → devcontainer.json
  - dbaeumer.vscode-eslint
extension_settings:            # VS Code workspace settings
  "editor.formatOnSave": true

claude:
  mcp: []                      # project-scoped MCP servers

The sections below explain each field in depth.


profile_version (required)

profile_version: "1"

Declares the schema version your profile was authored against. Currently "1". Missing → warning, unknown future version → hard error with an upgrade hint.

Major-only versioning (no semver) — the cognitive cost is small. Soft deprecations for renames live in a table inside lib/profile.sh; deprecated fields are rewritten in-memory with a warning, so your old profile keeps working when the schema evolves. (See ARD-0007.)

name (required)

name: my-app

Slug used as the compose project name. Sidecar containers get predictable names (my-app-postgres-1 rather than devcontainer-postgres-1), which makes dbx restore --into <container> and similar tooling tractable.

Pick something short, lowercase, hyphen-separated. Conventionally matches the repo name.


Base image — pick ONE of three paths

A .boring/profile.yaml needs exactly one source for the dev container's base image: preset:, stack.dockerfile:, or stack.base_image:. They're mutually exclusive.

preset: — curated images

preset: django-node
preset_version:
  python: "3.12"
  node: "20"

A preset: selects one of the curated templates/<preset>/Dockerfile images. v1.0 ships five:

Preset What's in it Sidecars seeded by default
python Python 3.14 + uv + Claude Code none
node Node 20 + npm + Claude Code none
node-postgres Node 20 + libpq + psql + Claude Code postgres:17
django-node Python 3.14 + uv + Node 20 + libpq + psql + Claude Code postgres:17, with DATABASE_URL env wired in
shopify Ruby 3 + Bundler + Shopify CLI + Node + Claude Code none

Today (v0.6-dev): shopify and django-node are end-to-end validated against production repos. The other three are scheduled for v1.0 polish.

Why polyglot presets, not one-per-tool? 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." (ARD-0007 + ARD-0014)

preset_version: is an optional override map. Each key targets a Dockerfile build ARG. Defaults are the latest stable of each language at the preset's release.

stack.dockerfile: — your own Dockerfile

stack:
  dockerfile: ./Dockerfile.dev

Use this when none of the presets fits. boring will docker build it as the dev image. The Dockerfile can live anywhere in the repo; the path is relative to the repo root.

You're responsible for installing git, claude-code (if you want in-container Claude), and any other tooling. The presets in templates/ are a good starting template.

stack.base_image: — registry image, no build

stack:
  base_image: node:20-bookworm-slim

The fastest path when an upstream image already has what you need. No Dockerfile build step.

You won't get Claude Code preinstalled this way — install it in your setup: chain if you need it.


services: — sidecars

services:
  - name: postgres
    image: postgres:17
    env:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: dev
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "app", "-d", "app"]
      interval: 5s
      timeout: 3s
      retries: 10

  - name: redis
    image: redis:7
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s

  - name: mongo
    image: mongo:7
    volumes:
      - mongo-data:/data/db

Each entry becomes a service block in the generated docker-compose.yml. Fields per service:

  • name (required) — compose service name and DNS hostname on the network. From inside the dev container, you reach postgres as postgres:5432, redis as redis:6379, etc.
  • image (required) — any registry image. postgres, redis, mongo, mysql, kafka, clickhouse, minio, elasticsearch — if docker pull works, boring emits it.
  • env (optional) — service env vars, written to the compose service's environment: block as-is. secret:// URIs work here too.
  • volumes (optional) — service-scoped volumes. Named volumes need to be declared at the top level (see volumes: below).
  • healthcheck (optional) — standard compose healthcheck syntax. Strongly recommendeddev.depends_on is auto-wired with condition: service_healthy when a sidecar has a healthcheck, and service_started otherwise. That's the difference between setup: running against a fully-booted Postgres vs. racing it.

dev.depends_on is automatic — you do not declare it. boring inspects the services: list, finds the ones with healthchecks, and emits the right conditions. (ARD-0007)

volumes: — top-level named volumes

volumes: [postgres-data, mongo-data]

Compose requires named volumes to be declared at the top level before they can be referenced by services. boring emits them verbatim. If you only use anonymous (./data:/data) or bind-mount (~/data:/data) volumes, you can omit volumes: entirely.


mounts: — host bind mounts

mounts:
  - ~/.config/gh:/home/dev/.config/gh           # gh CLI's OAuth token
  - ~/.config/shopify:/home/dev/.config/shopify # shopify CLI session
  - ~/.aws:/home/dev/.aws:ro                    # read-only AWS credentials
  - ~/.kube:/home/dev/.kube:ro
  - ./scripts:/workspace/scripts                # extra repo-local dir

Standard docker -v syntax: host-path:container-path[:ro]. ~ expands to the host user's home. Use this for tools that authenticate via long-lived host-side OAuth tokens (gh, shopify, gcloud, firebase), or for :ro host credential dirs.

The repo itself is bind-mounted automatically at /workspace — you don't need to declare it.

forward_ports: — host↔container port forwarding

forward_ports: [8000, 5173, 3000]

A simple integer list. Each port is forwarded host↔container 1:1. Run python manage.py runserver (binding 0.0.0.0:8000) inside the container and hit localhost:8000 from your host browser.

Range syntax and host:container differences are not supported in v1 — use a single integer per entry.


env: — environment variables (literal + secret URIs)

env:
  DJANGO_DEBUG: "True"                            # literal
  DATABASE_URL: "postgres://app:dev@postgres:5432/app"

  # Secrets — resolved at container start, in memory, never written to disk.
  OPENROUTER_API_KEY: secret://op://MyTeam/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        # Docker secrets, k8s mount, etc.

Literal values are written to the generated compose file's environment: block as-is.

Secret URIs are different. Any value starting with secret:// is classified as a secret, resolved at container-start time by shelling out to the appropriate CLI, captured in memory, and passed to devcontainer up --remote-env KEY=VALUE. The resolved value is never written to docker-compose.yml, devcontainer.json, .env, or anywhere on disk — even though those files are gitignored. (ARD-0002)

Seven URI schemes are supported. Pick whichever matches your team's secret store:

Scheme Backing CLI Format
op:// op (1Password CLI) secret://op://<vault>/<item>/<field>
keychain: security on macOS, secret-tool on Linux secret://keychain:<service>/<account>
vault:// vault (HashiCorp) secret://vault://<path>
aws-sm: aws secretsmanager secret://aws-sm:<secret-name> or secret://aws-sm:<secret-name>#<json-field>
dbx-vault: dbx vault read secret://dbx-vault:<key>
env: (no CLI) secret://env:<HOST_ENV_VAR_NAME>
file: (no CLI) secret://file:<absolute-path>

env: and file: are CI/Docker-secrets escape hatches; the others go through your team's actual secret store.

File-shaped credentials: secret-file:// (ARD-0034 #3). Some libraries demand a path, not a value — the canonical case is GCP's GOOGLE_APPLICATION_CREDENTIALS pointing at a service-account JSON key. Prefix any of the schemes above with secret-file:// instead of secret://:

env:
  GOOGLE_APPLICATION_CREDENTIALS: secret-file://op://MyTeam/gcp-sa/credential.json

The inner URI is resolved the same way, but the value is materialized to an in-container tmpfs file at /run/boring/secrets/<KEY> (0400, dev-owned), and the env var is set to that path. The content never touches host disk or argv — it is streamed over docker exec stdin after the container is up, and the tmpfs is memory-backed and dies with the container. Two caveats: the key must be strictly env-name-shaped (it becomes the file name), and setup: hooks run during devcontainer upbefore materialization — so they cannot read the file; the dev: command, shells, and boring run's Claude invocation all can.

The host-side CLIs need to be authenticated before boring open . runs — op signin, aws sso login, vault login, etc.


setup: — one-time post-up commands

setup:
  - uv sync --dev
  - uv run python manage.py migrate
  - (cd frontend && npm install)              # subshelled — cd does NOT bleed
  - ./scripts/seed.sh
  - touch /tmp/setup-done

A list of shell commands run once, after the container is up and sidecars report healthy. Migrations, dependency installs, seeding, build steps — anything your project needs on first up.

Emitted as the devcontainer's postCreateCommand. That means it runs for boring open AND for VS Code's "Reopen in Container" flow.

Belt-and-suspenders: 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, e.g., a migration raced the Postgres healthcheck and exited 1), boring re-runs the chain via devcontainer exec. Silence isn't success. (ARD-0007)

Commands run sequentially, in a fresh subshell each. cd frontend && npm install does not affect the next command's working directory — you're back at /workspace for command N+1.


tasks: — long-running processes (ARD-0023)

tasks:
  - name: api
    run: pnpm --filter immich start:dev
  - name: web
    run: pnpm --filter immich-web dev
    depends_on: [api]

Where setup: prepares the sandbox, tasks: starts the app — dev servers, watchers, workers: anything that should launch after setup and keep running until teardown. Each task becomes a window of a single tmux session (boring-tasks) inside the dev container, launched after setup: completes and after restore: runs.

  • name: (required) — kebab-case, unique within the profile. Names the tmux window and the log file.
  • run: (required) — a single shell command string. Runs as the dev user from /workspace via bash -lc "exec <run>", so the process is PID 1 of its window and signals propagate cleanly.
  • depends_on: (optional) — other task names; controls launch order only (topological; cycles are a hard schema error). There is no service_healthy equivalent — a task that needs another task to be ready retries its own connections, the same way it would on a laptop.

Day-to-day surface:

  • boring attach — land in the tmux session; inspect, scroll back, restart a dead task by hand. Detaching does not stop anything.
  • boring logs <name> [-f] — tail a task's log from the host (each window's output is tee'd to /var/log/boring/tasks/<name>.log, a bind-mount of .devcontainer/boring-runtime/task-logs/). Useful in CI and for agents reading their own task output.
  • boring close — tear the sandbox down from any shell (kills the tmux session, docker compose down, stops the audit collector). Ctrl-C in the original boring open foreground still works too.
  • boring open --no-tasks — run setup but skip the launch.

Crash policy (v1): no auto-restart. A task that exits leaves its window open showing the exit status; re-run it by hand from boring attach. Restart policies are deliberately deferred until real-world usage says which knobs matter.

boring run (headless, ARD-0013) ignores tasks: — a one-shot agent invocation has no use for long-running side processes. Don't expect a dev API server in headless CI runs.

tmux ships in every preset image; a custom stack.dockerfile: must apt-get install -y tmux for tasks: to launch (boring doctor checks a running container).


guardrails: — mechanical safety (codegen in v0.3)

guardrails:
  forbid_branches: [main, production, staging]
  forbid_commands:
    - "gh pr merge"
    - "kubectl apply"
    - "terraform apply"
    - "rm -rf"
  allowed_claude_tools: [read, edit, grep, bash, glob]

Schema parsed today; codegen ships in v0.3 (ARD-0009). When v0.3 lands, the host generates three artifacts at boring open time and bind-mounts them RO into the container:

  • pre-push hook from forbid_branches: — refuses pushes whose target ref matches. core.hooksPath repoints to the bind-mount.
  • bin/<cmd> wrappers from forbid_commands: — earlier on PATH than the real binary; prefix-matches argv; passes through to the real binary on no-match.
  • claude/settings.json from allowed_claude_tools:jq deep-merge of the image-baked baseline (ARD-0006 deny rules + ARD-0010 audit hooks) with the per-profile allow list.

So accidental pushes to main or invocations of gh pr merge become mechanically impossible for the agent and the human alike — not just discouraged.


audit: — observability tiers (v0.3)

audit:
  events: shared         # only "shared" today (per-user not meaningful for security)
  prompts: per_user      # per_user | shared

Wired through Claude Code's native hooks. Lands in v0.3 (ARD-0010).

  • events — security events (guardrail_violation, egress_block, restore, command_wrapper_fired) are always profile-shared. The team sees what the team did.
  • prompts — Claude prompt content is per-user by default (engineers see only their own prompts). Set to shared to opt into team-learning use cases where everyone's prompts are visible.

Both write to a FIFO inside the container that a host-side collector drains and persists tamper-resistant. The in-container agent can't disable it because the hook scripts are write-protected via ARD-0006's trust anchor pattern.


restore: — real-shape data into sidecars (v0.5)

restore:
  - source: dbx://prod/app-postgres
    target: postgres
    transform: ./scripts/sanitize.sql
    when: first_up                       # first_up | every_up | manual

Pipes prod-shape data through dbx restore --transform=<script> into a running sidecar, sanitized at stream time, ephemeral. Never on disk unsanitized. (ARD-0012)

Per-entry fields:

  • source — a dbx:// backup URL.
  • target — must reference a services: entry by name. Validation fails on a typo.
  • transform (optional, required if data_sensitivity: sanitized) — path to the dbx --transform script that strips PII.
  • whenfirst_up (default), every_up, or manual.

Idempotent by default via per-entry marker files at ~/.local/share/boring/restore-state/<profile>/<idx>-<target>.complete. boring restore <path> --refresh clears markers and re-runs.

data_sensitivity — operator-asserted data classification

data_sensitivity: internal      # internal | sanitized | public
  • internal — no real data ever in this container. restore: is rejected at profile parse.
  • sanitized — real-shape data allowed, but every restore: entry must declare a transform:.
  • public — anything goes.

What boring enforces vs. what you assert. boring enforces the parse-time interlocks above (internal forbids restore:; sanitized requires a transform: on each restore: entry). It does not verify data that arrives outside its view: if you declare sanitized but provision data with a host-side script and no boring-run restore: + transform:, boring cannot confirm the data was actually scrubbed — so boring open / boring run emits a warning. data_sensitivity is an operator assertion, not a guarantee boring can back. (ARD-0039)

The DB-volume ephemerality originally envisioned for this field (ARD-0001) is not yet implemented; today the value gates restore: and drives the warning above.


egress: — outbound network allowlist (declarative today; enforced v0.4)

egress:
  allow:
    - api.anthropic.com
    - github.com
    - registry.npmjs.org
    - pypi.org
    - api.openrouter.ai

A simple hostname allowlist, enforced in-container via iptables with CAP_NET_ADMIN (not --privileged). (ARD-0011, cross-platform --learn-mode via ARD-0015)

The right way to author this list: run boring open --learn-mode once, exercise the app, hit Ctrl-C, and paste the proposed egress.allow: diff into your profile. Enforcement and authoring ship together — one without the other is unshippable.

Always-on floor (ARD-0036). Beneath the allowlist, cloud-metadata (169.254.169.254, ECS 169.254.170.2, EC2 IMDSv6) and link-local (169.254.0.0/16, fe80::/10) are dropped unconditionally, in every mode — the #1 SSRF / credential-theft target a prompt-injected agent would reach for. The DNS resolver is carved out so name resolution still works.

Internal-network blocks (cross_sandbox + RFC1918). In enforce/learn modes the container's own docker subnet and the private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) are dropped too — so a compromised agent can't reach sibling sandboxes or the host LAN. The DNS resolver and the profile's declared sidecars (services[].name, resolved at boot) are carved out first, so dev → postgres/redis keeps working. If a sidecar name can't be resolved the blocks are skipped for that run (fail-open) rather than risk severing a sidecar.

boring open --unsafe-network. Relaxes egress to default-ACCEPT — the allowlist is not enforced, only the metadata/link-local floor still blocks. Mutually exclusive with --learn-mode. Use only when you accept the exposure (e.g. a throwaway repo); the warning is loud and intentional.

egress.sni: true — SNI-aware TLS egress (ARD-0034). The default enforcer resolves each hostname to IPs once at container boot, which cannot track CDN/anycast/rotating-IP services (BigQuery, *.googleapis.com, *.myshopify.com, cloud DBs) — long jobs drift off the pinned set and get rejected. Opting in redirects outbound :443 through a filtering proxy sidecar (tools/boring-egress-proxy) that matches the hostname the client actually requested (TLS SNI) against the same allowlist, with wildcard support:

egress:
  sni: true
  allow:
    - "*.googleapis.com"     # wildcards need sni: true
    - api.anthropic.com

No TLS interception — the proxy reads the plaintext server_name and splices the encrypted bytes through. Wildcard entries are only enforceable in SNI mode (they can't be IP-pinned; without sni: true they grant nothing and boot logs a warning). Non-:443 traffic keeps the IP-pinned rules; connections without SNI are denied; if the proxy is down, :443 fails closed. Applies to enforce mode only — --learn-mode and --unsafe-network behave as before.


claude: — project-scoped AI configuration

claude:
  mcp:
    - name: linear
      url: https://mcp.linear.app/sse
    - name: sentry
      command: ["uvx", "sentry-mcp"]

Project-scoped Claude Code configuration. mcp: lists MCP servers the in-container agent has access to — Linear, Sentry, custom ones. Each entry is forwarded verbatim into the container's ~/.claude/mcp.json.

The in-container Claude lives in a sandbox: this project's MCP servers, this project's memory, this profile's tool allowlist. A poisoned file in one project can't read another's notes. (ARD-0001)


extensions: / extension_settings: — VS Code editor setup

extensions:
  - dbaeumer.vscode-eslint          # publisher.id
  - shopify.theme-check-vscode@2.5.0 # optional @version pin
extension_settings:
  "editor.formatOnSave": true
  "eslint.run": "onType"

Declare the VS Code extensions and workspace settings boring writes into the generated devcontainer.json (customizations.vscode), so an editor attaching to the container gets a consistent setup. (ARD-0018)

  • extensions: — a list of Marketplace identifiers (publisher.id), each with an optional @version pin. The pin is recorded and extensions.autoUpdate is disabled so the installed version doesn't drift; the extensions array itself takes the bare publisher.id.
  • extension_settings: — a free-form VS Code settings map, merged verbatim into the workspace settings.

Invalid entries (anything not publisher.id-shaped) are rejected at profile parse. Per-preset curated default extension sets are a planned addition (the merge layer is designed in ARD-0018 §2); today extensions: is the profile's own declarations.


Profile overlays — host-local and machine-local tweaks

For settings that shouldn't live in the shared, committed profile, boring merges up to two overlays on top of .boring/profile.yaml at load time, last wins:

  1. Repo-local overlay.boring/profile.overlay.yaml (gitignored by convention), next to profile.yaml. Per-worktree tooling commonly regenerates this file.
  2. Machine-level overlay${XDG_CONFIG_HOME:-~/.config}/boring/overlays/<profile-name>.yaml, merged after the repo overlay (so the machine wins). It lives outside the repo, so regeneration of the repo overlay can't clobber it — the right home for per-machine facts like a port that's already taken on one developer's box (DB_PORT: "5433"). (ARD-0040)

Merge order: profile.yaml → repo overlay → machine overlay. Headless boring run ignores the machine overlay, so a host-local file can't alter a scripted/CI run's resolved posture.

Common uses:

  • A teammate who needs an extra host mount (~/my-tools:/home/dev/my-tools:ro).
  • A different preset_version.python on their machine.
  • A literal env override for a value that varies per host.

Overlays carry operational fields only — they cannot weaken the trust anchor. Both overlays are filtered before merge: any security- or identity-relevant key is dropped (with a warning) and taken solely from the committed, reviewed profile.yaml (ARD-0006). Overlays may not set egress, guardrails, allowed_paths, disallowed_paths, data_sensitivity, save, restore, claude, name, preset, or profile_version, and may not introduce secret:// URIs under env (literal env overrides are fine). This is enforced in lib/profile.sh, not just convention. (ARD-0040)


Cross-references

  • Getting Started — install + first-profile walkthrough.
  • ARD-0001 — the full v1 design these fields realize.
  • ARD-0002 — why secrets are URIs into your store, not values in the profile.
  • ARD-0007profile_version, preset_version, services:, setup: — most of this page's surface area is decided here.
  • Examples — working profiles for each preset, copy-pasteable.