Skip to content

nick22985/sbx

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

31 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

sbx

Sandboxed Docker dev environments. Pick a flavor (npm, bun, rust, java, claude, ...), sbx init a project, then sbx shell (or sbx run) to drop into a container with your repo bind-mounted at the same path it lives at on the host. Single Rust binary; dynamic shell completions via clap_complete.

sbx demo: cd into a project, sbx init bun, sbx config port add 3000, sbx run, dev server up. One command to a working sandbox.

Why

Modern dev environments are full of code you didn't write and don't audit: transitive npm/pip/cargo dependencies, postinstall scripts, AI coding agents running shell commands, language servers, build plugins. Any one of them runs with your full user privileges by default, meaning access to your SSH keys, browser cookies, cloud credentials, shell history, and every other project on disk.

That's the blast radius supply-chain worms keep exploiting. The Shai-Hulud npm worm (Sept 2025) propagated through a single compromised package and exfiltrated GitHub tokens, npm tokens, and cloud credentials from anyone who installed it, turning developer machines into the spreading mechanism. It wasn't the first and won't be the last: similar credential-stealing payloads keep shipping through compromised packages and prompt-injected agents.

sbx shrinks that blast radius. Each project runs in its own container with only the files it needs: the repo, declared mounts, scoped caches. No host home directory, no SSH agent unless you opt in, no docker socket unless you opt in, no host network unless you opt in. If something inside the sandbox tries to read ~/.aws/credentials or ~/.ssh/id_ed25519, there's nothing there to read. Outbound network access can be gated through an allow-listed host-proxy, public exposure goes through a separately-managed Cloudflare tunnel, and TLS termination happens in a sidecar so per-project secrets stay per-project.

It's not a security boundary as strong as a VM, but it's a meaningful default-deny for the day-to-day "I just ran npm install" risks.

Quick start

./install.sh                    # one-time, places `sbx` on $PATH
sbx build base                  # build the base image
sbx build npm                   # …and one flavor
cd ~/code/some-project
sbx init npm                    # tag the project; writes .sbx/config.toml
sbx config port add 3000        # publish 3000 to the host
sbx run                         # spin up & run config.toml's `start` (or shell in)

That's the 90% case. Everything else (HTTPS, VPN, public URLs, multi-service sidecars, claude profiles…) is covered below.

Install

./install.sh

Then for completions, add to your shell rc:

# Bash
source <(COMPLETE=bash sbx)
# Zsh
source <(COMPLETE=zsh sbx)
# Fish
COMPLETE=fish sbx | source

sbx completions <shell> also prints a static completion script if you'd rather check one in.

Flavors live under ~/.config/sbx/flavors/<flavor>/Dockerfile. The repo ships a working set in examples/config/flavors/, base, npm, bun, rust, java, and claude. Copy the ones you want into ~/.config/sbx/flavors/, then sbx build base and sbx build <flavor> to bring them up.

tree ~/.config/sbx/flavors/ showing one directory per flavor (base, npm, bun, rust, java, claude), each containing a Dockerfile.

Project lifecycle

sbx init [-p] <flavor>  Mark cwd as <flavor> and build the image
                        -p stores the marker in $SBX_PRIVATE_DIR instead of ./.sbx
sbx                     Print top-level help
sbx shell [cmd...]      Enter the project's container (or run `cmd` in it)
sbx shell -f <flavor> [cmd...]
                        Override the flavor (ad-hoc), with optional command
sbx <flavor>            Ad-hoc transient shell of <flavor> in cwd
sbx run                 Run `start` from .sbx/config.toml in a fresh container
sbx sessions            List running sbx containers (alias: ps)
sbx stop                Stop containers, services, and network sidecars
sbx list                List available flavors

Shadowing host tools (npm, bun, cargo, …)

sbx shell -f <flavor> [cmd...] lets you run a one-shot command inside the matching flavor container from anywhere on the host, without sbx init-ing the directory first. Pair it with shell aliases to transparently route risky tools through their sandbox:

