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:
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.controllerspresent) systemd-runonPATH- a running systemd user manager (
systemctl --user is-system-running) - the controllers the limits need, delegated to
user@<uid>.service:memoryformemory,cpuforcpus,pidsforpids
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 whencpuis not delegated. Preflight rejects it instead, naming the controller. Delegation is granted with a drop-in onuser@.servicesettingDelegate=cpu cpuset io memory pids. - Value rounds to nothing.
cpus = "0.004"would becomeCPUQuota=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=0would 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-runexit 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 outsideuser@<uid>.service- the usual case on SSH and bare-TTY logins, where the common cgroup ancestor is the root-owneduser-<uid>.sliceand 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 devsandboxto see volumes, ordevsandbox sandboxes listto 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 forwardis best-effort for the experimentalkrunmicroVM 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/kvmhost. See krun microVM backend.
Named sessions:
Use --name when starting a sandbox to give it a human-readable identifier:
If omitted, the name is auto-generated from the working directory basename.
List running sessions:
Troubleshooting¶
Checking Installation¶
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/iptablesplus thenf_tables/nf_conntrackmodules) - 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¶
Common Issues¶
"Docker daemon not running" (macOS)
Start your Docker runtime:
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_restrictand adjust if needed. - Debian / Ubuntu (< 23.10): May need
sudo sysctl -w kernel.unprivileged_userns_clone=1. The sysctlkernel.unprivileged_userns_cloneis 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=0or create an AppArmor profile exception for devsandbox. - Hardened kernels: Check
kernel.userns_restrictand 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 doctoroutput 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 = falsein 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 1or 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=1is 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 (
overlayorsplit) - Test new tool versions before committing to them (
tmpoverlay) - Allow AI assistants to install tools they need temporarily (
splitkeeps 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 (
--forcebypasses). - 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:
- Home directory paths (
~/.config/myapp) are mounted from the host - Project-relative patterns (
**/secrets/**) are applied to the project directory - 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:
Hide project secrets:
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:
Temporary scratch space:
Viewing Active Mounts¶
Use --info to see what custom mounts are active:
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:
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:
- UID/GID Mapping: Creates a container user matching your host UID/GID for proper file ownership
- Project Mount: Mounts your project directory at its host path (read/write)
- Sandbox Home:
- Linux: Bind mount for consistency with bwrap
- macOS: Named volume for better performance
- Tool Configs: Mounts nvim, tmux, starship configs read-only from host
- Host mise Tools (Linux hosts): Mounts
~/.local/share/mise/installsread-only and seeds the versions into the sandbox on startup, so host-installed tools resolve without a reinstall (see Tools: mise) - Proxy Support: Uses
host.docker.internalfor proxy connections
Docker Image¶
devsandbox uses a Dockerfile-based workflow. On first run, a default Dockerfile is created at
~/.config/devsandbox/Dockerfile with:
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:
Per-project Dockerfiles produce a devsandbox:<project-name>-<hash> image tag.
Manual Image Build¶
Build the image without starting a sandbox:
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).
Recommended Resources¶
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.envfiles - 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