Home DevOps Docker with GitLab CI/CD: Build Pipelines That Don't Burn You at 3 AM
Intermediate 6 min · July 11, 2026

Docker with GitLab CI/CD: Build Pipelines That Don't Burn You at 3 AM

Docker with GitLab CI/CD done right.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
July 18, 2026
last updated
2,466
articles · all by Naren
Before you start⏱ 30 min
  • Basic GitLab CI/CD YAML syntax
  • Dockerfile basics
  • Familiarity with Docker build and run commands
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Use the docker executor in GitLab Runner, or use Docker-in-Docker (dind) with the docker:latest image. Cache layers with --cache-from and multi-stage builds. Never use latest tags in production — pin versions everywhere.

✦ Definition~90s read
What is Docker with GitLab CI/CD?

Docker with GitLab CI/CD is the practice of using Docker containers as the execution environment for GitLab CI/CD jobs, and building/pushing Docker images from those pipelines. It's the standard way to get reproducible, isolated builds that match production.

Imagine you're a chef who needs to cook the same meal in 50 different kitchens.
Plain-English First

Imagine you're a chef who needs to cook the same meal in 50 different kitchens. Docker is your portable kitchen-in-a-box — same stove, same pans, same ingredients every time. GitLab CI/CD is the head chef that tells each kitchen when to start cooking, what recipe to follow, and how to plate the result. Together, they ensure every meal comes out identical, no matter which kitchen runs it.

You've seen the email: 'Pipeline failed — no space left on device.' Or worse, the 3 AM page because a Docker build that worked locally pushed a broken image to production. Docker with GitLab CI/CD is the backbone of modern DevOps, but most teams treat it like a black box. They copy-paste a .gitlab-ci.yml from a blog, cross their fingers, and hope. That works until it doesn't — and when it breaks, it breaks hard. This article gives you the battle-tested patterns to build pipelines that don't burn you. You'll learn how to cache like a pro, avoid the Docker-in-Docker trap, and debug the failures that make juniors cry. By the end, you'll be able to design a pipeline that's fast, reliable, and actually safe for production.

