Docker with GitLab CI/CD: Build Pipelines That Don't Burn You at 3 AM
Docker with GitLab CI/CD done right.
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓Basic GitLab CI/CD YAML syntax
- ✓Dockerfile basics
- ✓Familiarity with Docker build and run commands
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.
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.
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.
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.
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.
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.
--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.
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.
--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.
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.
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.
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.
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.The 4GB Container That Kept Dying
--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.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.- Your build process consumes memory too.
- Always monitor build container resource limits, not just the final app.
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.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.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 infoecho $DOCKER_HOSTservices: - docker:dind and set DOCKER_HOST: tcp://docker:2375 and DOCKER_TLS_CERTDIR: ""| File | Command / Code | Purpose |
|---|---|---|
| .gitlab-ci.yml | image: node:18-alpine | Why Your Pipeline Needs Docker (And Why It Doesn't) |
| Dockerfile | FROM node:18-alpine AS builder | Layer Caching |
| .gitlab-ci.yml | image: docker:24-git | The Docker-in-Docker Trap |
| Dockerfile | FROM node:18-alpine | Secrets Management |
| .gitlab-ci.yml | image: | Kaniko Deep Dive |
| .gitlab-ci.yml | image: quay.io/buildah/stable | Buildah as Rootless Alternative |
| .gitlab-ci.yml | include: | GitLab CI Security Scanning Templates |
| .gitlab-ci.yml | workflow: | DAG Pipelines with Needs and Workflow Rules |
| .gitlab-ci.yml | cleanup_policy: | GitLab Container Registry Lifecycle |
Key takeaways
latest, for traceability and rollback.docker system prune -f in after_script to avoid disk-full failures.Interview Questions on This Topic
How does Docker layer caching work in GitLab CI, and what's the most common mistake that invalidates the cache?
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.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's Docker. Mark it forged?
6 min read · try the examples if you haven't