# ~/.bashrc / ~/.zshrc
alias npm='sbx shell -f npm npm'
alias npx='sbx shell -f npm npx'
alias bun='sbx shell -f bun bun'
alias bunx='sbx shell -f bun bunx'
alias cargo='sbx shell -f rust cargo'
alias rustc='sbx shell -f rust rustc'

Now npm install in any directory runs npm install inside the npm flavor's container with cwd bind-mounted at the same path. No host node_modules postinstall scripts, no host cargo build.rs running with your credentials. If the project has its own .sbx/config.toml, drop the -f and the project's flavor is used automatically (sbx shell npm install).

Flavor name vs. binary: the -f arg picks the image, the rest is the command. Useful when they differ, e.g. sbx shell -f rust cargo build (the rust flavor ships cargo, not rust). --flavor and --flavour (British spelling) are both accepted as long forms of -f.

Images

sbx build [flavor|all]    Rebuild image(s)
sbx rebuild [flavor|all]  Rebuild with --no-cache
sbx clean [flavor]        Remove cache volumes
sbx purge [flavor]        Remove caches + images (prompts)
sbx scan [fs|image]       Full trivy scan

Per-project config

All per-project state lives under sbx config (aliases: cfg, conf).

A project's config.toml is resolved from up to three locations and merged: a private copy under $SBX_PRIVATE_DIR (defaults to ~/dotfiles/env/.config/.nickInstall/install/configs/private/sbx/<path>), the git common dir's .sbx/, and the working tree's .sbx/. Scalar fields (flavor, start, name, port-offset) follow local-wins precedence; list fields (mounts, caches, ports, [[tunnel]]) are concatenated and deduped; boolean flags (ssh, docker, gui) are OR'd; map fields (hostname, public) merge with the local key overriding. sbx config <field> ... writes to the local file only.

sbx config port     [list|add N|rm N]
sbx config mount    [list|add SPEC|rm SPEC] [-g]   SPEC: host[:container][:ro]; -g targets the global config
sbx config hostname [list|add HOST PORT|rm HOST]   Map HOST.sbx.localhost via the proxy sidecar
sbx config tunnel   [list|add DIR L R|rm DIR L]    Forward TCP between host, sandbox, and remote (DIR: out/in/via/via-host)
sbx config env      [list|set K=V|unset K]         Manages ~/.config/sbx/env
sbx config start    [show|set <cmd>|clear]
sbx config service  [list|add NAME|rm NAME]        Built-ins: redis, postgres, mongo, mysql, mailpit
sbx config ssh      [on|off|status]                Mount $SSH_AUTH_SOCK on next start
sbx config docker   [on|off|status]                Forward /var/run/docker.sock into the sandbox
sbx config gui      [on|off|status]                Forward host X11 / Wayland sockets so GUI apps can render on the host

Mounts

Extra host paths can be made visible inside every sbx session (opt-in, off by default). Three layered sources, plus claude's -m flag:

  • mounts = [...] in $XDG_CONFIG_HOME/sbx/flavors/<flavor>/config.toml, bound only when that flavor is active. Good for editor configs and other host paths a single flavor needs (e.g. ~/.config/nvim for the nvim flavor).
  • mounts = [...] in $XDG_CONFIG_HOME/sbx/config.toml, global, applied to every sbx session regardless of flavor. Good for caches/tooling you always want (e.g. ~/.m2, ~/.gradle, ~/.cache/pip).
  • mounts = [...] in ./.sbx/config.toml, per-project, layered on top of the global file.

Entry syntax (missing host paths are silently skipped):

"host"                     # same path on both sides
"host:container"           # explicit container path
"host:container:ro"        # read-only
"host::ro"                 # same-path bind, read-only

~/ on the host side expands to your host $HOME; ~/ on the container side expands to the flavor's container home (e.g. /home/dev, or ~/ for sbx claude which mirrors the host home).