Why Your Pipeline Needs Docker (And Why It Doesn't)

Docker gives you reproducibility. Your build runs in the exact same environment every time — same OS packages, same Node version, same glibc. Without it, you get the classic 'works on my machine' nightmare. But Docker isn't free. It adds complexity: you need a Docker daemon in your CI, you need to manage image caches, and you need to handle layer bloat. If your app is a simple static site that just needs npm install && npm build, you might be better off using a pre-built image with the tools you need, skipping Docker entirely. The rule: use Docker when you need to ship a container. Use a plain shell executor when you just need to run tests or lint.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — DevOps tutorial

# Minimal Docker pipeline for a Node.js app
image: node:18-alpine

services:
  - docker:dind

variables:
  DOCKER_HOST: tcp://docker:2375
  DOCKER_DRIVER: overlay2
  DOCKER_TLS_CERTDIR: ""

before_script:
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

build:
  stage: build
  script:
    - docker build --cache-from $CI_REGISTRY_IMAGE:latest -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  only:
    - main
Output
Pipeline runs, builds image, pushes to registry. No errors if cache exists.
⚠ Production Trap: Docker-in-Docker (dind) is a security risk
Running docker:dind gives your CI job root access to the Docker daemon. If your repo is compromised, an attacker can escape the container and access the host. Use docker:24-dind with TLS enabled, or better, use the docker executor with a shared socket (/var/run/docker.sock). But then you lose isolation. Trade-offs.
docker-gitlab-ci-cd THECODEFORGE.IO Docker-in-Docker vs Rootless Builders Layered architecture comparing DinD, Kaniko, and Buildah approaches CI Runner GitLab Runner | Executor (Docker/Shell) Build Environment Docker Socket | Rootless Podman | Kaniko Executor Image Builder Docker Daemon (DinD) | Buildah | Kaniko Cache Layer Registry Cache | Local Volume | Inline Cache Security Layer Root Privileges | User Namespaces | Seccomp Profiles THECODEFORGE.IO
thecodeforge.io
Docker Gitlab Ci Cd

Layer Caching: The Art of Not Rebuilding the World

Every RUN command in your Dockerfile creates a layer. If you change a file early in the Dockerfile, all subsequent layers are invalidated and rebuilt. The classic mistake: putting COPY . . before RUN npm install. That means every code change triggers a full npm install. Fix: copy package.json and package-lock.json first, run install, then copy the rest. But even then, npm install can take 30 seconds. Use --cache-from to pull the last built image and reuse its layers. GitLab CI/CD makes this easy: docker build --cache-from $CI_REGISTRY_IMAGE:latest. This cuts build times from 5 minutes to 30 seconds for a typical Node app.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — DevOps tutorial

FROM node:18-alpine AS builder
WORKDIR /app

# Copy only dependency files first to leverage cache
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Now copy the rest of the app
COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
Output
Image built with cached layers. Build time ~30s if dependencies unchanged.
💡Senior Shortcut: Multi-stage builds shrink images by 80%
Use a 'builder' stage with all build tools, then a 'runtime' stage with only the artifacts. Your final image is tiny — no compilers, no dev dependencies. For Go apps, you can go from 1GB to 10MB.

The Docker-in-Docker Trap: When Isolation Bites Back

Docker-in-Docker (dind) is the default way to build Docker images inside a GitLab CI job. You spin up a docker:dind service container, and your job talks to it via DOCKER_HOST=tcp://docker:2375. Sounds clean. But here's the catch: the dind container uses the overlay2 storage driver, which can eat disk space like candy. I've seen a single pipeline consume 50GB of disk because every build left dangling layers. The fix: run docker system prune -f in an after_script to clean up. Also, set DOCKER_DRIVER=overlay2 explicitly. Another trap: TLS. By default, dind expects TLS. If you don't set DOCKER_TLS_CERTDIR="", your job will fail with 'Cannot connect to the Docker daemon'. Always set that variable.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// io.thecodeforge — DevOps tutorial

# Safer dind configuration with cleanup
image: docker:24-git

services:
  - name: docker:24-dind
    command: ["--storage-driver", "overlay2"]

variables:
  DOCKER_HOST: tcp://docker:2375
  DOCKER_TLS_CERTDIR: ""
  DOCKER_DRIVER: overlay2

before_script:
  - docker info  # Verify daemon is reachable
  - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  after_script:
    - docker system prune -f --all --volumes  # Free disk space
  only:
    - main
Output
Pipeline builds and pushes image. After script cleans up dangling images and volumes.
⚠ Never Do This: Using `latest` tag in production
Tagging images with latest is a ticking time bomb. You lose traceability. Always tag with the commit SHA ($CI_COMMIT_SHA) and optionally a semantic version. latest should only be an alias for the most recent stable build, never the primary tag.
DinD vs Kaniko vs Buildah Trade-offs between Docker-in-Docker, Kaniko, and Buildah for CI/CD Docker-in-Docker Kaniko Privileged Mode Required (security risk) Not required Layer Caching Native Docker cache Registry-based cache Build Speed Fast with warm cache Slower initial builds Rootless Support No (needs root) Yes (fully rootless) Compatibility Full Dockerfile support Most Dockerfile features CI Integration Simple but heavy Lightweight, no daemon THECODEFORGE.IO
thecodeforge.io
Docker Gitlab Ci Cd

Secrets Management: Don't Bake Credentials Into Images

The worst thing you can do is hardcode a password in your Dockerfile. Next worst: passing it as a build arg that ends up in the image history. Use Docker build secrets (--secret) or GitLab CI/CD variables. For production, use a secrets manager like Vault or AWS Secrets Manager, and inject at runtime. Never, ever store secrets in the image. I've seen a startup's entire AWS account compromised because someone forgot to remove a .env file from the build context.

DockerfileDOCKERFILE
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — DevOps tutorial

# Use build secrets to avoid leaking credentials
FROM node:18-alpine
WORKDIR /app

# Copy only what's needed
COPY package.json package-lock.json ./
RUN npm ci --only=production

COPY . .

# Use a runtime secret, not a build arg
# The secret is mounted at /run/secrets/npm_token
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) \
    npm publish --registry https://registry.npmjs.org/ --//registry.npmjs.org/:_authToken=$NPM_TOKEN

EXPOSE 3000
CMD ["node", "dist/index.js"]
Output
Build succeeds without exposing the token in image layers.
🔥Interview Gold: How do you pass secrets to a Docker build in GitLab CI?
Use DOCKER_BUILDKIT=1 and --secret id=my_secret,env=MY_SECRET in the build command. In GitLab, set the secret as a CI/CD variable (masked), then reference it in the build script. The secret is never stored in the image layers.

