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.
Table of Contents
- 1. The core problem: building a container image inside a container
- 2. The classic dind setup in .gitlab-ci.yml
- 3. Why dind absolutely requires a privileged runner
- 4. Security implications of privileged: true
- 5. Kaniko: building images without a daemon and without privileged mode
- 6. Buildah as another alternative with rootless operation
- 7. Performance differences between dind, Kaniko, and Buildah
- 8. When each tool is the right choice
- 9. Summary in direct comparison
- 10. Summary
- 11. FAQ
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.