skip to content

A GitLab CI job on a docker-executor runner needs to build a container image. Why does the docker:dind service require privileged mode, and what are the alternatives?

level: seniorimportance: should knowfreq 55%

answer

  1. a daemon inside a container needs the kernel back
  2. privileged is a runner setting, not a job setting
  3. the socket shortcut is worse, not safer
  4. build the image without a daemon at all

basics

~20 s

Docker-in-Docker starts a second Docker daemon inside the job's container, and a daemon needs kernel capabilities that ordinary containers lack, so the runner must be configured with privileged mode. Daemonless builders such as Buildah or rootless BuildKit avoid that by building images without a daemon.

solid answer

~50 s

The `docker:dind` service is a full Docker daemon running as a job service container. A daemon has to create namespaces, manage cgroups, and set up overlay filesystems and networking, so it needs capabilities a normal container does not get — hence `privileged = true` under `[runners.docker]` in the runner's `config.toml`. That flag applies to the whole runner, not one job, so every job routed there can effectively escape to the host: privileged means device access and, in practice, root on the machine. The alternatives are to build without a daemon — Buildah or BuildKit in rootless mode, historically Kaniko — or to mount the host's Docker socket, which is *worse*, since a job can then start containers on the host and mount anything. The usual production answer is a dedicated, tagged, non-shared runner pool for image builds, or a daemonless builder everywhere else.

code

yaml · 11 lines
yaml
build-image:
  image: docker:27
  services:
    - docker:27-dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_TLS_VERIFY: 1
    DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
  script:
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .

go deeper

for a junior

Know that building an image inside a job needs a Docker daemon, that GitLab provides one through the docker:dind service, and that this requires extra privileges the runner must grant.

for a middle

Explain why a daemon needs namespaces, cgroups and overlay mounts, how the client reaches the dind service, and what DOCKER_TLS_CERTDIR does to the connection.

for a senior

Demonstrate the blast-radius reasoning: privileged is per-runner, so any job reaching that runner can own the host. Propose segregated tagged pools, ephemeral hosts, or daemonless builders.

for a principal

Own the policy — which projects may reach privileged capacity at all, whether the organization standardizes on daemonless builds, and how that decision interacts with cluster pod-security rules and supply-chain requirements.

## What DinD actually is A GitLab job that runs `docker build` needs a Docker daemon to talk to. The `services:` mechanism gives it one: GitLab starts `docker:dind` as a sibling container on the job's network, reachable at the alias `docker`, and the build container's Docker *client* points at it. ```yaml build-image: image: docker:27 services: - docker:27-dind variables: DOCKER_HOST: tcp://docker:2376 DOCKER_TLS_CERTDIR: "/certs" DOCKER_TLS_VERIFY: 1 DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client" script: - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" . ``` The `DOCKER_TLS_CERTDIR` dance exists because modern `dind` images generate TLS certificates and expose the daemon on 2376 rather than plaintext 2375; the client and daemon must agree on where those certs live, which is why an empty `DOCKER_TLS_CERTDIR` and port 2375 is the (less safe) plaintext fallback people copy off the internet. ## Why it needs privileged mode The inner daemon is not just a process — it does everything a container runtime does: unshare namespaces, write cgroup hierarchies, mount overlayfs, create bridge interfaces and iptables rules, and load kernel modules. A default container is stripped of the capabilities and device access required for that, so the daemon fails to start. `privileged = true` restores essentially all of it. The important part for an interview is the *granularity*. Privileged is set on the runner: ```toml [[runners]] executor = "docker" [runners.docker] privileged = true ``` Not per job. Every job that this runner accepts — including one added by a merge request from an untrusted contributor — runs with those powers. A privileged container can mount the host's disk, access `/dev`, and from there read the runner's own configuration, its authentication token, and any secret material on the box. Treat "this runner is privileged" as "any pipeline that can reach this runner can own this machine." ## The worse alternative: mounting the socket A popular shortcut is to bind-mount `/var/run/docker.sock` into the job so `docker build` talks to the *host's* daemon. This avoids privileged mode and is faster because the host layer cache is warm — and it is strictly more dangerous. A job with the socket can start a container with the host root filesystem mounted, or a privileged one, with no further permission. It also breaks the mental model of the build: paths in `docker build -v` refer to the host, not the job. ## Daemonless builders The real fix is not to need a daemon. Tools that assemble OCI images from a Dockerfile in user space, pushing directly to a registry: - **Buildah** — builds and pushes without a daemon, and runs rootless with user namespaces available. - **BuildKit rootless** — the same engine `docker build` uses under the hood, run as an unprivileged process. - **Kaniko** — long the default GitLab answer; its upstream project has since been archived, so treat it as legacy and check its status before adopting. ```yaml build-image: image: name: gcr.io/kaniko-project/executor:debug entrypoint: [""] script: - /kaniko/executor --context "$CI_PROJECT_DIR" --dockerfile "$CI_PROJECT_DIR/Dockerfile" --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" ``` None of these are free: they typically need registry credentials written to a config file in the job, some Dockerfile features behave differently, and cache reuse is weaker than a warm local daemon unless you configure a registry-backed cache. ## The operational answer Most shops land on a combination: 1. **Segregate.** Image builds go to a small, tagged, project- or group-scoped runner pool that is privileged; nothing else is allowed there, and it never serves fork merge requests. 2. **Prefer daemonless** on the general-purpose shared fleet, so the shared runners never need privileged mode at all. 3. **Make the pool disposable.** If privileged is unavoidable, run those jobs on ephemeral VMs or pods that are destroyed after each job, so a compromise does not persist. On the Kubernetes executor the same tension appears as pod security: DinD needs a privileged container, which many clusters forbid by policy — which is exactly why daemonless builders became the norm there first.

  • Why is mounting /var/run/docker.sock into a job not a safer alternative to privileged DinD?
    Because the job gains control of the host's daemon, which is equivalent to root on that host: it can launch a privileged container or one that mounts the host filesystem. Privileged DinD at least confines the damage to the job's own inner daemon, though both are dangerous. Neither belongs on a runner that serves untrusted merge requests.
  • How would you let image builds happen without making your shared runner fleet privileged?
    Route them with `tags:` to a separate, small runner pool that is privileged, scoped to trusted projects and protected refs, and rebuilt per job; keep the shared fleet unprivileged. Alternatively switch the build itself to a daemonless tool such as Buildah or rootless BuildKit, which needs no privileged runner anywhere.
  • What does DOCKER_TLS_CERTDIR control in a GitLab CI job using docker:dind?
    It tells both the dind daemon and the client where the generated TLS certificates live. Set to `/certs`, the daemon serves TLS on port 2376 and the client reads `"$DOCKER_TLS_CERTDIR/client"`. Set to an empty string, TLS is disabled and the daemon listens in plaintext on 2375 — the copy-pasted configuration that quietly removes transport security.

saying these in an interview costs you the question

  • Saying privileged mode is fine because it is only the build job
  • Suggesting the Docker socket mount as the secure option
  • Assuming DinD gives a warm layer cache like the host daemon
  • Thinking privileged can be enabled per job in .gitlab-ci.yml
  • Claiming daemonless builders are drop-in with no tradeoffs

context