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.
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 |
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?
3 min read · try the examples if you haven't