Skip to content

Storage

Bind mounts

From the host:

host in-container purpose
~/workspaces /home/dev/workspaces code
/var/run/docker.sock same DooD socket
$HOME/.ssh /home/dev/.ssh ssh keys
$HOME/.aws /home/dev/.aws aws credentials
$HOME/.config/gcloud /home/dev/.config/gcloud gcloud credentials

Named volumes

They keep tool state across image rebuilds, and live inside docker's data-root, so they follow it onto the second disk when you use --disk:

volume in-container
opencode-data /home/dev/.local/share/opencode (auth, db, sessions)
opencode-config /home/dev/.config/opencode
openchamber-config /home/dev/.config/openchamber
code-server-data /home/dev/.local/share/code-server
ollama-models /root/.ollama (only when an ollama* profile is selected)
obsidian-config /config (vaults + app state; only with the obsidian profile)
t3code-data /home/dev/.t3/userdata (sessions + settings; t3code profile)

Named volumes hold runtime state, never content the image owns. Docker seeds a named volume from the image once; after that the volume's copy wins forever, so anything image-owned behind it is frozen at whatever the volume was created with. That is why there is deliberately no volume at /home/dev/.opencode: it holds the binary, so the image stays authoritative and OPENCODE_VERSION bumps take effect on rebuild.

The same split applies to t3code: ~/.t3 holds the image-owned runtime (no volume), and only ~/.t3/userdata is persisted — so a rebuild updates T3 Code without losing sessions.

The same rule is why code-server-data needs help: it mixes runtime state (User/workspaceStorage — open editors, recent folders) with image-owned extensions, so docker-compose.yml keeps a post_start hook that installs any missing ones after each start.

DooD (docker-out-of-docker)

Each dev container gets the host socket and a client baked into the image (Dockerfile pins DOCKER_CLI_VERSION and COMPOSE_VERSION), so docker and docker compose work inside without a nested dockerd or --privileged. Mount the socket alone on purpose — binding the host's own binaries is the older approach and breaks on macOS (wrong executable format) and across distros (paths move). group_add grants the dev user access to the socket (host docker gid → DOCKER_GID in .env).

setup.sh resolves that gid for you: on Linux it reads it straight off /var/run/docker.sock; on macOS Docker Desktop presents the socket as root:root 0660, which the compose default DOCKER_GID=0 matches — a container-scoped group, not host root, overridable in .env.

Treat the socket as root. Anything that can write to it can start a container mounting / and own the host — that includes opencode running as an agent in these containers. It is why nothing here is exposed publicly by default. Narrow it further with a socket proxy (e.g. tecnativa/docker-socket-proxy) restricting the reachable API endpoints, or bind it :ro if you only need read-only inspection.

Second disk (opt-in: --disk)

Single-disk VMs need nothing: Docker keeps /var/lib/docker and code lives in ~/workspaces. With a second disk, sudo ./setup.sh --disk <mount> writes "data-root" into /etc/docker/daemon.json, so image layers and named volumes land on it before Docker is even installed — no rsync, no rm -rf, no symlinked /var/lib/docker like nanocode.sh. Two caveats:

  • Images already in /var/lib/docker are not carried over. Move them first with systemctl stop docker && rsync -a /var/lib/docker/ <mount>/docker/.
  • ~/workspaces becomes a symlink to <mount>/workspaces, so the bind mount above stays identical either way.
  • On macOS, Docker Desktop owns the data location — step 1 prints where to set it (Settings → Resources → Advanced) instead of writing daemon.json.

Next: Updating — rebuilds from inside the stack.