GitLab CI DinD: Docker builds with BuildKit caching

Published: 2026-03-25

Docker-in-Docker (DinD) in GitLab CI lets you build and push container images as part of your pipeline. The setup involves TLS, a Docker daemon service, and a shared volume for the TLS certificates. With BuildKit layer caching pointed at a registry, incremental builds take 30–60 seconds instead of 5 minutes.


Basic DinD setup

yaml# .gitlab-ci.yml
variables:
  DOCKER_TLS_CERTDIR: "/certs"
  DOCKER_BUILDKIT: "1"
  REGISTRY: registry.example.com

services:
  - name: docker:26-dind
    command: ["--registry-mirror", "https://mirror.example.com"]

build:
  image: docker:26
  stage: build
  before_script:
    - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$REGISTRY"
  script:
    - |
      docker buildx build \
        --cache-from type=registry,ref=${REGISTRY}/${CI_PROJECT_NAME}:cache \
        --cache-to type=registry,ref=${REGISTRY}/${CI_PROJECT_NAME}:cache,mode=max \
        --platform linux/amd64 \
        --tag ${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA} \
        --tag ${REGISTRY}/${CI_PROJECT_NAME}:latest \
        --push \
        .

The docker:dind service and docker:26 image must use the same major version. DOCKER_TLS_CERTDIR: "/certs" enables TLS between the client and daemon automatically — the client reads certs from /certs/client, the daemon writes them.


The TLS certificate problem

Without the shared DOCKER_TLS_CERTDIR, the Docker CLI can't authenticate to the daemon and you get:

Cannot connect to the Docker daemon at tcp://docker:2376.

Fix: set DOCKER_TLS_CERTDIR: "/certs" and GitLab handles the volume mount between the service and job container automatically. Both containers see /certs.

If TLS is blocking you during development, disable it with DOCKER_TLS_CERTDIR: "" and use tcp://docker:2375 — but never in production.


Registry mirror

A registry mirror is a pull-through cache for Docker Hub images. Without it, every DinD runner pulls alpine:3.20 fresh from Docker Hub, hitting rate limits and adding 30–60 seconds per pull.

yamlservices:
  - name: docker:26-dind
    command: ["--registry-mirror", "https://mirror.example.com"]

mirror.example.com is your own Registry proxy — registry:2 image configured with a proxy:

yaml# registry config.yml
proxy:
  remoteurl: https://registry-1.docker.io

BuildKit inline caching vs registry caching

Two modes for --cache-to:

inline — embeds cache metadata in the image layer. Simple but forces you to pull the full image to get cache data.

registry — stores cache as a separate manifest. The cache tag is small (just metadata + diff layers), pulled quickly. Use mode=max to cache all stages, not just the final one.

For a multi-stage .NET build:

  • restore stage rarely changes (only when .csproj changes)
  • build stage changes on every commit
  • With mode=max, the restore stage is cached even on cold runners

Multi-arch builds

yamlbefore_script:
  - docker run --privileged --rm tonistiigi/binfmt --install all
  - docker buildx create --use --name multiarch

script:
  - |
    docker buildx build \
      --platform linux/amd64,linux/arm64 \
      --cache-from type=registry,ref=${REGISTRY}/${CI_PROJECT_NAME}:cache \
      --cache-to type=registry,ref=${REGISTRY}/${CI_PROJECT_NAME}:cache,mode=max \
      --tag ${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA} \
      --push \
      .

binfmt registers QEMU emulators for non-native architectures. Expect 3–5× longer builds for the emulated architecture.


Trivy scan in the same pipeline

yamlscan:
  image:
    name: aquasec/trivy:latest
    entrypoint: [""]
  stage: scan
  needs: [build]
  script:
    - |
      trivy image \
        --format json \
        --output trivy-report.json \
        --severity HIGH,CRITICAL \
        --exit-code 1 \
        ${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA}
  artifacts:
    when: always
    reports:
      container_scanning: trivy-report.json

--exit-code 1 fails the pipeline on HIGH/CRITICAL vulnerabilities. The container_scanning artifact shows up in the GitLab MR security tab.


Kaniko as an alternative

If you can't use privileged containers (some Kubernetes setups), use Kaniko instead:

yamlbuild:
  image:
    name: gcr.io/kaniko-project/executor:debug
    entrypoint: [""]
  script:
    - |
      /kaniko/executor \
        --context . \
        --dockerfile Dockerfile \
        --destination ${REGISTRY}/${CI_PROJECT_NAME}:${CI_COMMIT_SHORT_SHA} \
        --cache=true \
        --cache-repo=${REGISTRY}/${CI_PROJECT_NAME}:cache

Kaniko doesn't require privileged mode and builds inside an unprivileged container. The tradeoff: slower than BuildKit and no multi-arch support.