Docker-in-Docker in GitLab CI, Properly Understood
AI generated
CI/CD
.yml
GitLab · CI/CD · Container Security
Docker-in-Docker in GitLab CI,
properly understood

The dind service shows up in nearly every GitLab CI guide on building container images, but rarely is it explained why it requires a privileged runner and what risk that carries.

18 min read docker:dind Privileged Mode Kaniko Buildah

1. The core problem: building a container image inside a container

In most runner configurations, GitLab CI jobs themselves run inside a Docker container started from the specified image value. If that job now needs to build a Docker image of its own, say the application image for the next deploy step, the immediate question is which Docker daemon actually performs that build. The job container itself normally contains no running daemon, only the docker client, and a build absolutely requires a reachable daemon to execute the build steps.

Docker-in-Docker, dind for short, solves this chicken-and-egg problem by starting a second, fully-fledged Docker daemon as its own service inside the job environment, against which the docker client in the actual job container then directs its commands. From the job's perspective this looks like a perfectly ordinary docker build, but under the hood the client is talking over the network to a separate daemon process running in its own service container, spun up specifically for that one job.

2. The classic dind setup in .gitlab-ci.yml

In practice, dind is wired in through the services directive, which starts an additional container alongside the job's main image and connects it to the job container over GitLab's internal Docker network. The classic combination is the docker client image for the job with docker:dind as a service, plus the DOCKER_HOST environment variable telling the client where to reach the daemon inside the service container, since by default it would look for a local socket that simply does not exist inside the job container.

An often-overlooked detail is the DOCKER_TLS_CERTDIR variable: since newer dind versions, TLS between client and daemon is enabled by default, which requires additional certificate paths shared between the job and the service. If this variable is set incorrectly or omitted entirely, the connection usually fails with cryptic TLS error messages that at first glance seem to have nothing to do with certificates.


build_image:
  stage: build
  image: docker:27
  services:
    - docker:27-dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_TLS_VERIFY: "1"
    DOCKER_CERT_PATH: "/certs/client"
  before_script:
    - docker info
  script:
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

3. Why dind absolutely requires a privileged runner

A Docker daemon needs deep kernel access to function normally: it creates network namespaces, manages cgroups for resource limits, mounts overlay filesystems, and manipulates network rules. All of that normally requires root privileges and access to device nodes like /dev, which a regular, unprivileged container specifically does not have for security reasons. A dind service, itself running inside a container, therefore needs the same elevated access as a Docker daemon running on a real host.

That is exactly why the GitLab Runner Docker executor must be configured with privileged = true, so the service container is even capable of starting a functioning daemon of its own. This setting lives in the runner's config.toml under the runners.docker section and is not something that can be toggled per job in .gitlab-ci.yml. A runner that is not privileged simply cannot execute dind jobs at all, no matter how the pipeline is configured.


# Excerpt from /etc/gitlab-runner/config.toml
[[runners]]
  name = "docker-runner"
  executor = "docker"
  [runners.docker]
    image = "docker:27"
    privileged = true
    volumes = ["/certs/client", "/cache"]

4. Security implications of privileged: true

A privileged container is, in practice, no longer isolated from the host system. It has access to every device node on the host, can load kernel modules, and in many cases can be used to escape the container if an attacker manages to run arbitrary code inside it. For a GitLab runner this means concretely: anyone able to run arbitrary code in a pipeline, for example through a compromised dependency in a build script, may under certain conditions gain access to the entire runner host, not just the isolated job container.

This risk becomes especially critical on Shared Runners, where pipelines from external or less trusted projects run. A malicious or compromised pipeline from one project could, in theory, attempt to escape the privileged container and interfere with other jobs on the same host or exfiltrate host resources. For that reason, both GitLab itself and common security guidelines recommend never offering privileged runners as Shared Runners for arbitrary, untrusted projects, but instead restricting them to dedicated, trusted projects only.

5. Kaniko: building images without a daemon and without privileged mode

Kaniko, a tool developed by Google, solves the underlying problem differently: instead of emulating a full Docker daemon, Kaniko interprets the Dockerfile itself in userspace and builds the resulting image layer by layer directly on the container's filesystem, without relying on privileged kernel features like overlay mounts. That lets Kaniko run inside a perfectly ordinary, unprivileged GitLab CI job, eliminating the security risk of a privileged runner entirely.

The Kaniko executor is typically used directly as the job image and expects the build context and target registry as command-line arguments. An important practical difference from docker build is that Kaniko does not simulate an interactive docker client call, it builds in one pass and pushes directly to the target registry, without a separate docker push step.


build_image_kaniko:
  stage: build
  image:
    name: gcr.io/kaniko-project/executor:v1.23.0-debug
    entrypoint: [""]
  script:
    - mkdir -p /kaniko/.docker
    - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf "%s:%s" "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json
    - /kaniko/executor
      --context "$CI_PROJECT_DIR"
      --dockerfile "$CI_PROJECT_DIR/Dockerfile"
      --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

6. Buildah as another alternative with rootless operation

Buildah, originally from the Red Hat container ecosystem, takes an approach similar to Kaniko but additionally offers a fine-grained command line that lets individual build steps, such as creating a new container, running commands inside it, and committing it as a new layer, be controlled separately, instead of requiring a complete Dockerfile interpreted in one pass. This is especially interesting for complex build pipelines that want to assemble layers dynamically without writing a rigid Dockerfile.

Buildah can operate both with and without root privileges, and its rootless mode is particularly relevant for GitLab CI environments, since it works entirely without a privileged container as long as the underlying kernel supports user namespaces. In practice, that means a regular, unprivileged runner is sufficient, which makes Buildah one of the few options that also allows more interactive layer manipulation beyond plain building, without carrying dind's security downsides.


