Home DevOps Docker with GitLab CI/CD: Build Pipelines That Don't Burn You at 3 AM
Intermediate 3 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 11, 2026
last updated
258
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.

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.

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.
● 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
4 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

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.
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 ...
Q01 of 06SENIOR

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 · 4 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?
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 11, 2026
last updated
258
articles · all by Naren
🔥

That's Docker. Mark it forged?

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

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