Mounting on top of a named volume. Some flavors (e.g. java) bind a named docker volume over a container path for cache reuse. To inject your own config without losing that cache, mount the single file on top of the volume:

~/.m2/settings.xml:~/.m2/settings.xml:ro

The container still gets the cache volume at ~/.m2/, but Maven now picks up your host settings.xml (auth, mirrors, etc.).

Caches

Flavor authors declare caches in caches = [...] inside ~/.config/sbx/flavors/<flavor>/config.toml, alongside the Dockerfile. Each entry names a host path or named docker volume that survives between runs (e.g. ~/.npm, ~/.cargo, @sbx-maven-cache:/home/dev/.m2). Two extra layers let you add to or override those without editing the flavor's config:

  • caches = [...] in $XDG_CONFIG_HOME/sbx/config.toml, global, applied to every sbx session regardless of flavor. Good for caches you always want shared with the host (e.g. .cargo/registry, .npm).
  • caches = [...] in ./.sbx/config.toml, per-project, layered on top of the global file.

All three layers use the same per-entry syntax:

.cache/pip                            # host bind: ~/.cache/pip -> /home/dev/.cache/pip
.m2:/home/dev/.m2                     # host bind, explicit container path
@sbx-maven-mine:/home/dev/.m2         # named docker volume