Testing Your Docker Image in the Pipeline

Building an image is useless if you don't test it. The pattern: build the image, then run a container from it in the same pipeline, execute tests, and only push if tests pass. Use the docker run command with --rm to auto-clean. For integration tests, you might need to start dependent services (database, cache) as additional containers. GitLab CI's services keyword is perfect for this — it spins up containers linked to your job.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// io.thecodeforge — DevOps tutorial

# Build, test, then push
image: docker:24-git

services:
  - docker:dind
  - postgres:13-alpine  # For integration tests

variables:
  DOCKER_HOST: tcp://docker:2375
  DOCKER_TLS_CERTDIR: ""
  POSTGRES_PASSWORD: testpass
  POSTGRES_DB: testdb

stages:
  - build
  - test
  - push

build:
  stage: build
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker tag $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA $CI_REGISTRY_IMAGE:test
  artifacts:
    paths:
      - image_id.txt
  only:
    - main

test:
  stage: test
  script:
    - docker run --rm --network host -e DATABASE_URL=postgres://postgres:testpass@localhost:5432/testdb $CI_REGISTRY_IMAGE:test npm test
  only:
    - main

push:
  stage: push
  script:
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  only:
    - main
Output
Pipeline builds, runs tests against a Postgres container, then pushes if tests pass.
💡Production Trap: Network mode in dind
When using dind, the service containers are on a different network. Use --network host to access them, or use the service alias (e.g., postgres) as hostname. But --network host doesn't work on macOS runners — use --network container:postgres instead.

When Docker in CI Is Overkill

If your project is a simple library or a static site, you don't need Docker in your pipeline. Use a pre-built image with the tools you need (e.g., node:18-alpine for Node, python:3.10 for Python). Run your tests directly. Only add Docker when you need to produce a container image. Also, if your team is small and your deployment target is a PaaS like Heroku or Vercel, they handle containerization for you. Don't add complexity you don't need.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// io.thecodeforge — DevOps tutorial

# Simple pipeline without Docker for a Node.js library
image: node:18-alpine

stages:
  - test
  - publish

test:
  stage: test
  script:
    - npm ci
    - npm test
  only:
    - main

publish:
  stage: publish
  script:
    - npm ci
    - npm publish
  only:
    - tags
  except:
    - branches
Output
Pipeline runs tests and publishes to npm. No Docker overhead.
🔥When Not to Use Docker in CI
If your build time is under 2 minutes and you don't need to ship a container, skip Docker. The overhead of spinning up dind adds 10-20 seconds. For a simple lint+test pipeline, that's a 50% increase in total time.

Kaniko Deep Dive

Docker-in-Docker requires privileged mode, which is a security concern for many organizations. Kaniko is Google's answer: it builds container images from a Dockerfile without requiring access to a Docker daemon at all. It runs as an unprivileged container, making it suitable for security-constrained environments like OpenShift or GitLab shared runners where privileged mode is disabled.

Kaniko works by extracting the base image filesystem, executing each Dockerfile command in userspace, and snapshotting the filesystem after each step. It doesn't use the Docker daemon, so there's no --cache-from in the Docker sense. Instead, Kaniko has its own cache mechanism: --cache=true caches intermediate layers in the registry, and --cache-ttl=24h controls expiration.

Important context: Google archived the original Kaniko repository in 2024. The community has forked it to continue maintenance. This means new features and security patches now come from the community fork, not Google. Before adopting Kaniko today, verify which fork is actively maintained and whether it supports your BuildKit-backed features (e.g., multi-stage builds work, but cache mounts don't).

When to choose Kaniko over dind or Buildah: Kaniko wins when you absolutely cannot run privileged containers. It loses on features (no BuildKit, no cache mounts) and speed (no parallel layer execution). For most teams, dind with proper cleanup is a better balance of security and performance.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// io.thecodeforge — DevOps tutorial

# Kaniko-based Docker build (no privileged mode needed)
image:
  name: gcr.io/kaniko-project/executor:debug
  entrypoint: [""]

variables:
  DOCKER_CONFIG: /kaniko/.docker
  KANIKO_CACHE: "true"
  KANIKO_CACHE_TTL: "24h"

before_script:
  - mkdir -p /kaniko/.docker
  - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n $CI_REGISTRY_USER:$CI_REGISTRY_PASSWORD | base64)\"}}}" > /kaniko/.docker/config.json

