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/dockerare not carried over. Move them first withsystemctl stop docker && rsync -a /var/lib/docker/ <mount>/docker/. ~/workspacesbecomes 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.