Skip to content

Sandboxing

How filesystem, process, and network isolation works in devsandbox.

devsandbox uses bubblewrap on Linux or Docker containers on macOS to create isolated environments for running untrusted code. On Linux, bwrap and pasta binaries are embedded - no system packages required. To use system-installed binaries instead, see configuration. Proxy mode is the one exception: its egress lockdown needs iproute2 and nft/iptables on the host, and a --proxy launch aborts without them (see proxy mode).

macOS users: devsandbox uses the Docker backend on macOS. Skip to How It Works (Docker) for platform-relevant details, or see Platform Differences for a comparison table.

Isolation Backends

devsandbox supports three isolation backends:

Backend Platform Description
bwrap Linux only Uses bubblewrap for namespace-based isolation. Preferred on Linux.
docker Linux, macOS Uses Docker containers. Required for macOS, optional on Linux.
krun Linux, macOS Experimental. Runs the sandbox image inside a libkrun microVM (podman --runtime krun) for hardware-level isolation. Opt-in only. See krun microVM backend.

bwrap and docker share the host kernel; a kernel-level exploit escapes both. krun puts the workload behind a hardware virtualization boundary with its own guest kernel - the right choice for genuinely untrusted code.

Automatic Selection

By default (--isolation=auto), devsandbox selects the best backend for your platform: - Linux: Uses bwrap (bubblewrap) - macOS: Uses docker

krun is never auto-selected; it must be requested explicitly.

Manual Override

Force a specific backend with the --isolation flag:

# Use Docker on Linux
devsandbox --isolation=docker

# Explicitly use bwrap (Linux only)
devsandbox --isolation=bwrap

# Use the krun microVM backend (requires podman + libkrun + /dev/kvm)
devsandbox --isolation=krun

Or configure in ~/.config/devsandbox/config.toml:

[sandbox]
isolation = "docker"  # "auto", "bwrap", "docker", or "krun"

Choosing a Backend (Linux)

Choose bwrap when Choose Docker when
You want sub-second startup SELinux/AppArmor blocks namespace operations
You prefer minimal dependencies You need Dockerfile customization
You want native filesystem performance You want consistency with macOS teammates
You're on a standard kernel with user namespaces You prefer container-based isolation

Security Model

Resource Access
Project directory Read/Write
.env files Hidden (overlaid with /dev/null), within the scan scope below
~/.ssh Not mounted (configurable)
~/.gitconfig Sanitized copy (configurable)
~/.aws, ~/.azure, ~/.gcloud Not mounted
mise-managed tools Read-only or overlay
Custom mount rules User-configurable (see below)
Network (default) Full access
Network (proxy mode) Isolated, routed through MITM proxy; enforced deny-by-default on bwrap and krun, advisory on Docker (see per-backend behavior)

What's Not Available (by default)

SSH Keys - The ~/.ssh directory is not mounted by default:

  • SSH authentication requires git.mode = "readwrite" (see configuration)
  • Use HTTPS for git operations in default mode
  • SSH agent forwarding is available in readwrite mode

Cloud Credentials - These directories are not mounted:

  • ~/.aws (AWS CLI credentials)
  • ~/.azure (Azure CLI credentials)
  • ~/.gcloud (Google Cloud SDK credentials)
  • ~/.config/gcloud (Google Cloud config)

Environment Files - Files matching .env and .env.* patterns (e.g., .env, .env.local, .env.production) in your project are overlaid with /dev/null, preventing secrets from being read by sandboxed code. Files like config.env that don't start with .env are not hidden. The scan descends up to 3 directory levels below the project root and skips node_modules, .git, vendor, and .venv, so matching files deeper than that or inside a skipped directory stay readable.

Git Credentials - By default, ~/.gitconfig is replaced with a sanitized copy containing only user.name and email. Use git.mode = "readwrite" for full git access.

What's Available

Project Directory - Full read/write access to the current directory and all subdirectories (except .env files).

Development Tools - All tools managed by mise are available read-only:

  • Node.js, Python, Go, Rust, etc.
  • Package managers (npm, pip, cargo)
  • Build tools

Network - By default, full network access is allowed. Use proxy mode to isolate and inspect traffic.

How It Works (bwrap)

The following sections describe how the bwrap (bubblewrap) backend implements isolation on Linux. For Docker-specific behavior, see Docker Backend below.