Override semantics: entries are merged by container path, with the project config winning over the global config winning over the flavor config. So if the java flavor ships @sbx-maven-cache:/home/dev/.m2 and you'd rather use your host's ~/.m2, add ".m2:/home/dev/.m2" to caches in ~/.config/sbx/config.toml (or just the project's .sbx/config.toml for one project) and the host bind replaces the named volume. Missing host paths are auto-created on first run.

User-defined volumes are user-owned: sbx clean / sbx purge only remove volumes declared in the flavor's own caches list, so renaming the active volume via an override won't trigger surprise deletions of your data.

GUI forwarding (opt-in)

sbx config gui on sets gui = true in ./.sbx/config.toml; the next container start mounts the host's Wayland and X11 sockets and forwards DISPLAY, WAYLAND_DISPLAY, and XDG_RUNTIME_DIR so GUI apps inside the sandbox render on the host (Electron apps, browsers, IDEs launched from a flavor shell, etc.).

sbx config gui on        # sets gui = true in ./.sbx/config.toml
sbx config gui status    # shows whether forwarding is on + detected host sockets
sbx config gui off       # sets gui = false

sbx config gui status prints whether forwarding is enabled and which of WAYLAND_DISPLAY / DISPLAY were detected on the host, useful when an app silently fails to open a window.

Networking

sbx network architecture: one shared netns (vpn / tailscale / first service / tunnel sidecar / sbx-container, first present owns it); a separate sbx-proxy-net bridge hosting sbx-proxy (Traefik) and sbx-public (cloudflared); sbx-host-proxy reached via host.docker.internal.
sbx net vpn       [status|use SPEC|auth|inline|off]
sbx net tailscale [on [name]|off|status|auth [name]|list|rm name]
sbx proxy         [status|routes|logs [-f]|stop]
sbx tunnel        [status|logs [-f]|stop]
sbx public        [list|add HOST PORT|rm HOST|login|status|logs [-f]|stop]
sbx host-proxy    [on|off|status|list|allow HOST|disallow HOST|reload|logs [-f]|stop]

sbx proxy controls the shared Traefik sidecar that publishes *.sbx.localhost routes from sbx config hostname and from any container labels. The Traefik dashboard is at http://traefik.sbx.localhost/dashboard/ whenever the sidecar is up.

Exposing a project (four flavors)

Scope Setup URL
Local plain HTTP sbx config hostname add app.sbx.localhost 8080 http://app.sbx.localhost/
Local HTTPS (mkcert) sbx proxy mkcert (once, needs host mkcert), then sbx config hostname add app.sbx.localhost 8080 https://app.sbx.localhost/
Local HTTPS (Let's Encrypt + Cloudflare DNS-01) sbx config hostname add app.local.example.com 8080, plus CLOUDFLARE_DNS_API_TOKEN and SBX_ACME_EMAIL in ~/.config/sbx/env https://app.local.example.com/
Public (Cloudflare Tunnel) sbx public login (once), then sbx public add app.example.com 8080 https://app.example.com/

All four share the same internal Traefik proxy on sbx-proxy-net. You can mix them in one project, e.g. app.sbx.localhost for fast local dev and app.example.com for a public preview link to share with a teammate.

How a request reaches the sandbox, by scope: (1) HTTP: browser to 127.0.0.1:80, Traefik routes to sandbox; (2) HTTPS: browser to 127.0.0.1:443, Traefik terminates TLS (mkcert or Let's Encrypt) and forwards plain HTTP to sandbox; (3) Public: browser to Cloudflare edge, cloudflared sidecar dials out over QUIC, then HTTP through Traefik to sandbox.

VPN/Tailscale settings are stored per-project under [network] in .sbx/config.toml and applied on the next sbx shell start. Tailscale supports multiple named profiles - each maps to its own SBX_TAILSCALE_AUTHKEY[_<NAME>] env var.

Tunnels

sbx config tunnel forwards raw TCP between the host, the sandbox, and remote services reachable via Tailscale/VPN. Four directions, written as [[tunnel]] tables in .sbx/config.toml:

[[tunnel]]                            # sandbox :3000 -> host 127.0.0.1:3000
dir = "out"
left = 3000
right = 3000

[[tunnel]]                            # host :5432 -> sandbox localhost:5432
dir = "in"
left = 5432
right = 5432

[[tunnel]]                            # host :5432 -> remote :5432 through the sandbox netns
dir = "via"
left = 5432
right = "db.staging.tail-net.ts.net:5432"

[[tunnel]]                            # sandbox -> host.docker.internal:27017 -> remote (uses host's netns)
dir = "via-host"
left = 27017
right = "192.168.1.67:27017"

via: is most useful with Tailscale/VPN on: the sandbox netns has tailnet routes and MagicDNS, so host tools (TablePlus, psql, etc.) can reach tailnet-only services without running Tailscale themselves. in: and via: spin up a small alpine/socat sidecar joined to the session's netns; out: is published via -p on the netns owner.

via-host: is the inverse: the sandbox needs to reach a destination the host can route to but the sandbox's own netns can't, most commonly a LAN/RFC1918 service when VPN is on (which has stolen the sandbox's default route). A separate sbx-via-host-<project> sidecar runs on --network host (so it has the host's full LAN/VPN routing) and listens on the docker bridge gateway at LEFT, forwarding to RIGHT. The sandbox connects to host.docker.internal:LEFT; this traffic exits the VPN netns via the bridge interface because gluetun's FIREWALL_OUTBOUND_SUBNETS already exempts the docker bridge subnet. The listener binds to the bridge IP specifically, so LAN-side machines can't reach the forward.

sbx tunnel status shows the configured tunnels and the state of the per-project socat sidecars; sbx tunnel logs [-f] tails them; sbx tunnel stop tears them down.

Host proxy (HTTPS pass-through via host)

sbx host-proxy lets the sandbox reuse the host's outbound network for HTTPS, typically to reach a service that is only routable via the host's Tailscale/VPN when you can't (or don't want to) run Tailscale inside the container. A shared sbx-host-proxy sidecar runs tinyproxy on the host network and the sandbox is given https_proxy=http://host.docker.internal:8118 so any tool that honours https_proxy (curl, maven, npm, pip, go, …) goes through it. TLS is end-to-end, tinyproxy uses HTTP CONNECT, never terminates the TLS.

sbx host-proxy on                                  # sets [host_proxy] enabled = true
sbx host-proxy allow repo.internal.example.com     # add a host to the allowlist
sbx host-proxy allow '*.maven.org'                 # wildcard (subdomains)
sbx host-proxy list                                # show this project's allowlist
sbx host-proxy status                              # marker + sidecar + merged allowlist
sbx run                                            # sidecar auto-starts on first session

The [host_proxy] table in .sbx/config.toml carries enabled (the on/off marker) and allow (the per-project allowlist). An empty allow with enabled = true means "unrestricted for this project". A non-empty list restricts proxied traffic to only those hosts (matched by a tinyproxy Filter with FilterDefaultDeny Yes). Wildcards: foo.com matches foo.com exactly; *.foo.com matches any subdomain.

Changes to the allowlist hot-reload, sbx host-proxy allow|disallow rewrites the filter file and sends SIGHUP to the running sidecar. No container restart, no session disruption.

When to use it instead of via: tunnels: via: is best for raw TCP to a single known host:port; host-proxy is best when the sandbox already talks HTTPS by URL (e.g. Maven repos behind a private Nexus) and you'd rather not enumerate every endpoint.

Shared-sidecar caveat. A single sbx-host-proxy container serves every project, so the active allowlist is the union of every active project's entries. If project A allows only repo.example.com and project B's allow is empty (meaning "unrestricted for B"), the sidecar is still restricted to [repo.example.com] because A demanded restrictions, B effectively inherits A's allowlist for the duration. To run a truly unrestricted host-proxy, none of the active projects can have a non-empty allowlist. The same goes the other way: adding an entry anywhere strictens the proxy for everyone.

Tinyproxy listens on 0.0.0.0:8118 of the host network and only accepts RFC1918 + loopback clients (Allow rules in the generated config). TLS stays end-to-end (CONNECT tunnel, the proxy never terminates TLS). The sidecar is reference-counted: it auto-starts when a session with the marker spins up, and stop_sidecar_if_idle tears it down once no sandbox container still has https_proxy set in its env.

Public URLs (Cloudflare Tunnel)

sbx public exposes a project on the public internet through a Cloudflare Tunnel, no inbound ports, no DNS records to manage by hand. One-time setup:

sbx public login                            # browser flow; writes ~/.config/sbx/cloudflared/cert.pem
sbx public add app.example.com 8080         # in your project dir, adds to [public] in .sbx/config.toml
sbx run

First start creates a single shared sbx-public Cloudflare tunnel, registers the CNAME (HOST → sbx-public.cfargotunnel.com), spins up a global sbx-public cloudflared sidecar on the proxy network, and routes traffic through the existing Traefik proxy, so sbx config hostname and sbx public share the same internal HTTP plane (CF terminates TLS at the edge). Multiple projects can register their own hostnames; the sidecar's config.yml is merged from per-project fragments.

sbx public status shows sidecar / login / tunnel state and merged hostnames across all active sessions. sbx public logs [-f] tails cloudflared; sbx public stop force-stops it. Hostnames added under [public] in ./.sbx/config.toml are registered on the next sbx run and unregistered on session exit.

The cert.pem produced by sbx public login is the Cloudflare API credential (account-scoped); the per-tunnel credentials.json next to it is what cloudflared actually uses at runtime. CF's dashboard makes you pick a zone during login, but the resulting cert works for every zone in your account.

sbx claude

sbx claude is just an agent like the rest, sharing the exact same CLI surface. Its [agent] config opts into two extra features: --dangerously-skip-permissions autonomy and remote-control = true.

sbx claude [-m PATH]... [-p PROFILE] [-s] [--rc] [--docker] [args...]
sbx claude shell                  Drop to bash inside the sandbox
sbx claude build|rebuild          Build/rebuild the claude image
sbx claude profile [list|add NAME|rm NAME|current]

Flags (-m, -p, -s, --rc, --docker) come before the subcommand: sbx claude -m ~/projects/foo -p work shell.

sbx claude is independent of the project's flavor, you can launch it on an npm/bun/rust/uninitialised project. It bind-mounts cwd at the same path it lives at on the host and the host's ~/.claude rw (so auth, config, and history are shared). The image bundles node + bun + rust + python and the Claude Code CLI.

Because the container is already a sandbox, sbx claude auto-passes --dangerously-skip-permissions to claude so prompts don't get in the way. Pass -s / --safe to opt out for a single invocation, or pass --dangerously-skip-permissions yourself and it won't be duplicated.

In addition to the global / per-project mount files, sbx claude accepts -m / --mount <SPEC> repeated per invocation for ad-hoc mounts: sbx claude -m ~/projects/foo -m ~/.m2/settings.xml:~/.m2/settings.xml:ro.

sbx claude can opt a session into Remote Control so it's reachable from claude.ai/code and the Claude mobile app. When enabled, sbx appends --remote-control "<project>-<pid>" to the inner claude invocation. It's off by default, opt in with --rc for a single run, set SBX_REMOTE_CONTROL=1 in ~/.config/sbx/env to default-enable it persistently, or pass your own --remote-control / --rc flag (sbx won't double up).

Profiles

sbx claude profile add work creates an isolated ~/.claude clone under $XDG_CONFIG_HOME/sbx/claude-profiles/work/ seeded from your host .claude.json. Use it with sbx claude -p work, or pin a project to a profile by setting [claude] profile = "..." in ./.sbx/config.toml. Useful for separating personal/work logins or for keeping different MCP setups apart.

Docker socket forwarding (opt-in)

sbx config docker on sets docker = true in ./.sbx/config.toml; every container start for that project then bind-mounts /var/run/docker.sock from the host and --group-adds the host docker GID so the unprivileged in-container user can talk to it. The base image ships the docker client binary.

sbx claude intentionally does not follow the project docker flag, opt in per-session with --docker, or globally with SBX_DOCKER=1 in ~/.config/sbx/env.

Security: mounting the docker socket is effectively root on the host - anything inside the container can docker run --privileged -v /:/host ... and escape the sandbox. Only enable this when you trust what's running inside.

Agents (sbx opencode / sbx copilot / your own)

An agent is any flavor whose config.toml declares an [agent] block. Such a flavor launches its CLI directly, with no per-agent Rust code, just a flavor directory:

sbx opencode [args...]                # opencode
sbx copilot  [args...]                # GitHub Copilot CLI
sbx <agent>  [-m PATH]... [-p PROFILE] [-s|--safe] [--docker] [args...]
sbx <agent>  shell | bash             # drop to bash inside the sandbox
sbx <agent>  build | rebuild          # build/rebuild the agent image
sbx <agent>  profile [list|add NAME|rm NAME|current]

sbx's own flags (-m/--mount, -p/--profile, -s/--safe, --docker, --shell) come before any passthrough args, which are forwarded verbatim to the inner CLI. The reserved verbs above (shell/bash, build, rebuild, profile) are claimed by sbx when they appear first, so they shadow any same-named subcommand of the inner CLI. Agents are independent of the project's flavor and bind-mount cwd at its host path. sbx claude is one of these agents (it just enables a couple of extra features in its config, see above).

Defining an agent

Add [agent] to a flavor's config.toml:

[agent]
bin = "opencode"        # binary to exec; defaults to the flavor name
persist = [".config/opencode", ".local/share/opencode"]  # host dirs bind-mounted for auth/config
autonomy = ["--allow-all"]            # flags injected unless --safe / already present
autonomy-detect = ["--yolo"]          # extra flags that count as "already autonomous"
forward-env = ["GH_TOKEN"]            # host env vars forwarded when set
profiles = true                       # enable named logins (sbx <agent> profile ...)

Pair it with a Dockerfile that installs the CLI and sbx <name> just works.

Autonomy

Since the container is already a sandbox, agents auto-inject their autonomy flags so prompts don't get in the way. sbx copilot injects --allow-all; use --safe to opt out for one invocation, or pass your own --allow-all / --yolo and it won't be duplicated. opencode declares no autonomy flag; it's autonomous by default and its prompting is configured via the permission key in opencode.json (shared from the host through the mounted ~/.config/opencode).

Auth and config

The persist dirs are bind-mounted from the host, so logins are shared with the host install:

  • opencode persists ~/.config/opencode and ~/.local/share/opencode (provider creds in auth.json, sessions). Log in once on the host with opencode auth login, or do it inside the sandbox (sbx opencode auth login); it persists either way.
  • copilot persists ~/.copilot (auth + state) and forwards COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN from the host env when set, so token auth works without an interactive /login.

Agents honor the global / per-project mount files plus ad-hoc -m / --mount <SPEC>, and the opt-in docker socket via --docker / SBX_DOCKER=1 (same caveats as sbx claude).

Profiles

Agents with profiles = true support multiple named logins, so you can keep e.g. a work and a personal account side by side:

sbx opencode profile add work        # create an empty profile
sbx opencode profile list            # list profiles (* marks the active one)
sbx opencode profile current         # print the active profile
sbx opencode profile rm work         # delete a profile
sbx opencode -p work [args...]       # run using that profile

Each profile lives at $XDG_CONFIG_HOME/sbx/<agent>-profiles/<name>/ and holds its own copy of the agent's persist dirs. When a profile is active those dirs bind from the profile instead of your host home, so logins are fully isolated. A freshly added profile starts logged out, so sign in once inside it (sbx opencode -p work auth login) and it persists there. With no profile selected, the agent binds from your host home and shares the host login.

Pin a project to a profile with a [profiles] entry in ./.sbx/config.toml:

[profiles]
opencode = "work"
copilot = "personal"

A -p NAME flag overrides the pin for one invocation. (sbx claude has the same feature via its own [claude] profile key, see above.)

Files

Three TOML scopes, one schema per scope, layered project > global > flavor:

  • ./.sbx/config.toml - per-project: every project knob (flavor, ports, mounts, caches, ssh, docker, gui, start, [network], [hostname], [public], [[tunnel]], [services], [host_proxy], [claude], [profiles], plus name / port-offset worktree overrides).
  • $XDG_CONFIG_HOME/sbx/config.toml - global: mounts and caches applied to every sbx session.
  • $XDG_CONFIG_HOME/sbx/flavors/<flavor>/config.toml - flavor: mounts and caches shipped with the flavor's Dockerfile.

Plus:

  • ./.sbx/Dockerfile - optional, layers on top of the flavor image.
  • $XDG_CONFIG_HOME/sbx/flavors/<flavor>/Dockerfile - base image source per flavor.
  • $XDG_CONFIG_HOME/sbx/env - persistent env (KEY=value, chmod 600).
  • $XDG_CONFIG_HOME/sbx/claude-profiles/<n>/ - alternate ~/.claude per profile.
  • $XDG_CONFIG_HOME/sbx/<agent>-profiles/<n>/ - isolated persist dirs per agent profile.

See examples/sbx/ for an annotated project config.toml and examples/config/ for the global + per-flavor layout.

Coming from an older sbx layout with separate files for flavor, hostname, ports, mounts, caches, etc., or with flavors at the top level of ~/.config/sbx/? Run sbx migrate once; it folds project files into ./.sbx/config.toml, global ~/.config/sbx/{mounts,caches} into ~/.config/sbx/config.toml, relocates each top-level flavor dir into ~/.config/sbx/flavors/<flavor>/, and consolidates each flavor's mounts and caches into a per-flavor config.toml. Project legacy originals land in ./.sbx/legacy/ and global ones in ~/.config/sbx/legacy/.

In a git worktree, .sbx/config.toml is looked up in the worktree first, then the shared bare/primary repo, then the private overlay ($SBX_PRIVATE_DIR/<rel-path>/.sbx/).

Worktrees

When sbx runs inside a linked git worktree it auto-derives a suffix from the checked-out branch and applies it everywhere that needs to be unique across worktrees:

  • project_name becomes <repo>-<branch>, distinct containers, proxy routes, sidecars, etc., so two worktrees of the same repo can run side by side.
  • Hostnames under [hostname] and [public] in .sbx/config.toml are auto-prefixed with <branch>-. So a single shared "app.sbx.localhost" = 3000 under [hostname] yields https://app.sbx.localhost/ in the main checkout and https://server-live-app.sbx.localhost/ in a worktree on branch server-live. The prefix is flat (dash, not dot) so the URL stays at the same DNS depth as the original, existing wildcard certs (*.sbx.localhost, *.example.com) keep working.
  • Published ports get a stable hash-derived offset in [1, 9] so two worktrees of the same repo don't collide on the host (master / main / non-worktree always use 0). Pin a specific offset by setting port-offset = N in .sbx/config.toml.

The branch name is sanitized (feature/foo -> feature-foo). To use something other than the branch name, set name = "exp1" in the worktree's .sbx/config.toml; its value replaces the suffix (so name = "exp1" -> exp1-app.sbx.localhost and project_name <repo>-exp1). Project images (sbx-<flavor>-<repo>) are unaffected, they're shared across worktrees.

Environment

Var Meaning
SBX_PORTS=3000,8080 Extra ports to publish
CLOUDFLARE_DNS_API_TOKEN=… CF API token used by Traefik for ACME DNS-01 (real-domain local HTTPS)
SBX_ACME_EMAIL=… Contact email for Let's Encrypt registration. Required with CLOUDFLARE_DNS_API_TOKEN
SOCKET_CLI_API_TOKEN=… socket.dev API token (forwarded into containers that ship socket)
SOCKET_ORG_SLUG=… Default org for socket scan create
SBX_VPN_DIR=… Directory for bare VPN names
SBX_PRIVATE_DIR=… Read-only overlay for .sbx configs; also where sbx init -p writes
SBX_PROJECT_DIR=… Override the detected project root (set inside the sandbox; usually auto)
SBX_PROJECT Set inside sandboxes, full project name (e.g. myapp-master)
SBX_PROJECT_BASE Set inside sandboxes, repo base name without worktree suffix (e.g. myapp)
SBX_WORKTREE Set inside sandboxes, worktree suffix, empty in main checkout (e.g. master)
SBX_HOSTNAME / SBX_HOSTNAMES Set inside sandboxes, primary / all hostnames (public preferred over local, useful for OAuth/SAML callbacks)
SBX_LOCAL_HOSTNAME / SBX_LOCAL_HOSTNAMES Set inside sandboxes, first / all local hostnames from [hostname] (already prefixed)
SBX_PUBLIC_HOSTNAME / SBX_PUBLIC_HOSTNAMES Set inside sandboxes, first / all public hostnames from [public]
SBX_PORT Set inside sandboxes, primary published port (first entry in ports)
SBX_DOCKER=1 Default sbx claude to mount the host docker socket
SBX_REMOTE_CONTROL=1 Default sbx claude to enable --remote-control (off by default)
SBX_TAILSCALE_AUTHKEY[_<NAME>]=… Auth key for the default / named tailscale profile
SBX_TAILSCALE_EXTRA_ARGS=… Extra args appended to tailscale up
SBX_BUILDX_BUILDER=default Buildx builder to use for sbx's own builds (default: default; set empty to inherit docker buildx use)

Persist these in ~/.config/sbx/env (KEY=value lines, chmod 600). Host env wins over the file.

Anything you put in ~/.config/sbx/env is also forwarded into every sbx container. So sbx config env set MY_API_KEY=… (or editing the file directly) is enough to make MY_API_KEY available to your app inside the sandbox, no allowlist or prefix required. The sbx-internal vars above (CLOUDFLARE_DNS_API_TOKEN, SBX_TAILSCALE_AUTHKEY*, etc.) are forwarded too, treat the file as the single source of truth for "env I want in my sandboxes" and keep host-only secrets out of it.

Packages

 
 
 

Contributors