Why DinD Looks Attractive
Many teams adopt Docker‑in‑Docker (DinD) when they need to build container images inside a CI job. The idea is simple: run a Docker daemon inside the job container, execute docker build, and push the artifact to a registry. On the surface this removes the need for a separate build host and fits neatly into a YAML‑based pipeline definition.
The following sections walk through a minimal DinD setup, then expose the subtle problems that appear only under real‑world load, multi‑arch builds, or when the pipeline runs in a shared runner pool.
Step‑by‑Step DinD Setup in GitHub Actions
First, create a workflow file .github/workflows/dind.yml. The job uses the official docker image that already contains a Docker daemon ready to be started in “privileged” mode.
name: DinD Build Demo
on:
push:
branches: [ main ]
jobs:
build:
runs-on: ubuntu‑latest
container:
image: docker:20.10-dind
options: --privileged
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Start Docker daemon
run: |
dockerd --host=unix:///var/run/docker.sock &
timeout 15 sh -c 'until docker info; do sleep 1; done'
- name: Build image
run: |
docker build -t ghcr.io/${{ github.repository }}:$(git rev-parse --short HEAD) .
docker push ghcr.io/${{ github.repository }}:$(git rev-parse --short HEAD)
The workflow runs successfully on a test repository. It builds a tiny alpine‑based image and pushes it to GitHub Container Registry. For a single‑stage build, this appears to work flawlessly.
Hidden Failure Modes
As soon as the pipeline grows—multiple stages, caching layers, or multi‑architecture builds—the DinD approach begins to reveal three classes of hidden issues:
- Layer contamination: Because the inner Docker daemon shares the host’s kernel, any stray container left behind from a previous run can affect subsequent builds. A forgotten
docker network rmor lingering volume may cause “network not found” errors that are hard to trace. - Resource exhaustion: The inner Docker daemon consumes its own cgroups inside the outer container. When the outer runner already limits CPU and memory, the nested daemon can silently hit its own limits, causing builds to abort with “no space left on device” while the outer logs show only a generic timeout.
- Security surface expansion: Running in
--privilegedgrants the job container full access to the host kernel. If an attacker manages to inject malicious code into the build (for example, via a compromised dependency), they can escape the container and affect the runner host, compromising all other jobs that share the same pool.
The following snippet demonstrates a typical failure caused by leftover containers:
# Simulate a stray container left from a previous run
docker run -d --name stray alpine sleep 3600
# Next build attempts to create a network with the same name
docker network create mynet
# Error: network mynet already exists
When the pipeline runs on a shared runner, the stray container may belong to a completely unrelated project, making debugging extremely time‑consuming.
Why the “privileged” Flag Is a Red Flag
The --privileged flag disables most of Docker’s isolation guarantees. It grants the inner daemon the ability to load kernel modules, manipulate device nodes, and adjust system settings. In a CI environment that processes untrusted pull‑requests, this is a direct pathway for a supply‑chain attack.
An illustrative exploit can be reproduced with a malicious Dockerfile that attempts to load a kernel module:
FROM alpine
RUN apk add --no-cache kmod \
&& modprobe evil_module || true
On a non‑privileged runner this step would fail, but inside DinD it succeeds silently, potentially leaving the host compromised. This is why many security teams now flag any workflow that requests --privileged as a high‑severity finding.
Alternative Approaches
To keep the convenience of building images in CI without the drawbacks of DinD, consider one of the following patterns:
- Docker socket binding: Mount the host’s Docker socket (
/var/run/docker.sock) into the job container. This avoids a nested daemon but still requires careful permission management. - BuildKit remote workers: Use a dedicated BuildKit server (e.g.,
moby/buildkit) that runs on a separate VM or managed service. Jobs send build requests over HTTP, keeping isolation intact. - Kaniko or img: These tools perform image builds without requiring a Docker daemon, operating directly on the filesystem. They work in unprivileged containers and are safe for public‑facing CI.
Below is a minimal Kaniko example that replaces the DinD steps from the earlier workflow:
- name: Build with Kaniko
uses: gcr.io/kaniko-project/kaniko-action@v2
with:
dockerfile: Dockerfile
context: .
destination: ghcr.io/${{ github.repository }}:$(git rev-parse --short HEAD)
The Kaniko action runs in a plain container, requires no privileged mode, and writes the resulting image directly to the registry. It also respects the CI runner’s resource limits, avoiding hidden cgroup exhaustion.
Security and Best Practices
If you must keep DinD for legacy reasons, adopt these mitigations:
- Run each DinD job on an isolated VM or dedicated self‑hosted runner to prevent cross‑project contamination.
- Explicitly prune containers, images, and volumes at the end of each run:
# Clean up everything docker system prune -af --volumes - Limit the privileged flag to the smallest possible scope by using
--cap-add=SYS_ADMINand dropping unnecessary capabilities. - Enable Docker Content Trust (DCT) and image signing to ensure that only verified images are pushed.
- Audit all third‑party dependencies for potential malicious Dockerfile instructions.
“The convenience of Docker‑in‑Docker is quickly outweighed by the operational debt it creates. Choose a safer build method before the hidden costs become a production incident.” – Cloud Security Engineer
Conclusion
Docker‑in‑Docker provides an easy way to get container builds inside CI, but it carries hidden liabilities: contaminating build environments, exhausting resources without obvious warnings, and dramatically expanding the attack surface through privileged execution. By understanding these pitfalls and migrating to socket‑bound Docker, BuildKit, or daemon‑less tools like Kaniko, teams can retain fast feedback loops while preserving security and reliability.
The key takeaway is not to dismiss DinD outright, but to recognize when its convenience becomes a silent risk and to replace it with a method that aligns with the security posture of modern CI/CD pipelines.