Skip to content

Nested Devcontainers

Hansehart edited this page Sep 13, 2026 · 1 revision

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.

Docker context

(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 ls

See also: Docker contexts

Start the stack

(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

Join

(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

Adjustments

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 local
Worktrees

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_NAME takes the repository's name, and the worktree's alongside it when the two differ, so each worktree brings up containers of its own.
  • --mount puts the repository's git directory at the path the worktree's .git file names, which is how git resolves the worktree from inside.

Clone this wiki locally