Filesystem Isolation

Bubblewrap creates a new mount namespace with selective bind mounts:

/ (new root)
├── /usr, /lib, /lib64, /bin  → Host system (read-only)
├── /etc                       → Host /etc (read-only, some files masked)
├── /tmp                       → Fresh tmpfs
├── /home/user                 → Sandbox home (~/.local/share/devsandbox/<project>/home)
├── /home/user/.config/mise   → Host mise config (read-only)
└── /path/to/project          → Host project directory (read/write)

PID Isolation

Processes inside the sandbox:

  • Cannot see processes running outside
  • Cannot send signals to external processes
  • Have their own PID namespace (init is PID 1)

User Namespace

devsandbox uses unprivileged user namespaces:

  • No root privileges required
  • UID/GID mapping preserves file ownership
  • Works on most modern Linux distributions

Resource Limits

bwrap itself has no cgroup controls, so memory, CPU and process caps are applied by running the sandbox inside a systemd transient scope. When [sandbox.resources] is configured, the launch becomes:

systemd-run --user --scope --quiet --collect \
  --unit=devsandbox-<pid>-<rand> --description="devsandbox sandbox" \
  -p MemoryMax=4G -p CPUQuota=200% -p TasksMax=2048 \
  - bwrap [args] - <command>

--description is load-bearing: without it systemd derives Description= from the full command line, writing every host path bound into the sandbox to the journal and to systemctl --user list-units. --unit names the scope predictably, with a random suffix so a scope leaked by an earlier run plus PID reuse cannot collide. The preflight probe described below creates a second, short-lived unit (...-probe) that you will see if you are watching systemctl --user.

MemoryMax= bounds memory.max only; no MemorySwapMax= accompanies it, so memory.swap.max stays at max and swap is not bounded on bwrap. A process that outgrows the cap is reclaimed into swap and throttled there on a host with swap, rather than OOM-killed the instant its RSS crosses the line, and it can keep growing in swap past the configured number. On a host with no swap the cap is a hard ceiling and the process is OOM-killed at it.

This is weaker than the container backends, and knowing so matters on a swap-enabled host: docker and krun pass --memory with --memory-swap unset, which the engine expands to a combined memory+swap ceiling of twice the value - memory = "4g" there means 4g resident plus at most 4g of swap, after which the workload is killed. Under bwrap the same config throttles a runaway allocator into swap instead of killing it.

Adding MemorySwapMax=0 was considered and rejected. It would make the configured number a hard ceiling, but it cannot be verified: memory.swap.max exists only with kernel swap accounting, and without it systemd logs a warning and starts the scope regardless, which no preflight check can observe. Asserting a guarantee the host may not be enforcing is worse than documenting the weaker one bwrap actually delivers.

systemd-run execs in place, so the scope adds no process of its own. devsandbox does stay alive as the sandbox's parent, and deliberately: nothing host-side can notice that a sandbox was OOM-killed once devsandbox has replaced itself with bwrap. It stays out of the way of the exit - it returns as soon as the sandbox does, propagates its exit status, and for a sandbox that was terminated raises the same signal on itself so it dies the same way, escalating to SIGKILL if that signal turns out to be blocked. Dying of the signal is what a status code cannot express: a shell only aborts a while loop on a child that actually died of SIGINT. SIGINT, SIGTERM, SIGHUP and SIGQUIT aimed at devsandbox alone are forwarded on to the sandbox, since the terminal delivers them to the whole process group anyway. In proxy mode the scope wraps pasta, the outermost sandbox process - devsandbox itself stays outside it, so the proxy is never capped by the sandbox's memory limit.

Limits are opt-in and there are no bwrap defaults. With no [sandbox.resources] block the sandbox launches byte-for-byte as it always has, and nothing about systemd is required. The deprecated [sandbox.docker.resources] block does not opt bwrap in - it stays scoped to the container backends, precisely so a config written for docker cannot turn into a failed bwrap launch on a host without the requirements below. See Configuration for the config surface.

Requirements, all checked before the sandbox starts:

  • a cgroup v2 unified hierarchy (/sys/fs/cgroup/cgroup.controllers present)
  • systemd-run on PATH
  • a running systemd user manager (systemctl --user is-system-running)
  • the controllers the limits need, delegated to user@<uid>.service: memory for memory, cpu for cpus, pids for pids

