Inside the dev container, docker ps fails with permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock, or it works but docker run -v $PWD:/src alpine ls /src shows an empty directory, or Testcontainers-based tests hang at Waiting for Ryuk container to start. Running Docker from inside a dev container is common — for Compose stacks, integration tests, image builds — and there are two very different ways to do it, each with its own failure modes. This page chooses between them and fixes the classic errors, as part of dev container configuration standards.

The two approaches are docker-outside-of-docker, which shares the host's Docker daemon through its socket, and docker-in-docker, which runs a separate daemon inside the dev container.

Diagnostic

Find out which approach is configured, whether the user can reach the socket, and how paths map:

#!/usr/bin/env bash
set -euo pipefail
jq '.features // {} | keys[] | select(test("docker-(in|outside)"))' .devcontainer/devcontainer.json
ls -l /var/run/docker.sock /var/run/docker-host.sock 2>/dev/null || true
id -nG
docker info --format 'daemon: {{.Name}} {{.OperatingSystem}}' 2>&1 | head -1
echo "workspace inside: $PWD   LOCAL_WORKSPACE_FOLDER=${LOCAL_WORKSPACE_FOLDER:-unset}"
docker run --rm -v "$PWD":/src alpine ls /src | head -3

Expected bad output with docker-outside-of-docker and default paths:

"ghcr.io/devcontainers/features/docker-outside-of-docker:1"
srw-rw---- 1 root 998 0 Sep 18 09:10 /var/run/docker-host.sock
vscode sudo
permission denied while trying to connect to the Docker daemon socket
workspace inside: /workspaces/shop   LOCAL_WORKSPACE_FOLDER=/Users/dev/src/shop

The user is not in the group that owns the host socket, and even once it is, $PWD is a path inside the dev container that the host daemon does not know about, so the bind mount comes up empty.

Outside-of-Docker vs In-Docker Comparison of sharing the host Docker daemon against running a nested daemon inside the dev container. Outside-of-Docker vs In-Docker docker-outside-of-docker docker-in-docker host daemon via socket separate daemon inside sees host containers isolated container list shares host image cache own cache, rebuilt bind paths are host paths bind paths are local no privileged mode needs privileged
Share the host daemon for most work; nest a daemon only when isolation matters.

Root cause

With docker-outside-of-docker, the dev container's docker CLI talks to the host daemon through a mounted socket. Two consequences follow. The socket is owned by a group on the host whose numeric ID may not exist or match inside the container, so the non-root dev user gets permission denied until the Feature's group mapping runs. And every path in a docker run -v or Compose bind mount is resolved by the host daemon, on the host filesystem — so /workspaces/shop, which exists only inside the dev container, maps to nothing. The host path is available as LOCAL_WORKSPACE_FOLDER. With docker-in-docker, a separate daemon runs inside the container, paths are local and isolation is complete, but it needs --privileged, has its own image cache (so the first pull of everything is slow) and doubles memory use. Testcontainers adds a third layer: its Ryuk reaper container needs to mount the Docker socket too, and its path assumptions depend on which approach is active.

Resolution

  1. Choose the approach. Default to docker-outside-of-docker for everyday Compose work and builds; use docker-in-docker when tests need an isolated daemon or when the host daemon must not see the dev container's containers.

  2. Configure docker-outside-of-docker with the socket group mapped and the host path exposed:

{
  "image": "mcr.microsoft.com/devcontainers/base:1.2.3-bookworm",
  "features": {
    "ghcr.io/devcontainers/features/docker-outside-of-docker:1.5.0": { "moby": false }
  },
  "remoteEnv": {
    "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}"
  },
  "remoteUser": "vscode"
}

The Feature adds the user to a group matching the socket's GID at startup. Rebuild the container after adding it; an existing container keeps the old group list.

  1. Use host paths for bind mounts when launching containers from inside the dev container:
#!/usr/bin/env bash
set -euo pipefail
host_path="${LOCAL_WORKSPACE_FOLDER:?not running in a dev container with the host path exposed}"
docker run --rm -v "$host_path":/src alpine ls /src | head -3

In Compose files used from inside the dev container, set the project directory to the host path: docker compose --project-directory "$LOCAL_WORKSPACE_FOLDER" up -d, so relative bind mounts resolve on the host.

  1. Or configure docker-in-docker when isolation is required:
{
  "features": {
    "ghcr.io/devcontainers/features/docker-in-docker:2.12.0": { "version": "27.2", "dockerDashComposeVersion": "v2" }
  },
  "runArgs": ["--privileged"],
  "mounts": ["source=dind-var-lib-docker,target=/var/lib/docker,type=volume"]
}

