Runners, DinD, Caching and Artifacts
Docker in GitLab CI is powerful, but the configuration is full of pitfalls: the wrong runner type, insecure Docker-in-Docker, build caches that never hit, and artifacts that transfer unnecessary megabytes. Once you understand the mechanisms, you build pipelines that finish in three minutes instead of thirty.
Table of Contents
- 1. GitLab runner types: Shell, Docker and Kubernetes
- 2. Docker-in-Docker: when you need it and when you do not
- 3. Socket mounting as a DinD alternative
- 4. Enabling BuildKit in GitLab CI
- 5. Registry-based build cache for fast rebuilds
- 6. Pipeline structure for Docker builds
- 7. Using artifacts the right way
- 8. DinD vs. socket mounting compared
- 9. Security: what never belongs in the CI pipeline
- 10. Summary
- 11. FAQ
1. GitLab runner types: Shell, Docker and Kubernetes
The GitLab CI runner is the component that executes pipeline jobs. There are three important executor types. The Shell executor runs jobs directly on the runner host, with no container and no isolation. That is fast, but each job can affect the state of the next one. The Docker executor creates a container for each job from the image defined under image: and discards it after the job finishes. That gives full isolation: every job starts in a clean state. The Kubernetes executor runs jobs as pods in the cluster, which is ideal if you already have K8s infrastructure in place.
For Docker in GitLab CI, the Docker executor is the most common choice. The runner configuration in /etc/gitlab-runner/config.toml defines which privileged flag the Docker executor receives and which volumes are mounted. Privileged mode is required for Docker-in-Docker, but it is a security risk: a container with the privileged flag can escalate on the host. Self-hosted runners should run on dedicated hosts, not on the same systems that run production workloads. GitLab.com SaaS runners use isolated VMs for every job and do not have the privileged problem.
2. Docker-in-Docker: when you need it and when you do not
Docker-in-Docker (DinD) means running a Docker daemon inside a Docker container. This is necessary when a GitLab CI job needs to run Docker commands, for example to build images, and the runner container itself has no access to the host's Docker daemon. DinD requires the docker:dind service container, which provides the Docker daemon, and the privileged: true flag on the runner so the service container can use the kernel features it needs. Without privileged mode, DinD does not run.
The security implication of DinD in GitLab CI is that a privileged container has full access rights to the host kernel. Any CI job running in a privileged container can, in principle, break out of the container. That is acceptable on dedicated CI runner hosts, where the risk stays confined to that host. On hosts that run other workloads, it is not acceptable. A genuine alternative is Google's kaniko build container, which builds Docker images without a Docker daemon and without the privileged flag, though it is slower and lacks full BuildKit support.
# .gitlab-ci.yml: Docker image build with Docker-in-Docker
# Requires privileged Runner; use on dedicated CI hosts only
variables:
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_BUILDKIT: "1" # enable BuildKit for parallel stages and cache
IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
build:
stage: build
image: docker:26
services:
- name: docker:26-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
before_script:
# Login to GitLab Container Registry using built-in CI variables
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
# Build with registry cache, speeds up rebuilds significantly
- |
docker buildx build \
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache:main \
--cache-to type=registry,ref=$CI_REGISTRY_IMAGE/cache:main,mode=max \
--tag $IMAGE_TAG \
--push \
.
after_script:
- docker logout "$CI_REGISTRY"
3. Socket mounting as a DinD alternative
Socket mounting is the most common alternative to DinD in Docker GitLab CI setups. Here, the host's Docker socket (/var/run/docker.sock) is mounted as a volume into the job container. The container can then run Docker commands that execute directly on the host's Docker daemon, without running its own privileged daemon. This is simpler than DinD, but it has one important difference: all jobs share the same Docker daemon, which can lead to race conditions when images or container names collide.
The security model of socket mounting in GitLab CI is problematic: a job with access to /var/run/docker.sock can start arbitrary containers on the host, mount volumes, and thereby effectively gain root access to the host. That is at least as risky as DinD with the privileged flag. The only truly secure approach for Docker in GitLab CI on shared infrastructure is kaniko or Buildah, neither of which needs a daemon connection. On dedicated CI hosts, socket mounting is pragmatic and widely used.
# GitLab Runner config.toml: Socket mounting configuration
# Use on dedicated CI hosts only, socket access grants host root equivalence
[[runners]]
name = "docker-runner-socket"
executor = "docker"
[runners.docker]
image = "docker:26"
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
# No privileged = true needed for socket mounting
# But: any job can start arbitrary containers on the host
---
# .gitlab-ci.yml: Build job using socket-mounted Docker daemon
build-socket:
stage: build
image: docker:26
variables:
DOCKER_HOST: "unix:///var/run/docker.sock" # use host daemon via socket
DOCKER_BUILDKIT: "1"
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
# Use unique tag based on commit SHA to avoid naming conflicts between parallel jobs
- docker build --tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
# Tag as latest only on default branch
- |
if [ "$CI_COMMIT_BRANCH" = "$CI_DEFAULT_BRANCH" ]; then
docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" "$CI_REGISTRY_IMAGE:latest"
docker push "$CI_REGISTRY_IMAGE:latest"
fi
4. Enabling BuildKit in GitLab CI
BuildKit has been the default since Docker Engine 23, but it still needs to be enabled explicitly in GitLab CI jobs if the job container uses an older Docker version or the environment variable is not set. You enable it via the DOCKER_BUILDKIT=1 variable in the variables block. With BuildKit enabled, multi-stage builds are parallelized, the registry cache works with --cache-from type=registry, and mount types for secrets and caches become available. BuildKit substantially improves the build performance of Docker in GitLab CI for multi-stage builds.
An important BuildKit feature for GitLab CI is inline caching. With --build-arg BUILDKIT_INLINE_CACHE=1, cache metadata is embedded directly into the image. That lets you use an existing image as a cache source without a separate cache export: --cache-from $CI_REGISTRY_IMAGE:main then uses the already-pushed main branch image as the cache basis for feature branch builds. This is less efficient than the full registry cache export, but simpler to configure and good enough for many teams.
5. Registry-based build cache for fast rebuilds
The registry-based build cache is the most effective way to speed up Docker in GitLab CI builds. Without a cache, every Dockerfile step runs from scratch on each pipeline run: package installation, dependency downloads, compiler runs. With a registry cache, cached layers are loaded from the registry and reused, so only the steps that actually changed run again. The cache hit rate depends on how well the Dockerfile is optimized for maximum caching.
The recommended cache strategy for GitLab CI with Docker is a separate cache repository in the GitLab registry ($CI_REGISTRY_IMAGE/cache) that is populated independently of the actual images. The cache is exported with mode=max, which stores all layers from all stages, not just the final stage. Feature branch builds use the main branch cache as a starting point, and the cache is refreshed after every main branch build. This pattern ensures feature branches benefit from a warm cache without parallel branches overwriting each other.
6. Pipeline structure for Docker builds
A well-structured GitLab CI pipeline for Docker builds has clear stages with defined responsibilities: build for building the image and pushing it to the registry, test for tests that use the built container, scan for security scanning, and deploy for deployments to target environments. Each stage's job uses the image from the previous stage rather than checking out the source code again and rebuilding it. That guarantees test, scan and deploy all work with the exact same image that ends up in production.
Dependency jobs in GitLab CI control which job has to wait for which other job. Using needs: instead of dependencies: defines direct job dependencies independent of stage order. That allows independent jobs to run in parallel: the image build and static code analysis can run at the same time, with the test job waiting on both. This parallelization reduces the pipeline's overall runtime significantly, which matters more than the build time of the single Docker job.
# .gitlab-ci.yml: Complete Docker CI pipeline with caching, scanning and deployment
stages:
- build
- test
- scan
- deploy
variables:
DOCKER_BUILDKIT: "1"
IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
CACHE_IMAGE: $CI_REGISTRY_IMAGE/cache:$CI_COMMIT_REF_SLUG
build-image:
stage: build
image: docker:26
services: [docker:26-dind]
before_script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
script:
# Use branch-specific cache, fallback to main branch cache
- |
docker buildx build \
--cache-from type=registry,ref=$CI_REGISTRY_IMAGE/cache:main \
--cache-from type=registry,ref=$CACHE_IMAGE \
--cache-to type=registry,ref=$CACHE_IMAGE,mode=max \
--tag $IMAGE \
--push .
trivy-scan:
stage: scan
image: aquasec/trivy:latest
needs: [build-image]
script:
# Fail pipeline on critical CVEs; generate GitLab Security Dashboard report
- trivy image --exit-code 1 --severity CRITICAL --format gitlab $IMAGE > gl-container-scanning-report.json || true
artifacts:
reports:
container_scanning: gl-container-scanning-report.json
deploy-staging:
stage: deploy
image: alpine/k8s:1.30
needs: [build-image, trivy-scan]
script:
- kubectl set image deployment/app app=$IMAGE -n staging
- kubectl rollout status deployment/app -n staging
environment:
name: staging
only:
- main
7. Using artifacts the right way
Artifacts in GitLab CI are files a job produces and makes available to subsequent jobs or for download. The most common mistake is having too many or too large artifacts. Every artifact is compressed, written to the GitLab database, and transferred for each downstream job. If a build job saves the entire build output as an artifact, that quickly adds up to hundreds of megabytes per pipeline run, slowing down the pipeline and putting a strain on GitLab storage.
The right strategy for Docker in GitLab CI is to push images to the registry instead of storing them as artifacts. The built Docker container is the artifact: it lives in the registry, tagged with the commit SHA. Downstream jobs pull the image from the registry instead of receiving it as a file artifact. Genuine file artifacts in the pipeline should be small and targeted: test reports, coverage files, security scan reports and deployment logs. These are typically under a megabyte and valuable for GitLab's reporting mechanism.
8. DinD vs. socket mounting compared
Choosing between DinD and socket mounting in Docker GitLab CI is one of the most common configuration decisions. Both approaches have clear trade-offs.
| Criterion | Docker-in-Docker (DinD) | Socket mounting | Recommendation |
|---|---|---|---|
| Isolation | Dedicated daemon per job | Shared host daemon | DinD for isolation |
| Security | Requires privileged: true | Docker socket = root equivalence | Both on dedicated hosts only |
| Performance | Slower startup (daemon) | Faster startup | Socket for fast builds |
| TLS | TLS between job and daemon | No TLS, local socket | DinD for TLS security |
| BuildKit cache | Fully supported | Fully supported | Both equal |
The practical recommendation for Docker in GitLab CI is DinD on dedicated runner hosts with TLS enabled. Socket mounting is faster, but the shared daemon occasionally causes conflicts when parallel jobs use similar image names. With the DinD TLS configuration (DOCKER_TLS_CERTDIR: "/certs"), DinD has been considerably more secure since Docker 20.10 than it used to be.
9. Security: what never belongs in the CI pipeline
Security mistakes in GitLab CI pipelines with Docker are often trivial to avoid, yet they happen regularly. Credentials never belong in .gitlab-ci.yml or in the repository itself. GitLab CI Variables (under Settings, then CI/CD, then Variables) store secrets encrypted and mask them in logs. The $CI_REGISTRY_PASSWORD variable is always automatically available and should never end up in a file in the repository. API keys, deploy tokens and credentials for external services should be created as masked and protected variables.
Another critical security point in Docker GitLab CI is that pushed images must be scanned for known vulnerabilities before they are deployed. A Trivy scan job that runs after the build job and blocks the deploy job on critical CVEs is an important security layer. The GitLab Security Dashboard displays the scan results in a structured way when the job produces a report in the GitLab format. This requires no external scanning infrastructure: Trivy runs as a container inside the CI job and only needs registry access to the built image.
10. Summary
Using Docker in GitLab CI efficiently requires the right runner configuration, a clear decision between DinD and socket mounting, and a well-configured build cache. The Docker executor with DinD on dedicated hosts is the safest option for isolated CI jobs. BuildKit with a registry-based cache reduces build times significantly, since only the changed Dockerfile steps run again. Push images to the registry instead of storing them as artifacts, and let downstream jobs pull the image from the registry.
A good GitLab CI pipeline for Docker has clear stages: build, test, scan, deploy. Each stage's job uses the pushed image from the previous stage. Trivy scanning after the build blocks deployments on critical CVEs. Credentials never live in the pipeline definition, only in GitLab CI Variables. With these principles, a Docker GitLab CI pipeline runs fast, secure and reproducible, with no surprises in production.
Mironsoft
GitLab CI/CD, Docker pipelines and deployment automation
Slow or fragile GitLab CI pipeline?
We analyze existing GitLab CI pipelines, configure BuildKit with a registry cache, set up image scanning, and bring Docker builds down from 20 minutes to under 5 minutes.
Pipeline audit
Analysis of slow jobs, inefficient caching strategies and security gaps in GitLab CI
BuildKit optimization
Registry cache, parallel stages and Dockerfile ordering for the maximum cache hit rate
Security setup
Trivy scanning, Security Dashboard integration and secure variable management
Docker in GitLab CI: the essentials at a glance
DinD vs. socket
DinD: dedicated daemon per job, better isolation, TLS security, needs privileged. Socket: faster, but shared daemon and no job isolation.
Registry cache
--cache-from type=registry plus --cache-to mode=max. Feature branches use the main branch cache. Saves 80% of build time on cache hits.
Artifacts
Push images to the registry, not as artifacts. Artifacts only for test reports, coverage and security scan results, typically under 1 MB.
Security
Credentials only as GitLab CI Variables. Trivy scanning after the build. Critical CVEs block deploy. Runners only on dedicated hosts.
11. FAQ: Docker in GitLab CI
1DinD vs. socket mounting: main difference?
2Why does DinD need privileged: true?
3Enabling BuildKit in GitLab CI?
DOCKER_BUILDKIT: "1" in the variables block. Default since Docker Engine 23, the variable secures older images.4Registry cache in GitLab CI?
--cache-from type=registry loads layers. --cache-to mode=max writes all layers back. Feature branches use the main cache.5Store images as artifacts?
6Keeping credentials out of logs?
$CI_REGISTRY_* variables.7What is inline caching?
BUILDKIT_INLINE_CACHE=1 embeds cache metadata into the image. A pushed image can then be used as --cache-from. Simpler, but less efficient than mode=max.8Parallel jobs without naming conflicts?
$CI_COMMIT_SHORT_SHA or $CI_JOB_ID. Prevents collisions between parallel jobs.9Integrating Trivy into GitLab CI?
--format gitlab for the Security Dashboard. --exit-code 1 blocks deploy on critical CVEs.