Fail fast, never silently unlimited. A configured limit that cannot be enforced aborts the run with an error naming exactly what is missing. devsandbox never falls back to running without the limit, because that would leave you believing the sandbox is capped when it is not. The three ways enforcement can silently fail are each guarded:

  • Controller not delegated. systemd accepts CPUQuota= in a user scope and ignores it with only a journal warning when cpu is not delegated. Preflight rejects it instead, naming the controller. Delegation is granted with a drop-in on user@.service setting Delegate=cpu cpuset io memory pids.
  • Value rounds to nothing. cpus = "0.004" would become CPUQuota=0%, a sandbox that never schedules. It is rejected during translation. A zero memory limit (memory = "0", "0m", "0g") is rejected the same way on this path: MemoryMax=0 would grant the scope no memory at all. The config layer still accepts it, because it is docker's documented spelling of unlimited.
  • systemd refuses the scope. A property the user manager rejects at D-Bus time makes systemd-run exit 1, which is indistinguishable from the sandboxed command exiting 1. Preflight therefore creates a throwaway probe scope carrying the same properties first, so a refusal surfaces as a specific error before anything launches. When the refusal happens in a session outside user@<uid>.service - the usual case on SSH and bare-TTY logins, where the common cgroup ancestor is the root-owned user-<uid>.slice and the user manager cannot migrate the process into the scope - the error says so, rather than passing systemd's bare "Permission denied" through.

Hosts without a systemd user manager, or on cgroup v1, cannot use bwrap limits at all. They keep working normally as long as no limits are configured.

Detecting the OOM kill

A limit that is doing its job eventually kills something, and the kernel does that silently. The scope makes it observable: while the session runs, devsandbox watches the scope's memory.events for oom_kill and oom_group_kill, and reports each increase to stderr, to a sandbox.oom audit event, and to the sandbox's metadata where devsandbox sandboxes list shows it. See Configuration: OOM Tracking for what is reported.

Three details make it work rather than nearly work:

  • The watch is driven by inotify, not polling. The kernel notifies from the OOM path itself, while the victim is still dying. The cgroup - and with it memory.events - is removed a few milliseconds after the last process in it exits, so a poll interval cheap enough to leave running would lose that race on exactly the case that matters most, a sandbox killed outright.
  • The cgroup must be ours. The scope is matched by unit name, not by position in the hierarchy, because systemd chooses the slice a user scope lands in. Only the scope this launch asked for is trusted; a sandbox with no limits is left alone rather than credited with the OOM kills of everything else sharing the invoking shell's cgroup.
  • Monitoring never delays the sandbox, at either end. The watch attaches in the background, because waiting for systemd to publish the scope would otherwise sit in front of the launch, and a process stays a member of the cgroup it was launched in until it is reaped - so a launch that never reaches its scope would keep that wait going right through the sandbox's exit. When the sandbox ends, an attach still in flight is abandoned rather than waited out.

Where the counters cannot be read, devsandbox says so instead of leaving you to assume a sandbox is monitored. The exception is the states where there was never anything to watch: a launch with no limits, and a sandbox that exited before its scope resolved.

Data Locations

Sandbox data follows XDG conventions:

~/.local/share/devsandbox/<project-hash>/
├── home/                    # Sandbox $HOME (isolated per-project)
│   ├── .config/             # XDG_CONFIG_HOME
│   ├── .local/share/        # XDG_DATA_HOME
│   ├── .cache/              # XDG_CACHE_HOME
│   │   ├── go-build/        # Go build cache
│   │   └── go-mod/          # Go module cache
│   └── go/                  # GOPATH
├── .ca/                     # Proxy CA (if proxy mode used)
│   ├── ca.crt
│   └── ca.key
└── logs/
    ├── proxy/               # HTTP request logs
    └── internal/            # Internal error logs

macOS (Docker backend): The sandbox home directory is stored as a named Docker volume rather than at the host path shown above. Use docker volume ls | grep devsandbox to see volumes, or devsandbox sandboxes list to view all sandboxes with their storage type.

Project Naming

Sandbox directories are named <project-basename>-<hash> where the hash is derived from the full project path. This ensures:

  • Unique sandboxes for projects with the same name in different locations
  • Predictable directory names for the same project

Environment Variables

Inside the sandbox, these environment variables are set:

Variable Value Description
DEVSANDBOX 1 Indicates running inside sandbox
DEVSANDBOX_PROJECT Project name Base name of project directory
DEVSANDBOX_PROXY 1 Set only in proxy mode
GOTOOLCHAIN local Prevents Go from downloading toolchains
HOME Sandbox home Points to isolated home directory
XDG_CONFIG_HOME $HOME/.config XDG config directory
XDG_DATA_HOME $HOME/.local/share XDG data directory
XDG_CACHE_HOME $HOME/.cache XDG cache directory

Managing Sandboxes

Listing Sandboxes

# List all sandboxes
devsandbox sandboxes list

# Skip disk usage calculation (faster)
devsandbox sandboxes list --no-size

# JSON output for scripting
devsandbox sandboxes list --json

# Sort by last used
devsandbox sandboxes list --sort used

Pruning Sandboxes

# Remove orphaned sandboxes (project directory no longer exists)
devsandbox sandboxes prune

# Keep only 5 most recently used
devsandbox sandboxes prune --keep 5

# Remove sandboxes unused for 30 days
devsandbox sandboxes prune --older-than 30d

# Remove all sandboxes
devsandbox sandboxes prune --all

# Preview what would be removed
devsandbox sandboxes prune --dry-run

Port Forwarding

Runtime Port Forwarding

Instead of pre-configuring static port rules, you can forward ports to a running sandbox on demand.

Auto-detect listening ports (opt-in, disabled by default for security):

[port_forwarding]
# Automatically detect and forward listening ports inside the sandbox
auto_detect = false

# How often to scan for new listening ports
scan_interval = "2s"

# Ports to never auto-forward (e.g., internal services)
exclude_ports = [22, 80, 443]

When auto_detect = true, devsandbox monitors the sandbox for new TCP listeners and automatically forwards them to the same port on 127.0.0.1. Auto-detect requires proxy mode: without an isolated network namespace the sandbox and host share the same kernel port space, so a userland forwarder would collide with the sandbox listener on the same port (and the port is already directly accessible on 127.0.0.1 anyway). If auto-detect is enabled without proxy mode, devsandbox logs a notice and skips auto-forward at session start. When the preferred host port is already in use, devsandbox falls back to an ephemeral host port chosen by the OS and logs the mapping; the actual host port is recorded in the session registry and visible via devsandbox sessions. Ports below 1024 are always excluded.

Manual forwarding to a running sandbox:

# Forward a single port (host:3000 → sandbox:3000)
devsandbox forward 3000

# Forward sandbox port 3000 to host port 8080
devsandbox forward 3000:8080

# Target a specific sandbox by name
devsandbox forward --name myapp 3000

# Bind to all interfaces (for LAN access)
devsandbox forward --bind 0.0.0.0 3000

# Forward multiple ports at once
devsandbox forward 3000 5173 9090

Port spec format: <sandbox_port>[:<host_port>]. The sandbox port (the dev server you want to reach) comes first. If host_port is omitted, it defaults to the same as sandbox_port.

krun backend: devsandbox forward is best-effort for the experimental krun microVM backend. The session is registered for forwarding, but reaching a listener inside the guest through the microVM network namespace has not yet been validated on a /dev/kvm host. See krun microVM backend.

Named sessions:

Use --name when starting a sandbox to give it a human-readable identifier:

devsandbox --proxy --name myapp

If omitted, the name is auto-generated from the working directory basename.

List running sessions:

devsandbox sessions
devsandbox sessions --json

Troubleshooting

Checking Installation

devsandbox doctor

This verifies:

  • Required binaries (bwrap - embedded or system-installed)
  • Optional binaries (pasta for proxy mode - embedded or system-installed)
  • The egress firewall proxy mode needs (proxy: firewall - nft/iptables plus the nf_tables/nf_conntrack modules)
  • User namespace support
  • Directory permissions
  • Overlayfs support (for tool writable layers)
  • Docker availability and daemon status
  • Docker base image (ghcr.io/zekker6/devsandbox:latest) presence
  • Configuration file validity
  • Kernel version
  • Recent errors in internal logs
  • Detected development tools (mise, editors, etc.)

Debug Mode

# Show bwrap arguments
DEVSANDBOX_DEBUG=1 devsandbox

Common Issues

"Docker daemon not running" (macOS)

Start your Docker runtime:

# Docker Desktop
open -a Docker

# OrbStack
open -a OrbStack