The named volume on /var/lib/docker keeps the inner daemon's image cache across rebuilds, which removes most of the slow first-pull cost.

  1. Point Testcontainers at the right socket and host:
#!/usr/bin/env bash
set -euo pipefail
export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock
export TESTCONTAINERS_HOST_OVERRIDE=host.docker.internal
npm test -- --grep integration

With docker-outside-of-docker, containers Testcontainers starts run on the host, so tests reach them through host.docker.internal rather than localhost.

Which Docker Setup for This Dev Container? Decision diagram choosing docker-outside-of-docker or docker-in-docker based on isolation needs. Which Docker Setup for This Dev Container? Must containers be isolated from the host? No docker-outside-of-docker Yes, CI-like tests docker-in-docker
Most teams need the shared daemon; isolation needs justify the nested one.

Expected output

$ docker info --format 'daemon: {{.Name}} {{.OperatingSystem}}'
daemon: docker-desktop Docker Desktop
$ docker run --rm -v "$LOCAL_WORKSPACE_FOLDER":/src alpine ls /src | head -3
README.md
api
compose.yaml
$ npm test -- --grep integration
  ✓ creates an order against a real Postgres (2311ms)

The CLI reaches the daemon without sudo, bind mounts show the project files, and Testcontainers-based tests start their containers and connect to them.

The daemon name in the first line is a quick way to confirm which approach is in effect: with docker-outside-of-docker it reports the host daemon (docker-desktop, colima or the host's hostname), with docker-in-docker it reports the dev container's own hostname. When a colleague reports that "Docker inside the dev container behaves differently", comparing that one line usually shows they are on the other approach.

Prevention

  1. Check Docker access in postStartCommand with docker info >/dev/null || echo 'Docker not reachable: rebuild the container', so group or socket problems are reported when the container starts, not mid-task.

  2. Avoid $PWD in bind mounts in scripts that may run inside a dev container; use a helper that prefers LOCAL_WORKSPACE_FOLDER when set.

  3. Document the chosen approach in the repository's contributing guide, because the two have different path and networking behaviour that affects every script.

Common Errors and Fixes Table mapping Docker-in-dev-container errors to their cause and fix. Common Errors and Fixes Error Cause Fix permission denied socket GID not mapped rebuild with Feature empty bind mount container path used LOCAL_WORKSPACE_FOLDER Ryuk hangs socket path wrong socket override var connection refused localhost used host.docker.internal
Most errors come from socket permissions or from paths resolved on the host.

Platform caveats

macOS (Docker Desktop): with docker-outside-of-docker, host paths are macOS paths (/Users/...), which Docker Desktop's file sharing must include. Projects outside shared directories produce empty mounts even with the right path.

WSL2: LOCAL_WORKSPACE_FOLDER is a Linux path inside the WSL distribution when the repository lives there, which the Docker Desktop daemon can mount directly. Repositories under /mnt/c work but are slow.

Apple Silicon (ARM64): docker-in-docker images are multi-arch; the inner daemon runs arm64 containers by default and amd64 ones through the host's emulation.

Codespaces: docker-in-docker is the usual choice because the host daemon is not exposed; the named-volume cache still helps across rebuilds.

Rollback

Remove the Docker Feature and rebuild; the dev container returns to having no Docker CLI:

#!/usr/bin/env bash
set -euo pipefail
jq 'del(.features["ghcr.io/devcontainers/features/docker-outside-of-docker:1.5.0"]) | del(.remoteEnv.LOCAL_WORKSPACE_FOLDER)' \
  .devcontainer/devcontainer.json > /tmp/dc.json && mv /tmp/dc.json .devcontainer/devcontainer.json
devcontainer up --workspace-folder . --remove-existing-container

Frequently Asked Questions

Why does docker ps say permission denied inside the dev container?

The dev user is not in the group that owns the mounted socket. The docker-outside-of-docker Feature fixes this at container start; rebuild the container after adding the Feature so the group mapping runs.

Why are my bind mounts empty when run from the dev container?

With a shared host daemon, bind-mount paths are resolved on the host. Use the host path from LOCAL_WORKSPACE_FOLDER instead of the path inside the dev container.

Is docker-in-docker safe?

It requires privileged mode, which gives the dev container broad access to the host kernel. That is acceptable for trusted development environments but should not be used for untrusted code.

Which approach do Testcontainers prefer?

Both work. With the shared daemon, set the socket override and reach containers via host.docker.internal; with docker-in-docker, defaults usually work because the daemon is local.