Best Practices for devcontainer.json in Monorepos
Scale devcontainer.json across polyglot monorepos without mount conflicts. Standardize toolchain injection, fix ENOSPC/EACCES errors, and enforce per-service parity.
Nested devcontainer.json files in a polyglot monorepo bind-mount overlapping host directories, triggering ENOSPC (inode exhaustion) and EACCES (permission denied) the moment pnpm install runs. This page fixes the mount conflict and standardizes a single root configuration; it extends the devcontainer configuration standards within the broader containerized local environment patterns.
Diagnostic
When service-level devcontainer.json files override the root workspaceMount, Docker mounts both the monorepo root and a nested package path. The overlapping recursive bind mapping exhausts inodes or denies permission during dependency resolution. Inspect the active bind mounts:
#!/usr/bin/env bash
set -euo pipefail
docker compose -f .devcontainer/docker-compose.yml config --quiet
docker inspect "$(docker ps -q -f name=devcontainer)" \
--format '{{json .Mounts}}' | jq '.[] | select(.Type=="bind")'
Expected BAD output — note the duplicated root and nested bind paths:
[
{
"Type": "bind",
"Source": "/Users/dev/projects/monorepo/packages/web",
"Destination": "/workspace/packages/web",
"RW": true,
"Propagation": "rprivate"
},
{
"Type": "bind",
"Source": "/Users/dev/projects/monorepo",
"Destination": "/workspace",
"RW": true,
"Propagation": "rprivate"
}
]
A second tell is high overlay utilization from the redundant mounts:
#!/usr/bin/env bash
set -euo pipefail
grep -rE 'workspaceFolder|workspaceMount' .devcontainer/ --include='*.json'
docker run --rm -v "$(pwd):/tmp" alpine df -h /tmp
.devcontainer/web/devcontainer.json: "workspaceMount": "...monorepo..."
.devcontainer/api/devcontainer.json: "workspaceMount": "...monorepo..."
Filesystem Size Used Avail Use% Mounted on
overlay 59G 45G 14G 77% /tmp
Root Cause
Each nested devcontainer.json redeclares workspaceMount against the monorepo root, so Docker stacks one bind mount inside another. The recursive inode mapping exhausts the device (ENOSPC), and because the nested service runs as a different user than the root mount owner, writes into node_modules fail with EACCES. Hardcoded absolute host paths make this worse across macOS, Linux, and Windows because the same config resolves to different real directories per host.
Resolution
Remove nested
workspaceMountoverrides. Strip explicitworkspaceMountdeclarations from every service-leveldevcontainer.jsonand let the root configuration propagate the single bind mount.Define one root configuration at
.devcontainer/devcontainer.jsonthat owns the mount and the Compose file:// .devcontainer/devcontainer.json { "workspaceFolder": "/workspace", "dockerComposeFile": ["docker-compose.yml"], "service": "dev", "postCreateCommand": "pnpm install --frozen-lockfile && pnpm run build" }Map toolchain caches to named volumes, not bind mounts, so polyglot package stores bypass the host I/O path:
# .devcontainer/docker-compose.yml services: dev: build: . volumes: - ..:/workspace:cached - pnpm_store:/workspace/.pnpm-store - go_cache:/workspace/.cache/go volumes: pnpm_store: go_cache:Inject toolchain roots via
remoteEnvinstead of hardcoded host paths, keeping resolution identical across operating systems:// .devcontainer/devcontainer.json { "remoteEnv": { "NX_WORKSPACE_ROOT": "/workspace", "PNPM_HOME": "/workspace/.pnpm-store", "GOPATH": "/workspace/.cache/go" } }Validate before committing with
devcontainer config --workspace-folder .to confirm a single resolvedworkspaceMount.
Expected Output
After consolidation, a trace-level bring-up shows exactly one workspace bind mount and named-volume caches:
#!/usr/bin/env bash
set -euo pipefail
devcontainer up --workspace-folder . --log-level trace 2>&1 \
| grep -E 'workspaceMount|volume|network'
workspaceMount: source=/Users/dev/projects/monorepo,target=/workspace,type=bind
volume: monorepo_pnpm_store:/workspace/.pnpm-store
network: monorepo_net attached to container
devcontainer up completed successfully
Prevention
Maintain a central
.devcontainer/base/devcontainer.jsonand have services reference it instead of redeclaring mounts.Add a PR check that rejects any nested
workspaceMountkey:#!/usr/bin/env bash set -euo pipefail if grep -rl '"workspaceMount"' .devcontainer/*/devcontainer.json 2>/dev/null; then echo "ERROR: nested workspaceMount override detected" >&2 exit 1 fi echo "mount strategy OK"Pin base images to SHA digests (
node:20.11.0-alpine@sha256:...) so cache state is reproducible, per the devcontainer configuration standards.
macOS (Docker Desktop): Inode pressure surfaces faster because each FUSE-translated bind mount carries overhead; named volumes for caches sidestep it entirely. WSL2: Keep the monorepo on the Linux filesystem (
~/code, not/mnt/c) — 9p translation multiplies the cost of the duplicate mounts. Apple Silicon (ARM64): Confirm every base image has anarm64manifest withdocker manifest inspect; an emulatedamd64toolchain compounds the slow-mount problem duringpnpm install.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD -- .devcontainer/
docker compose -f .devcontainer/docker-compose.yml down -v
devcontainer up --workspace-folder . --remove-existing-container