# Colima
colima start

Then verify with devsandbox doctor.

"User namespaces not enabled"

This varies by distribution:

  • Arch Linux / Fedora: User namespaces are enabled by default. If you see this error, check for a hardened kernel with kernel.userns_restrict and adjust if needed.
  • Debian / Ubuntu (< 23.10): May need sudo sysctl -w kernel.unprivileged_userns_clone=1. The sysctl kernel.unprivileged_userns_clone is a Debian/Ubuntu-specific patch and does not exist on all kernels.
  • Ubuntu 23.10+: AppArmor may restrict unprivileged user namespaces even when the sysctl is set. Run sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 or create an AppArmor profile exception for devsandbox.
  • Hardened kernels: Check kernel.userns_restrict and adjust if needed.

If namespace restrictions cannot be resolved, use the Docker backend instead (--isolation=docker).

"bwrap not found"

  • devsandbox includes embedded bwrap - this error means extraction failed and no system package is installed
  • Check devsandbox doctor output for details (embedded vs system source)
  • Install bubblewrap as a fallback: see Installation
  • To disable embedded binaries and use only system packages, set use_embedded = false in configuration

"Permission denied" on project files

  • Ensure your user owns the project directory
  • Check for ACLs that might interfere

Security Modules

devsandbox does not include SELinux or AppArmor handling. On systems with these security modules, sandbox operations may be blocked.

SELinux (Fedora, RHEL, CentOS):

  • SELinux policy may block bwrap namespace operations
  • Workaround: sudo setsebool -P user_namespace_allowed 1 or create a custom policy module
  • Check audit log: sudo ausearch -m avc -ts recent

AppArmor (Ubuntu, Debian):

  • On Ubuntu 23.10+, kernel.apparmor_restrict_unprivileged_userns=1 is enabled by default
  • Workaround: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
  • Alternative: Create an AppArmor profile exception for devsandbox

Fallback: If security module restrictions cannot be resolved, use the Docker backend (--isolation=docker) which avoids direct namespace creation.

Overlay Filesystem

By default, tool directories use the split mount mode: config paths get a tmpoverlay (discarded on exit) while caches and data get a persistent overlay. This protects host configs from supply chain attacks while preserving expensive caches across sessions. Use the [overlay] section to change the global default, or per-tool mount_mode overrides to fine-tune individual tools.

Configuration

Set the global default in ~/.config/devsandbox/config.toml:

[overlay]
# Default mount mode for all tool bindings
# split (default): configs → tmpoverlay, caches/data → persistent overlay
default = "split"

[tools.mise]
# Override for mise: persist all state across sessions
mount_mode = "overlay"

How Overlay Works

Overlayfs creates a layered filesystem:

┌─────────────────────────────────────┐
│  Sandbox view (merged)              │  ← What you see inside sandbox
├─────────────────────────────────────┤
│  Upper layer (writable)             │  ← New/modified files go here
├─────────────────────────────────────┤
│  Lower layer (host, read-only)      │  ← Original host files
└─────────────────────────────────────┘
  • tmpoverlay: Upper layer is tmpfs, discarded on exit
  • overlay (persistent): Upper layer stored in ~/.local/share/devsandbox/<project>/home/overlay/

Only the bwrap backend realizes a tmpoverlay directory as a kernel overlayfs mount. The docker and krun backends copy the host source into the sandbox on start instead: Docker's default seccomp profile denies the namespace creation the mount would need in a container without CAP_SYS_ADMIN (which the sandbox drops), the krun guest rejects an overlayfs whose lower layer is on virtio-fs, and Docker Desktop on macOS has no overlayfs at all. The discard semantics are preserved by clearing the copy target at the start of every run rather than at exit, so a previous run's writes are never visible - but they do sit in the sandbox home between runs instead of vanishing when the sandbox stops.

Choosing an Overlay Mode

Mode Writes persist? Host modified? Use when
split (default) Caches/data: yes. Configs: no Never Default - protects host configs from supply chain attacks
overlay Yes (all) Never You want all tool state to persist across sessions
tmpoverlay No Never Disposable experiments, CI, untrusted code
readonly No (writes fail) Never Maximum lockdown, no tool installation
readwrite Yes Yes Trusted projects where you want write-through to host