build:
  stage: build
  script:
    - /kaniko/executor
      --context $CI_PROJECT_DIR
      --dockerfile $CI_PROJECT_DIR/Dockerfile
      --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
      --cache=true
      --cache-ttl=24h
      --skip-tls-verify=false
  only:
    - main
Output
Kaniko builds image without privileged mode. Cache stores intermediate layers in the registry with 24h TTL.
⚠ Production Trap: Kaniko Cache TTL Gotcha
Kaniko's cache TTL starts from when the layer is pushed, not when it expires in your registry. Set --cache-ttl longer than your expected build frequency. For teams building multiple times daily, 168h (7 days) is safer than 24h. If the cache expires, Kaniko rebuilds from scratch.

Buildah as Rootless Alternative

Buildah is a rootless container build tool from the Podman project. Unlike Kaniko (which runs as a single binary), Buildah provides a full command-line interface for building OCI-compliant images without a Docker daemon. It can run rootless, making it suitable for GitLab CI environments where privileged mode is restricted.

Buildah vs dind vs Kaniko: dind gives you the full Docker experience but requires privileged mode. Kaniko is truly daemonless but has a slower, less flexible build process. Buildah sits in the middle — it can run rootless, supports Dockerfiles (via buildah bud), and can directly push to OCI registries. It also supports building from Containerfiles (Podman's Dockerfile equivalent) and can mount host directories as build contexts.

However, Buildah has a steeper learning curve than dind or Kaniko. Its command syntax is different from Docker's, and some Dockerfile features are not fully supported. For teams already invested in the Podman ecosystem, Buildah is the natural choice. For Docker-native teams, the dind + proper cleanup pattern is usually simpler and better supported.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — DevOps tutorial

# Rootless Buildah build (no privileged mode)
image: quay.io/buildah/stable

variables:
  STORAGE_DRIVER: vfs
  BUILDAH_ISOLATION: chroot

before_script:
  - buildah login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY

build:
  stage: build
  script:
    - buildah bud
      -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
      -f Dockerfile .
    - buildah push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
  only:
    - main
Output
Buildah builds image rootless. Image pushed to GitLab Container Registry without needing docker:dind or privileged mode.
🔥Production Trap: Storage Driver Selection
Buildah defaults to overlay storage driver, which requires kernel support. On shared GitLab runners, VFS driver (STORAGE_DRIVER: vfs) is the safest choice. It's slower (every layer is a full copy) but works everywhere without kernel module dependencies.

GitLab CI Security Scanning Templates

GitLab provides built-in security scanning templates that you can include in your pipeline with a single include directive. These templates add SAST (Static Application Security Testing), DAST (Dynamic Application Security Testing), container scanning, dependency scanning, and secret detection jobs to your pipeline without writing any custom scanning logic.

For container images, the Container-Scanning.gitlab-ci.yml template uses Trivy or Grype internally to scan your built image for vulnerabilities. It's enabled by adding include: - template: Jobs/Container-Scanning.gitlab-ci.yml to your pipeline. The scan runs after the build stage and produces a report that GitLab displays in the merge request UI.

For deeper control, you can integrate Trivy directly as a custom job. This allows you to configure severity thresholds, fail the pipeline on critical findings, and generate SBOMs. The Trivy template integration approach gives you the same scanning as the built-in GitLab template but with more flexibility in output format and exit behavior.

Key tip: always combine container scanning with dependency scanning. A vulnerability in a base image package is just as dangerous as one in your application dependencies.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// io.thecodeforge — DevOps tutorial

# GitLab CI with built-in container scanning
include:
  - template: Jobs/Container-Scanning.gitlab-ci.yml
  - template: Jobs/DAST.gitlab-ci.yml
  - template: Jobs/SAST.gitlab-ci.yml
  - template: Jobs/Secret-Detection.gitlab-ci.yml
  - template: Jobs/Dependency-Scanning.gitlab-ci.yml

stages:
  - build
  - test
  - scan
  - deploy

variables:
  SECURE_LOG_LEVEL: info
  DS_DISABLE_TELEMETRY: "true"
  CS_IMAGE: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

build:
  stage: build
  image: docker:24-git
  services:
    - docker:dind
  variables:
    DOCKER_HOST: tcp://docker:2375
    DOCKER_TLS_CERTDIR: ""
  script:
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker build -t $CS_IMAGE .
    - docker push $CS_IMAGE
  only:
    - main

# Container scanning runs automatically with the CS_IMAGE variable set
# Fail pipeline on critical vulnerabilities
container_scanning:
  variables:
    CS_SEVERITY_THRESHOLD: critical
    CS_FEATURE_DISABLE: ""
  allow_failure: false
Output
Pipeline runs build, then container scanning auto-detects the image and scans it. Report generated in merge request. Pipeline fails if critical vulnerabilities found.
💡Senior Shortcut: Custom Trivy Integration
If GitLab's built-in container scanning is too slow or inflexible, add a custom Trivy job: image: aquasec/trivy:latest with trivy image --exit-code 1 --severity CRITICAL,HIGH $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA. This gives you more control over severity filtering and output format.

DAG Pipelines with Needs and Workflow Rules

Not all jobs in a pipeline need to run sequentially. GitLab's Directed Acyclic Graph (DAG) pipelines let you run independent jobs in parallel using the needs keyword, dramatically reducing total pipeline time. Without DAG, GitLab runs jobs stage-by-stage: all jobs in build finish before any job in test starts. With needs, you specify exact dependencies — a test job can start as soon as its build job finishes, even if other build jobs are still running.

The workflow:rules keyword prevents duplicate pipelines. The classic problem: a push to a branch triggers both a branch pipeline and a merge request pipeline. workflow:rules with if: $CI_PIPELINE_SOURCE == 'merge_request_event' ensures only the MR pipeline runs, saving CI minutes.

Multi-project pipelines let you trigger pipelines in other projects. For example, a microservice build can trigger the deployment project's pipeline. This is essential for organizations with separate repos for application code and infrastructure. Use trigger job keyword with a project path and optional branch.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// io.thecodeforge — DevOps tutorial

# DAG pipeline with needs and workflow rules

workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == "main"

stages:
  - build
  - test
  - scan
  - deploy

variables:
  IMAGE_TAG: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

build-amd64:
  stage: build
  script:
    - docker build --platform linux/amd64 -t $IMAGE_TAG-amd64 .
    - docker push $IMAGE_TAG-amd64

build-arm64:
  stage: build
  script:
    - docker build --platform linux/arm64 -t $IMAGE_TAG-arm64 .
    - docker push $IMAGE_TAG-arm64

# Runs as soon as BOTH amd64 builds finish
# Does NOT wait for arm64 build
test-amd64:
  stage: test
  needs: ["build-amd64"]
  script:
    - docker run --rm $IMAGE_TAG-amd64 npm test

# Runs in parallel with test-amd64
test-arm64:
  stage: test
  needs: ["build-arm64"]
  script:
    - docker run --rm $IMAGE_TAG-arm64 npm test

# Runs after both test jobs finish (not waiting for scan stage)
deploy:
  stage: deploy
  needs: ["test-amd64", "test-arm64"]
  script:
    - docker buildx imagetools create -t $CI_REGISTRY_IMAGE:latest $IMAGE_TAG-amd64 $IMAGE_TAG-arm64

# Multi-project pipeline trigger (separate repo)
deploy-prod:
  stage: deploy
  needs: ["test-amd64", "test-arm64"]
  trigger:
    project: myorg/deployment-manifests
    branch: main
    strategy: depend
Output
AMD64 and ARM64 builds run in parallel. Tests start as soon as their respective build finishes. Deploy only after both tests pass.
🔥Production Trap: DAG and Artifacts
When using needs, artifacts from needed jobs are automatically downloaded. But if a job needs artifacts from a job it doesn't list in needs, the artifacts won't be available. Always list all dependency jobs in needs if you need their artifacts.

GitLab Container Registry Lifecycle

The GitLab Container Registry is a Docker-compatible registry built into every GitLab project. It's convenient — no separate registry to set up — but left unmanaged, it fills up with stale images. Every pipeline push creates a new image tag. Over months, you accumulate thousands of images consuming storage and making the registry slow to browse.

GitLab's cleanup policy is the solution. It automatically deletes tags based on rules you define: keep the latest N tags (e.g., 10), delete tags older than N days (e.g., 30), delete tags matching a regex pattern (e.g., .*-dev$), and crucially, delete untagged artifacts. Untagged artifacts are dangling layers from interrupted pushes or failed cleanup jobs — they take up space but are invisible in the UI.

The deep-dive setting most teams miss: DELETE_MULTI_TAGGED_IMAGES=false (default). This prevents deletion of images that have multiple tags pointing to the same digest. For example, if latest and v1.2.3 point to the same image, setting this to true might delete the image when v1.2.3 is cleaned up, breaking the latest tag. Keep this false unless you understand the implications.

For GitLab SaaS, cleanup policies run once per day. For self-managed, you can configure the frequency. Regardless, periodically verify your policy is working by checking the registry's storage usage in the project settings.

.gitlab-ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — DevOps tutorial

# Container registry cleanup policy configuration
# Set in Project > Settings > CI/CD > Container Registry

# Example policy (configured via GitLab UI or API):
cleanup_policy:
  cadence: "0 0 * * *"  # Daily at midnight
  keep_n: 10            # Keep last 10 tags per image name
  older_than: 30d       # Delete tags older than 30 days
  name_regex_delete:
    - ".*-dev$"        # Delete all dev tags
    - ".*-test$"       # Delete all test tags
  name_regex_keep:
    - ".*-stable$"     # Always keep stable tags
    - "^v[0-9]+\.[0-9]+\.[0-9]+$"  # Keep semver tags
  delete_multi_tagged_images: false  # Don't delete images with multiple tags
  delete_untagged_artifacts: true    # Delete orphaned layers
Output
Cleanup policy runs daily. Keeps 10 most recent tags, deletes dev/test tags after 30 days, always keeps stable and semver tags. Untagged artifacts are deleted.
⚠ Production Trap: Untagged Artifact Bloat
If your pipeline crashes during docker push, you get untagged layers that consume storage. Always set delete_untagged_artifacts: true in your cleanup policy. Also run docker system prune -f in after_script to clean local layers that won't be pushed.
● Production incidentPOST-MORTEMseverity: high

The 4GB Container That Kept Dying

Symptom
A microservice container would crash every 2-3 hours with OOMKilled. No pattern. No obvious memory leak in the app.
Assumption
Team assumed a memory leak in the Node.js app. Spent two weeks profiling heap dumps.
Root cause
The CI/CD pipeline was building the Docker image with --no-cache every time, and the npm install layer was 1.2GB. The GitLab Runner's docker executor had a default memory_limit of 4GB. During build, the container ran out of memory because the build process itself (npm install + webpack) peaked at 3.8GB, leaving no room for the Docker daemon's overhead.
Fix
Set DOCKER_DRIVER=overlay2 and DOCKER_OPTS='--storage-opt dm.basesize=10G' on the runner. Also added --cache-from $CI_REGISTRY_IMAGE:latest to the build command to reuse cached layers.
Key lesson
  • Your build process consumes memory too.
  • Always monitor build container resource limits, not just the final app.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
Pipeline fails with 'Cannot connect to the Docker daemon at tcp://docker:2375. Is the docker daemon running?'
Fix
1. Check that docker:dind is listed in services. 2. Verify DOCKER_HOST is set to tcp://docker:2375. 3. Set DOCKER_TLS_CERTDIR: "" to disable TLS. 4. Ensure the dind image version matches your Docker client version.
Symptom · 02
Build succeeds but push fails with 'denied: access forbidden'
Fix
1. Check that CI_REGISTRY_USER and CI_REGISTRY_PASSWORD are set as CI/CD variables. 2. Verify the user has write access to the registry. 3. Run docker login in before_script with those variables. 4. Ensure the image tag doesn't contain invalid characters.
Symptom · 03
Container runs out of disk space during build
Fix
1. Set DOCKER_DRIVER=overlay2 to use more efficient storage. 2. Add docker system prune -f --all --volumes in after_script. 3. Increase runner disk space or set DOCKER_OPTS='--storage-opt dm.basesize=10G'. 4. Use multi-stage builds to reduce image size.
★ Docker with GitLab CI/CD Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Pipeline fails with `Cannot connect to the Docker daemon`
Immediate action
Check if dind service is running and DOCKER_HOST is set
Commands
docker info
echo $DOCKER_HOST
Fix now
Add services: - docker:dind and set DOCKER_HOST: tcp://docker:2375 and DOCKER_TLS_CERTDIR: ""
Build takes >5 minutes for a small app+
Immediate action
Check if cache is being used
Commands
docker build --cache-from $CI_REGISTRY_IMAGE:latest -t test .
docker history $CI_REGISTRY_IMAGE:latest
Fix now
Add --cache-from $CI_REGISTRY_IMAGE:latest to build command. Ensure docker login is before build.
Disk space full on runner+
Immediate action
Check disk usage
Commands
df -h
docker system df
Fix now
Add after_script: - docker system prune -f --all --volumes to your job.
Push fails with `denied: access forbidden`+
Immediate action
Check registry credentials
Commands
docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
docker push $CI_REGISTRY_IMAGE:test
Fix now
Ensure CI_REGISTRY_USER and CI_REGISTRY_PASSWORD are set as CI/CD variables (masked).
Feature / AspectDocker-in-Docker (dind)Docker Socket Binding
IsolationFull isolation — builds can't affect hostNo isolation — builds share host daemon
SecuritySafer — container escape less likelyRiskier — any job can access host daemon
PerformanceSlower — extra layer of virtualizationFaster — direct socket access
Disk UsageHigher — each build uses overlay2 storageLower — reuses host's Docker storage
Setup ComplexityHigher — need to configure dind serviceLower — just bind mount /var/run/docker.sock
⚙ Quick Reference
9 commands from this guide
FileCommand / CodePurpose
.gitlab-ci.ymlimage: node:18-alpineWhy Your Pipeline Needs Docker (And Why It Doesn't)
DockerfileFROM node:18-alpine AS builderLayer Caching
.gitlab-ci.ymlimage: docker:24-gitThe Docker-in-Docker Trap
DockerfileFROM node:18-alpineSecrets Management
.gitlab-ci.ymlimage:Kaniko Deep Dive
.gitlab-ci.ymlimage: quay.io/buildah/stableBuildah as Rootless Alternative
.gitlab-ci.ymlinclude:GitLab CI Security Scanning Templates
.gitlab-ci.ymlworkflow:DAG Pipelines with Needs and Workflow Rules
.gitlab-ci.ymlcleanup_policy:GitLab Container Registry Lifecycle

Key takeaways

1
Always tag Docker images with the commit SHA, never latest, for traceability and rollback.
2
Cache Docker layers by copying dependency files first
this cuts build times from minutes to seconds.
3
Clean up dangling images with docker system prune -f in after_script to avoid disk-full failures.
4
Docker-in-Docker gives isolation but costs performance and disk space
choose based on your trust model.
5
Kaniko and Buildah are viable alternatives to Docker-in-Docker when privileged mode is restricted
choose based on your security and performance requirements.
6
GitLab's built-in container scanning templates and cleanup policies reduce operational burden, but always verify they're configured correctly for your team's workflow.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Docker layer caching work in GitLab CI, and what's the most com...
Q02SENIOR
When would you choose Docker-in-Docker over binding the Docker socket in...
Q03SENIOR
What happens if you run out of disk space during a Docker build in GitLa...
Q04JUNIOR
What is the purpose of the `image` keyword in `.gitlab-ci.yml`?
Q05SENIOR
Your pipeline builds a Docker image, but the push step fails intermitten...
Q06SENIOR
How would you design a GitLab CI pipeline for a microservice that needs ...
Q07SENIOR
How would you implement a secure CI/CD pipeline for Docker images in Git...
Q08SENIOR
What are the security trade-offs between Docker-in-Dind, Kaniko, and Bui...
Q01 of 08SENIOR

How does Docker layer caching work in GitLab CI, and what's the most common mistake that invalidates the cache?

ANSWER
Docker caches layers based on the order of commands in the Dockerfile. The most common mistake is copying all source files before running npm install (or equivalent). This invalidates the cache on every code change, forcing a full dependency install. The fix is to copy only dependency manifests first, run install, then copy the rest.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
How do I use Docker in GitLab CI/CD?
02
What's the difference between `docker:dind` and binding the Docker socket?
03
How do I cache Docker layers in GitLab CI to speed up builds?
04
Why does my Docker build fail with 'no space left on device' in GitLab CI?
05
When should I use Kaniko instead of Docker-in-Docker in GitLab CI?
06
How do I enable container scanning in GitLab CI/CD?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
July 18, 2026
last updated
2,466
articles · all by Naren
🔥

That's Docker. Mark it forged?

6 min read · try the examples if you haven't

Previous
Docker with GitHub Actions
31 / 43 · Docker
Next
Docker Secrets Management