Docker 86 🐳 Docker-in-Docker (DinD) vs Docker-outside-of-Docker (DooD) in CI Runners
The choice between DinD and DooD is a choice about where the Docker daemon lives and who controls it. Docker-in-Docker runs a nested daemon inside the CI job container, giving each job its own isolated Docker environment with its own image cache, network, and storage. Docker-outside-of-Docker mounts the host’s Docker socket into the job container, so the docker commands the job runs are executed by the host daemon, sharing the host’s image cache, network, and—critically—its root privileges . The security difference is not subtle: DooD grants the job container effective root access to the host, while DinD requires --privileged mode, which disables the isolation boundaries the container runtime was supposed to provide .
The operational difference is equally stark. DinD starts with an empty image cache every time, so every docker pull and every docker build that depends on a base image downloads from the registry unless a cache volume is explicitly mounted . DooD reuses the host’s cache, so images pulled once are available to every subsequent job. GitLab’s documentation recommends DinD with TLS enabled as the default pattern, because it isolates jobs from the host and from each other, at the cost of requiring privileged = true and accepting the cold-cache penalty . The type=registry and inline cache strategies exist to mitigate the cold-cache problem by storing build layers in a registry .
This chapter covers the architecture of each approach, the GitLab CI configuration for DinD with TLS and DooD socket mounting, the security model that determines which approach is acceptable, the performance implications of the cache and network differences, and the alternative builders—Kaniko, rootless BuildKit—that avoid both the privileged requirement and the socket mount. It assumes familiarity with GitLab CI runners and Docker fundamentals.
Key point: DinD runs a daemon inside the job container; DooD mounts the host socket. DinD requires privileged = true and starts with an empty cache; DooD requires no privilege escalation but grants the job root-equivalent control of the host daemon. Neither is a security boundary for untrusted code. For untrusted workloads, use a daemonless builder like Kaniko or rootless BuildKit.
Why DinD and DooD are both problematic, and why one is usually chosen anyway
DooD is a host takeover primitive. Access to /var/run/docker.sock is equivalent to root on the host. A job that can talk to the socket can start a container with the host filesystem mounted at /host, read /etc/shadow, or modify the Docker daemon’s configuration. The socket is not an API with granular permissions; it is the control channel for a root daemon . A CI job that runs docker build on a Dockerfile from a pull request is running arbitrary code from that pull request with host-root access. This is why DooD is only acceptable when every commit that reaches the runner is trusted—an internal repository with mandatory review, or a pipeline that only runs on the main branch after merge.
DinD requires --privileged, which is also a boundary violation. A privileged container has all Linux capabilities and device access. It can load kernel modules, mount filesystems, and access host devices. The isolation that containers normally provide is disabled . The rationale is that the nested Docker daemon needs to create namespaces, mount overlay filesystems, and configure network bridges—operations that require capabilities the default seccomp and AppArmor profiles deny. The result is a job container that is effectively a small VM with root on the host kernel. A container escape from a privileged container is not a theoretical risk; it is a documented path .
The cache difference is the operational cost of isolation. DinD’s empty cache means every job pays the pull cost for every base image. A job that builds a Node.js image pulls node:18 every time, unless a volume is mounted at /var/lib/docker or a registry cache is configured . DooD’s shared cache means the image is pulled once, and every subsequent job uses the local layer. The GitLab documentation’s DinD setup mounts /certs/client for TLS certificates and relies on the registry for cache, not a persistent volume . The cold-cache penalty is real and measurable: a job that takes thirty seconds with DooD can take minutes with DinD if the base images are large and the registry is remote.
The network model differs in ways that affect test containers. DooD’s child containers are siblings on the host’s Docker network. A Testcontainers-managed PostgreSQL container is reachable from the job container by service name or IP, and from other host containers on the same bridge. DinD’s child containers are on a nested bridge inside the job container. The job container must publish ports from the inner network to its own network, or use host networking, to make the inner containers reachable . The extra layer of NAT and port mapping is a source of configuration friction that DooD avoids.
The trade-off. DooD is faster and simpler but grants host root. DinD is slower and requires privilege escalation but isolates the job’s Docker state from the host and from other jobs. Neither is safe for untrusted code. The alternative is a daemonless builder—Kaniko, Buildah, or rootless BuildKit—which builds images without a Docker daemon and without --privileged, at the cost of not being able to run arbitrary containers as part of the build .
a. GitLab CI configuration for DinD with TLS
The GitLab documentation’s recommended pattern uses the docker:dind service as a sidecar to the job container. The runner must be configured with privileged = true, and the job must set DOCKER_HOST to the service’s TLS endpoint and DOCKER_TLS_CERTDIR to the certificate directory .
# .gitlab-ci.yml
default:
image: docker:24.0.5-cli
services:
- name: docker:24.0.5-dind
alias: docker
before_script:
- docker info
variables:
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_VERIFY: 1
DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
build:
stage: build
script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
The alias: docker is the hostname the client uses to reach the service. Docker 19.03 and later use TLS by default, so the DOCKER_HOST must point to port 2376 and the certificate path must be configured . The runner’s config.toml must have privileged = true and a volume mount for /certs/client if the certificates are to persist across the job container and the service container .
For a DinD setup without TLS—which the documentation marks as less secure and only for cases where the runner configuration cannot be changed—the DOCKER_TLS_CERTDIR is set to an empty string and DOCKER_HOST points to port 2375 . The privileged mode is still required. The TLS disablement does not make the setup more secure; it only simplifies the configuration.
The runner registration command includes --docker-privileged and the volume for the certificates. The privileged = true setting is the non-negotiable requirement for DinD; without it, the nested daemon cannot start .
b. GitLab CI configuration for DooD socket mounting
DooD is configured by mounting the host’s Docker socket into the job container and setting DOCKER_HOST to the Unix socket. The runner does not need privileged = true, and the job does not need a dind service. The docker CLI inside the job container talks directly to the host daemon .
# .gitlab-ci.yml
variables:
DOCKER_HOST: unix:///var/run/docker.sock
build:
stage: build
image: docker:24.0.5-cli
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# The runner must mount the socket.
# In config.toml:
# volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
The runner’s config.toml declares the socket mount in the volumes list. The job container’s docker CLI connects to the socket, and the host daemon executes the build. The image layers are stored in the host’s cache, so the next job reuses them without a registry pull .
The security implication is the reason DooD is discouraged. A job that can mount the socket can run docker run -v /:/host --privileged alpine chroot /host and take over the host. The socket mount is a root grant. A socket proxy can restrict the API endpoints the job can call—docker-socket-proxy with environment flags for CONTAINERS=1, POST=0, for example—but it cannot filter the response bodies, and it cannot prevent a POST if the endpoint is enabled . The proxy is a mitigation, not a boundary.
c. Cache strategies and the performance gap
DinD’s cold cache is the primary performance complaint. The GitLab documentation describes two caching strategies: inline caching and registry caching. Inline caching embeds the cache metadata in the image itself with --build-arg BUILDKIT_INLINE_CACHE=1, and the next build pulls the image and uses it as a cache source with --cache-from . Registry caching with docker buildx build --cache-to type=registry stores the cache in a separate image in the registry, which scales better for multi-stage builds .
# Inline cache example for DinD.
build:
script:
- docker pull $CI_REGISTRY_IMAGE:latest || true
- docker build --build-arg BUILDKIT_INLINE_CACHE=1 --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
The || true on the pull is necessary because the image may not exist on the first build. The inline cache is stored in the pushed image, so the next build can use it without a separate cache image. The limitation is that inline caching only caches the final image layers, not the intermediate layers of a multi-stage build; the registry cache backend with mode=max is the solution for that .
The performance comparison from the development-container analysis is representative: DooD’s shared cache means container startup is sub-second for cached images, while DinD’s cold cache means 10–20 seconds per image pull on the first run. For a test suite that starts three containers per run, the difference is 30–60 seconds per CI job . The registry cache reduces the gap but does not eliminate it, because the registry pull is still a network operation.
Complete Example Session
# ============================================
# PART 1: DIND WITH TLS (RECOMMENDED)
# ============================================
# Runner config.toml
# [[runners]]
# executor = "docker"
# [runners.docker]
# privileged = true
# volumes = ["/certs/client", "/cache"]
# .gitlab-ci.yml
default:
image: docker:24.0.5-cli
services:
- name: docker:24.0.5-dind
alias: docker
before_script:
- docker info
variables:
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_VERIFY: 1
DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
build:
stage: build
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# ============================================
# PART 2: DIND WITHOUT TLS (LESS SECURE)
# ============================================
# variables:
# DOCKER_TLS_CERTDIR: ""
# DOCKER_HOST: tcp://docker:2375
#
# services:
# - name: docker:24.0.5-dind
# alias: docker
# variables:
# HEALTHCHECK_TCP_PORT: "2375"
# ============================================
# PART 3: DOOD (SOCKET MOUNT)
# ============================================
# Runner config.toml
# [[runners]]
# executor = "docker"
# [runners.docker]
# privileged = false
# volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
# .gitlab-ci.yml
# variables:
# DOCKER_HOST: unix:///var/run/docker.sock
#
# build:
# image: docker:24.0.5-cli
# script:
# - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
# ============================================
# PART 4: DINDL INLINE CACHE
# ============================================
# build:
# script:
# - docker pull $CI_REGISTRY_IMAGE:latest || true
# - docker build
# --build-arg BUILDKIT_INLINE_CACHE=1
# --cache-from $CI_REGISTRY_IMAGE:latest
# -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
# ============================================
# PART 5: DINDL REGISTRY CACHE
# ============================================
# build:
# script:
# - docker buildx create --use
# - docker buildx build
# --push
# --cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache,mode=max
# --cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache
# -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
# ============================================
# PART 6: SECURITY COMPARISON
# ============================================
# DinD: privileged = true, isolated cache, nested network.
# DooD: socket mount, host cache, host network, root-equivalent.
# ============================================
# PART 7: KANIKO (DAEMONLESS)
# ============================================
# build:
# image: gcr.io/kaniko-project/executor:latest
# script:
# - /kaniko/executor
# --context .
# --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
# No privileged mode. No socket. No daemon.
# ============================================
# PART 8: ROOTLESS BUILDKIT
# ============================================
# build:
# image: moby/buildkit:rootless
# script:
# - buildctl-daemonless.sh build
# --frontend dockerfile.v0
# --local context=.
# --output type=image,name=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA,push=true
# ============================================
# PART 9: NETWORK DIFFERENCE
# ============================================
# DooD: Testcontainers are siblings on host network.
# DinD: Testcontainers are nested; need port mapping.
# ============================================
# PART 10: THE COMPLETE FLOW
# ============================================
# 1. Trusted code + performance → DooD.
# 2. Isolation + accept privilege → DinD with TLS.
# 3. Untrusted code → Kaniko or rootless BuildKit.
# 4. DinD cache → inline or registry backend.
# 5. Never use DooD for fork PRs.
Quick Reference
DinD vs DooD
| Aspect | DinD | DooD |
|---|---|---|
| Daemon location | Inside job container | Host daemon |
| Privileged required | Yes | No |
| Image cache | Empty per job (unless volume) | Shared host cache |
| Network | Nested bridge | Host bridge |
| Security | Privileged container | Root-equivalent socket |
| Setup | dind service + TLS | Socket mount |
GitLab DinD Variables
| Variable | TLS Enabled | TLS Disabled |
|---|---|---|
DOCKER_HOST | tcp://docker:2376 | tcp://docker:2375 |
DOCKER_TLS_CERTDIR | /certs | "" |
DOCKER_TLS_VERIFY | 1 | (unset) |
DOCKER_CERT_PATH | /certs/client | (unset) |
Cache Strategies for DinD
| Strategy | Command | Use When |
|---|---|---|
| Inline | --build-arg BUILDKIT_INLINE_CACHE=1 | Single-stage, simple |
| Registry | --cache-to type=registry,mode=max | Multi-stage, complex |
Daemonless Alternatives
| Tool | Privileged | Socket | Run Containers |
|---|---|---|---|
| Kaniko | No | No | No |
| Rootless BuildKit | No | No | No |
| Buildah | No | No | Limited |
Best Practices
✅ Do This:
# Use DinD with TLS for isolated builds. // ✅
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: tcp://docker:2376
# Use registry cache for multi-stage DinD builds. // ✅
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache,mode=max
# Use DooD only for trusted code. // ✅
# Internal repo, merged commits only.
# Use Kaniko for untrusted PRs. // ✅
image: gcr.io/kaniko-project/executor:latest
❌ Don’t Do This:
# Mount docker.sock for fork PRs. // ❌
# Root-equivalent host access for untrusted code.
# Use DinD without privileged mode. // ❌
# Nested daemon cannot start.
# Assume DinD has cache without configuration. // ❌
# Every job pulls base images from scratch.
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| DinD service fails to start | Missing privileged = true | Set in runner config.toml |
| “Cannot connect to daemon” | TLS config mismatch | Match DOCKER_HOST and cert dir |
| Slow builds | Cold cache | Registry cache or inline cache |
| Testcontainers unreachable in DinD | Nested network | Publish ports or use host network |
| Host compromised via DooD | Socket mount | Use DinD or daemonless builder |
Real-World Examples
1. DinD with TLS
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_HOST: tcp://docker:2376
services: [docker:24.0.5-dind]
2. DooD Socket Mount
DOCKER_HOST: unix:///var/run/docker.sock
volumes: ["/var/run/docker.sock:/var/run/docker.sock"]
3. Inline Cache
--build-arg BUILDKIT_INLINE_CACHE=1 --cache-from $CI_REGISTRY_IMAGE:latest
4. Registry Cache
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache,mode=max
5. Kaniko Build
/kaniko/executor --context . --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
6. DinD TLS Disabled
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: tcp://docker:2375
7. Runner Privileged
[runners.docker]
privileged = true
8. Socket Proxy (Mitigation)
# docker-socket-proxy with CONTAINERS=1, POST=0
9. Testcontainers in DooD
# Child containers are siblings on host network.
10. Rootless BuildKit
image: moby/buildkit:rootless
Visual
DinD Architecture
┌──────────────────────────────────────────────┐
│ HOST │
│ │
│ ┌────────────────────────────────────┐ │
│ │ Job Container (privileged) │ │
│ │ docker CLI ──► dind service │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Nested daemon │ │
│ │ (own cache, network) │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Child containers │ │
│ └────────────────────────────────────┘ │
│ │
│ Isolation: job cannot affect host daemon. │
│ Cost: privileged, cold cache, nested network│
│ │
└──────────────────────────────────────────────┘
DooD Architecture
┌──────────────────────────────────────────────┐
│ HOST │
│ │
│ Host Docker Daemon (root) │
│ ▲ │
│ │ /var/run/docker.sock │
│ │ │
│ ┌────────────────────────────────────┐ │
│ │ Job Container (unprivileged) │ │
│ │ docker CLI ──► socket mount │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ Host daemon executes │ │
│ │ (host cache, network) │ │
│ └────────────────────────────────────┘ │
│ │
│ Risk: job can control host daemon. │
│ Benefit: shared cache, simple networking. │
│ │
└──────────────────────────────────────────────┘
Cache Difference
┌──────────────────────────────────────────────┐
│ FIRST JOB SECOND JOB │
│ │
│ DooD: DooD: │
│ pulls node:18 cache hit │
│ builds builds fast │
│ │
│ DinD: DinD: │
│ pulls node:18 pulls node:18 │
│ builds builds │
│ (cache empty) (cache empty) │
│ │
│ Registry cache mitigates DinD cold start. │
│ │
└──────────────────────────────────────────────┘
Security Boundary
┌──────────────────────────────────────────────┐
│ TRUST MODEL │
│ │
│ Trusted code (internal, merged): │
│ DooD acceptable. │
│ │
│ Untrusted code (fork PRs, external): │
│ DinD still privileged. │
│ Use Kaniko or rootless BuildKit. │
│ │
│ No socket mount for untrusted code. │
│ │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| DinD daemon | Nested inside job container |
| DooD daemon | Host daemon via socket mount |
| DinD privilege | privileged = true required |
| DooD privilege | None, but root-equivalent via socket |
| DinD cache | Empty per job (registry cache mitigates) |
| DooD cache | Shared host cache |
| DinD network | Nested bridge |
| DooD network | Host bridge |
| GitLab DinD TLS | DOCKER_HOST=tcp://docker:2376 |
| GitLab DooD | DOCKER_HOST=unix:///var/run/docker.sock |
| Untrusted code | Kaniko or rootless BuildKit |
Key takeaways:
- DinD runs a nested daemon inside the job; DooD talks to the host daemon through the socket. DinD requires
privileged = true, which disables container isolation, but keeps the job’s Docker state separate from the host. DooD requires no privilege escalation but grants the job root-equivalent control of the host . - DooD shares the host cache; DinD starts empty. The operational cost of DinD is the cold cache: every base image pull and every build layer that is not cached in a registry is re-downloaded. The registry cache backend (
--cache-to type=registry) mitigates this at the cost of a network round trip . - DinD’s network is nested; DooD’s is the host bridge. Testcontainers in DooD are siblings on the host network and reachable by service name. Testcontainers in DinD are on a nested bridge and require port publishing or host networking to be reachable .
- Neither approach is safe for untrusted code. DooD is a host takeover primitive. DinD’s privileged mode is a boundary violation that enables container escapes. For fork PRs and external contributions, use a daemonless builder like Kaniko or rootless BuildKit, which builds images without a daemon and without privilege escalation .
- GitLab’s recommended DinD pattern uses TLS. The
docker:dindservice withDOCKER_TLS_CERTDIR=/certsandDOCKER_HOST=tcp://docker:2376is the documented setup. The TLS disablement (DOCKER_TLS_CERTDIR="") is for cases where the runner configuration cannot be changed, not a security improvement . - The choice is a trust decision. DooD for trusted internal pipelines where performance and cache sharing matter. DinD for jobs that need isolation from the host daemon despite the privileged requirement. Daemonless builders for anything that runs code the operator does not control.
Remember: DinD and DooD are two ways to give a CI job access to a Docker daemon, and both compromise the security model in different ways. DinD compromises the container boundary by requiring privileged = true; DooD compromises the host boundary by mounting the root-equivalent socket. The operational trade-off is the cache: DooD shares it, DinD starts empty. The trust model decides which compromise is acceptable. For trusted code, DooD’s performance is the reason it persists. For isolated jobs, DinD’s separation is worth the privilege cost. For untrusted code, neither is acceptable, and the daemonless builders are the only safe path.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!