Repository navigation
Nested Devcontainers
A project that ships its own .devcontainer runs inside the cage: the cage carries a rootless daemon (docker-in-docker), so the project's stack comes up one level down. The Dev Containers extension is scoped to the outermost VS Code though, and it only sees the Docker host's daemon. Reaching the inner container takes three steps: publish the cage's daemon to the host, start the stack from the cage, attach from the host.
(Host) The cage's daemon listens on a second socket, /run/docker-host/docker.sock, which compose.yaml shares to ~/.o3s-docker-host. Add it there as a context:
docker context create o3s-dind --docker host=unix://$HOME/.o3s-docker-host/docker.sock
docker context lsSee also: Docker contexts
(Cage) From the project's own dev container definition:
npx @devcontainers/cli up [--build-no-cache] [--workspace-folder ~/projects/<project>]See also: Dev Container CLI
(Host) Point the tooling at the cage's daemon, and reload the window if needed:
docker context use o3s-dind(VSCode) The Container Tools view now lists the cage's containers, the project's among them. Right-click it and pick:
Attach Visual Studio Code
See also: Container Tools
MCP servers
A project commits its servers in .mcp.json, written for the container it ships:
{
"mcpServers": {
"chrome-devtools": {
"command": "npx",
"args": ["-y", "chrome-devtools-mcp@latest", "--no-usage-statistics", "--isolated=true", "--headless=true"]
},
"laravel-boost": {
"command": "php",
"args": ["artisan", "boost:mcp"]
},
"shadcn": {
"command": "npx",
"args": ["shadcn@latest", "mcp"]
}
}
}Each server runs where its command exists. Assume here that the cage's features ship npx, and the inner container php.
(Cage) The php that laravel-boost wants exists in the inner container alone, so an agent in the cage reaches it through a docker exec. Register the override from the project folder, in the local scope, which takes precedence and leaves the committed file untouched:
claude mcp add-json laravel-boost '{"command":"docker","args":["exec","-i","<container-name>","php","artisan","boost:mcp"]}' --scope localWorktrees
Each worktree carries its own .devcontainer and keeps its history in the main checkout, so the stack needs that repository's git directory at its own path, and a project name that tells the worktrees apart. The layout this assumes:
~/projects/
├── myproject/ the main checkout, holding .git
└── myproject.worktrees/
└── feature-x/ the worktree, its .git naming the path above
(Cage) From the worktree:
WORKTREE_DIR="$(pwd)"
REPO_GIT_DIR="$(git -C "$WORKTREE_DIR" rev-parse --path-format=absolute --git-common-dir)"
REPO_DIR="$(dirname "$REPO_GIT_DIR")"
export COMPOSE_PROJECT_NAME="$(basename "$REPO_DIR")$([ "$WORKTREE_DIR" != "$REPO_DIR" ] && echo "-$(basename "$WORKTREE_DIR")")"
npx @devcontainers/cli up \
--workspace-folder "$WORKTREE_DIR" \
--mount "type=bind,source=$REPO_GIT_DIR,target=$REPO_GIT_DIR" \
[--mount "type=bind,source=/etc/ssl/certs/ca-certificates.crt,target=/etc/ssl/certs/ca-certificates.crt"] \
[--remote-env SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt]-
COMPOSE_PROJECT_NAMEtakes the repository's name, and the worktree's alongside it when the two differ, so each worktree brings up containers of its own. -
--mountputs the repository's git directory at the path the worktree's.gitfile names, which is how git resolves the worktree from inside.