# Rootless Buildah in a GitLab CI job (excerpt from script:)
buildah bud --isolation chroot -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
buildah push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"

7. Performance differences between dind, Kaniko, and Buildah

In pure build-speed tests, dind often comes out slightly ahead, since it relies on Docker's mature, heavily optimized build cache mechanism that has been tuned over years for common Dockerfile patterns. Kaniko also implements caching, for example through a dedicated cache registry, but does not always reach the same hit rate as the native Docker layer cache, especially with more complex multi-stage builds that have many intermediate steps.

Taken as a whole, though, this speed advantage of dind quickly shrinks once you factor in the effort of safely provisioning privileged runners: dedicated, isolated runner pools, additional network segmentation, and separate monitoring for these security-critical machines. For most teams, the security gain of a rootless-capable tool like Kaniko or Buildah clearly outweighs the modest speed loss, especially where Shared Runners are involved.

8. When each tool is the right choice

For teams with full control over their own isolated runner infrastructure and high trust in every pipeline author, dind can still be a reasonable choice, especially when a lot of existing tooling and Docker client expertise is already in place and complex BuildKit features like multi-platform builds are needed, which Kaniko only supports in a limited way. The important part is strictly separating the privileged runner from any shared-runner pool and offering it exclusively to trusted, internally controlled projects.

For everything else, especially Shared Runners, multi-tenant environments, or generally a desire for a minimal attack surface, Kaniko is usually the better default because of its simpler integration, while Buildah shines where finer control over individual build steps or rootless operation with the full feature set of a classic container build tool is needed.

9. Summary in direct comparison

All three approaches achieve the same goal, building a container image inside a GitLab CI pipeline, but differ fundamentally in how much kernel access they need for it and what security trade-offs come with that. The choice should never be made on speed alone, but always in the context of how trusted the pipeline authors are and whether the runner operates in a shared or a dedicated environment.

The table below compares the three options against the most important decision criteria to make the choice easier for your own setup.

Tool Privileged runner needed Build cache quality Recommended for
docker:dind Yes, mandatory Very good, native Docker cache Dedicated, trusted runners
Kaniko No Good, via cache registry Shared Runners, multi-tenant
Buildah (rootless) No (with user-namespace support) Good, configurable Fine-grained layer control
Buildah (root) No, but a root process Good, configurable Legacy kernels without namespace support

Mironsoft

CI/CD pipelines, zero-downtime deployments and release automation

Deployments that run without downtime and without the nail-biting?

We review existing GitLab pipelines for fragile deployment steps and missing safeguards, then build a release process with zero-downtime deployments, automated checks and a rollback you can actually trust in an emergency.

Pipeline Review

Checking an existing .gitlab-ci.yml for fragility, missing stages and security gaps.

Zero-Downtime Deployment

Building symlink releases, health checks and rollback strategies for Magento stores.

CI/CD Automation

Connecting tests, security scans and deployments into one reliable pipeline.

10. Summary

Docker-in-Docker: Key Takeaways

dind principle

A second Docker daemon runs as a service container alongside the actual job.

Privileged mode

dind needs kernel access like a real host, hence a mandatory privileged runner.

Kaniko

Builds images in userspace without a daemon, runs inside unprivileged jobs.

Buildah

Rootless-capable with fine-grained control over individual build steps.

11. FAQ: Docker-in-Docker: Key Takeaways

1Why isn't the docker client in the job container enough on its own?
The client only forwards commands to a running Docker daemon, it does not perform builds itself. Without a reachable daemon, whether via a dind service or an externally mounted host socket, docker build cannot work at all.
2Is mounting the host Docker socket safer than dind?
No, mounting /var/run/docker.sock into the job container tends to be even riskier than dind, since the job then has direct access to the host daemon itself and could theoretically start or manipulate arbitrary containers on the host.
3Can I use Kaniko for multi-stage Dockerfiles too?
Yes, Kaniko fully supports multi-stage builds since it interprets the Dockerfile format itself. Some very new BuildKit-specific syntax extensions, however, are not always supported immediately.
4Does rootless Buildah really need no special runner privileges?
As long as the runner host's kernel supports user namespaces, which is standard on modern Linux distributions, Buildah can run in rootless mode inside a perfectly ordinary, unprivileged job.
5What happens if DOCKER_TLS_CERTDIR is set incorrectly?
The docker client in the job container then cannot establish a TLS connection to the dind service and the connection fails with a certificate error message, even though the actual problem is an incorrect or missing variable.
6Is Kaniko slower than docker build with dind?
In many cases somewhat slower, especially for complex multi-stage builds with many intermediate layers, since Docker's native cache mechanism is more mature. The difference is usually small compared to the security gain.
7Can I use dind with Shared Runners on GitLab.com?
The Shared Runners provided by GitLab.com support dind through predefined service templates, but with the general security caveats of a privileged container that should be kept in mind when using them.
8Does Kaniko support private registries with custom certificates?
Yes, through the JSON-format Docker config that Kaniko expects in the same format as a regular Docker client, private registries with their own credentials and certificates can be configured as well.
9What is the main practical difference between Kaniko and Buildah?
Kaniko focuses purely on building from an existing Dockerfile and is simpler to integrate, while Buildah offers a fine-grained command line for individual build steps and suits dynamic, script-driven image creation better.
10Do I need to make my entire runner pool privileged if I need dind for one project?
No, it is recommended to configure a separate, dedicated runner with privileged: true only for the projects that genuinely need dind, while keeping all other runners unprivileged.