Use Cases

  • Install project-specific tool versions without polluting host (overlay or split)
  • Test new tool versions before committing to them (tmpoverlay)
  • Allow AI assistants to install tools they need temporarily (split keeps caches, discards config changes)
  • Run untrusted code with no persistent state (tmpoverlay)
  • Trusted projects where you want direct host access (readwrite)

Migrating Overlay Data to Host

Under the default split policy, category-data and category-cache bindings mount as persistent overlays. Writes made inside the sandbox (e.g. Claude Code session JSONLs under ~/.claude/projects, installed mise tools under ~/.local/share/mise) accumulate in the sandbox's overlay upper directory under ~/.local/share/devsandbox/<sandbox>/home/overlay/.../upper/ and are never promoted to the real host path.

If you want to flip a binding from overlay/split to readwrite - or just surface accumulated sandbox state onto the host - use devsandbox overlay migrate:

# Preview (dry-run, default): shows what would be written, overwritten, deleted.
devsandbox overlay migrate --sandbox my-project --tool claude

# Apply for real:
devsandbox overlay migrate --sandbox my-project --tool claude --apply

# Promote overlay data from every sandbox into the host path in one go:
devsandbox overlay migrate --all-sandboxes --tool claude --apply

# Target an arbitrary host path rather than a tool:
devsandbox overlay migrate --sandbox my-project --path ~/.local/share/mise --apply

# After migration, flip the binding to readwrite so future writes go straight to host:
devsandbox overlay migrate --sandbox my-project --tool claude --apply --set-mode readwrite

Flags:

Flag Purpose
--sandbox NAME Operate on one sandbox (mutually exclusive with --all-sandboxes).
--all-sandboxes Iterate every sandbox under ~/.local/share/devsandbox/.
--path HOST_PATH Promote a specific host path (mutually exclusive with --tool).
--tool NAME Shorthand: expand to every overlay binding the named tool declares.
--primary-only Ignore per-session uppers; only promote the primary persistent upper.
--apply Actually perform the migration (default is dry-run).
--set-mode MODE After a successful apply, set the tool's mount_mode in .devsandbox.toml. Requires --tool.
--force Proceed even if a targeted sandbox appears to have an active session (racy - only use when you're sure).
--yes Skip the multi-sandbox confirmation prompt when --set-mode would touch more than one config file.

Safety model:

  • Dry-run by default. Nothing is written without --apply. The preview lists every create / overwrite / delete the apply phase would perform.
  • Stopped-sandbox check. The command refuses to run if any targeted sandbox has an active session (--force bypasses).
  • Last-write-wins across stacked uppers. The primary persistent upper comes first, followed by per-session uppers in mtime order. The most recent upper's version of any file wins.
  • Whiteouts honored. Files the sandbox deleted (overlayfs char-device whiteouts) become host-file deletions on apply.
  • No automatic host backup. If you want one, make it yourself before passing --apply.

Custom Mounts

Beyond the built-in security rules, you can configure custom mount rules to control exactly what the sandbox can access.

Modes

Mode Behavior
readonly Host path visible inside sandbox, but writes blocked
readwrite Full read/write access to host path
hidden Path replaced with /dev/null (files only)
overlay Writes saved to sandbox home, host unchanged
tmpoverlay Writes go to tmpfs, discarded on exit

hidden is the only mode that conceals anything, and it applies to files only: a directory cannot be replaced with /dev/null, so a rule that resolves to a directory is skipped and devsandbox warns at startup. The other modes all leave the host files readable. To keep a directory's contents out of the sandbox, match the files inside it (**/secrets/**, ~/secrets/**/*) rather than the directory itself (secrets/**, ~/secrets).

How Custom Mounts Work

Custom mounts are processed before the sandbox home is mounted. This means:

  1. Home directory paths (~/.config/myapp) are mounted from the host
  2. Project-relative patterns (**/secrets/**) are applied to the project directory
  3. First matching rule wins when patterns overlap
Mount Order:
1. System bindings (/usr, /lib, /etc)
2. Network bindings (/etc/resolv.conf, etc)
3. Custom mounts ← Your rules applied here
4. Sandbox home (~/)
5. Project directory
6. Tool bindings (mise, git, etc)

Pattern Matching

Patterns use glob syntax with the doublestar library:

Pattern Description
~/.config/myapp Exact path (~ expanded to $HOME)
*.conf Files ending in .conf (current dir)
**/*.key All .key files at any depth
**/secrets/** Anything under any "secrets" dir
/opt/tools Absolute path (no expansion)

Examples

Mount application config:

[[sandbox.mounts.rules]]
pattern = "~/.config/myapp"
mode = "readonly"

Hide project secrets:

[[sandbox.mounts.rules]]
pattern = "**/secrets/**"
mode = "hidden"

The trailing /** is what makes this work - it matches the files under every secrets directory. secrets/** would resolve to the directory itself, which cannot be hidden.

Writable cache with persistence:

[[sandbox.mounts.rules]]
pattern = "~/.cache/expensive-builds"
mode = "overlay"

Temporary scratch space:

[[sandbox.mounts.rules]]
pattern = "~/.local/share/myapp/tmp"
mode = "tmpoverlay"

Viewing Active Mounts

Use --info to see what custom mounts are active:

devsandbox --info

Output includes a "Custom Mounts" section if rules are configured.

How It Works (Docker)

The Docker backend provides isolation via containers, enabling macOS support and simplified distribution. The following sections are Docker-specific. For bwrap behavior, see How It Works (bwrap) above.

Docker-Specific Configuration

Configure Docker settings in ~/.config/devsandbox/config.toml. See Configuration: Isolation Backend for the full TOML reference.

Container Persistence

By default, Docker containers are kept after exit to enable fast restarts. This significantly improves startup time from ~5-10 seconds to ~1-2 seconds.

Container Lifecycle:

State Behavior
Container doesn't exist Creates new container, then starts it
Container stopped Starts existing container (~1-2s)
Container running Exec into running container (instant)

Container Naming:

Containers are named devsandbox-<project>-<hash> where hash is derived from the project path: - Same project directory always gets the same container - Different directories with same project name get different containers

Configuration:

[sandbox.docker]
keep_container = true   # Keep container after exit (default)
keep_container = false  # Remove container on exit

One-off Fresh Containers:

Use --rm flag for ephemeral mode - sandbox state is removed after exit:

devsandbox --rm

This works for both backends: - Docker: don't keep container after exit (fresh container each run) - bwrap: remove sandbox home directory after exit

Managing Containers:

# List all sandboxes including Docker container state
devsandbox sandboxes list

# Prune stopped Docker containers
devsandbox sandboxes prune

# Force remove specific container
docker rm -f devsandbox-myproject-abc123

The sandboxes list command shows container state (running/stopped/exited) in the STATUS column.

How Docker Backend Works

The Docker backend:

  1. UID/GID Mapping: Creates a container user matching your host UID/GID for proper file ownership
  2. Project Mount: Mounts your project directory at its host path (read/write)
  3. Sandbox Home:
  4. Linux: Bind mount for consistency with bwrap
  5. macOS: Named volume for better performance
  6. Tool Configs: Mounts nvim, tmux, starship configs read-only from host
  7. Host mise Tools (Linux hosts): Mounts ~/.local/share/mise/installs read-only and seeds the versions into the sandbox on startup, so host-installed tools resolve without a reinstall (see Tools: mise)
  8. Proxy Support: Uses host.docker.internal for proxy connections

Docker Image

devsandbox uses a Dockerfile-based workflow. On first run, a default Dockerfile is created at ~/.config/devsandbox/Dockerfile with:

FROM ghcr.io/zekker6/devsandbox:latest

The image is rebuilt on every sandbox start. Docker layer caching keeps this fast when the Dockerfile hasn't changed.

The base image (ghcr.io/zekker6/devsandbox:latest) includes: - Debian slim base - mise for tool management - Common development tools (git, curl, bash, zsh) - devsandbox-shim, which drops from container root to the sandbox user itself - passt/pasta for network isolation (if needed)

Customizing the Image

Edit the default Dockerfile at ~/.config/devsandbox/Dockerfile to add tools globally:

FROM ghcr.io/zekker6/devsandbox:latest

# Add project-specific tools
RUN apt-get update && apt-get install -y postgresql-client

# Pre-install mise tools
RUN mise install node@20 python@3.12

The global Dockerfile produces a devsandbox:local image tag.

Per-Project Dockerfiles

For project-specific customizations, point to a different Dockerfile in .devsandbox.toml:

[sandbox.docker]
dockerfile = "Dockerfile.devsandbox"

Per-project Dockerfiles produce a devsandbox:<project-name>-<hash> image tag.

Manual Image Build

Build the image without starting a sandbox:

devsandbox image build

Platform Differences

Feature Linux (bwrap) Linux (docker) macOS (docker)
Sandbox home Bind mount Bind mount Named volume
File performance Native Near-native Slower (volume)
.env hiding Overlay Volume mount Volume mount
Network isolation pasta HTTP_PROXY HTTP_PROXY
Host integration Full Via mounts Via mounts

macOS Docker Environments

Supported Runtimes

Runtime Notes
Docker Desktop Official Docker runtime. Best compatibility.
OrbStack Lightweight alternative. Faster startup, lower resource usage.
Colima Free, open-source. Uses Lima VMs.

All three provide a Docker-compatible daemon. devsandbox auto-detects the Docker socket location (see tools.md for socket detection details).

Docker Desktop (or equivalent) should be configured with at least: - RAM: 4 GB+ - CPUs: 2+

These are Docker Desktop minimum allocations. devsandbox containers use these resources but can be further constrained via [sandbox.resources] in your config. The container limit cannot exceed what Docker Desktop allocates. Lower values work but may slow builds and tool installations inside the sandbox.

Volume Performance

On macOS, the sandbox home directory uses a named Docker volume instead of a bind mount. This is necessary because bind mounts on macOS go through a virtualization layer (VirtioFS or gRPC-FUSE) that can be 2-5x slower than native filesystem access.

Named volumes store data inside the Docker VM's filesystem, providing near-native performance for operations like npm install, Go builds, and other I/O-heavy tasks. The tradeoff is that volume contents are not directly accessible from the macOS Finder - use devsandbox sandboxes list to view sizes and devsandbox sandboxes prune to reclaim space. To see Docker volumes directly: docker volume ls | grep devsandbox.

File Watching Limitations

File watching (inotify) across the macOS ↔ Docker boundary can be unreliable. If your dev server doesn't detect file changes, configure it to use polling:

# Next.js / webpack
WATCHPACK_POLLING=true devsandbox npm run dev

# Vite
devsandbox - npx vite --force

# Generic: set CHOKIDAR_USEPOLLING for chokidar-based watchers
CHOKIDAR_USEPOLLING=true devsandbox npm run dev

Docker Socket Forwarding Security Warning

When [tools.docker] enabled = true, the sandbox gains filtered access to the host Docker daemon. The proxy allows:

  • Read operations: List all containers, images, volumes, and networks on the host
  • Exec/attach: Execute commands inside ANY running container on the host

This is intentional - it enables Docker-in-Docker workflows. However, it effectively grants the sandbox host-level access via Docker, which is often equivalent to root. Only enable this for trusted code.

Container creation, deletion, and image manipulation are blocked by the proxy filter.

Known Limitations (Docker)

  • No pasta network: Network isolation uses HTTP_PROXY instead of pasta
  • Proxy filtering: Proxy mode with request filtering is supported via per-session Docker networks
  • .env hiding: Uses Docker volume mounts (-v /dev/null) to hide .env files - no special privileges required
  • macOS file performance: Named volumes are slower than native filesystem
  • inotify limitations: File watching may be less reliable on macOS
  • No nested Docker: Cannot run Docker commands inside the sandbox

Limitations

Bwrap Backend (Linux)

  • Linux only - Uses Linux-specific namespaces and capabilities
  • User namespaces required - Most modern distros have this enabled
  • No nested containers - Running Docker inside the sandbox is not supported
  • Some tools may break - Tools that require specific system access may fail
  • Overlay requires kernel support - Unprivileged overlayfs (inside user namespaces) requires kernel 5.11+
  • Resource limits need systemd - [sandbox.resources] requires cgroup v2 and a systemd user manager with the relevant controllers delegated; without limits configured, none of that is needed

Docker Backend (All Platforms)

  • Docker required - Docker Desktop or Docker Engine must be installed and running
  • No pasta network - Uses HTTP_PROXY for network isolation instead
  • Performance on macOS - File operations may be slower due to volume mounts
  • Image build - First run builds the image from a Dockerfile (requires downloading the base image ~200MB)

See Also

  • Proxy Mode - network isolation and traffic inspection
  • Tools - how development tools are made available inside the sandbox
  • Configuration - config file reference, custom mounts, overlay settings
  • Use Cases - workflows and shell setup

Back to docs index | Back to README