This episode dissects Docker integration in GitLab CI/CD: running jobs with custom images, the image build technique via Docker-in-Docker (DinD), then Kaniko which is more secure without a privileged daemon, all the way to automatically building and pushing images to the GitLab Container Registry.

In episode 5 you learned to manage variables and secrets in GitLab CI — using predefined variables, hiding values with masks, and fetching secrets from Vault. Now let's level up: almost every modern application is packaged as a Docker image before being shipped to environments. The question is: where is that image built? It could be on each developer's laptop, but that's prone to "works on my machine". The correct solution is to build and push images inside the GitLab pipeline — from one source code, one result.
The catch is that building an image inside CI isn't as naive as running docker build on your laptop. CI containers don't have a Docker daemon by default, and there are several techniques with different security tradeoffs. This episode dissects the two most popular techniques — Docker-in-Docker (DinD) and Kaniko — complete with their integration into the GitLab Container Registry, GitLab's built-in registry that updates every time you push.
Before building images, recall: with the Docker executor, every job runs inside a container. The image: keyword determines which container is used. Each job can use a different image depending on its needs:
build_js:
image: node:20-alpine
script:
- node --version
- npm ci
- npm run build
test_py:
image: python:3.11
script:
- python --version
- pip install -r requirements.txt
- pytestHere the build_js job runs inside the node:20-alpine container — small, lightweight, and already containing Node.js — while test_py uses python:3.11 to run pytest. The important principle: pin image versions. node:latest can change its contents at any time and break your pipeline out of nowhere. Use specific tags — node:20-alpine or even a digest — so your builds are reproducible.
Tip
Alpine-based images are far smaller than Debian variants (hundreds of MB vs tens of MB). For CI jobs created and destroyed on every run, a small image means faster startup and cheaper runner quotas. Choose the Alpine variant when the tools you need are available there.
Running jobs with custom images is how you use Docker. Now comes the turn to build your own images. The goal is clear: configure once in .gitlab-ci.yml, and every team gets exactly the same image, built from exactly the same commit. No more "let's try building it on my machine first".
There are two dominant techniques, and both are equally valid — they only differ in infrastructure requirements and risk profile.
DinD works by "tucking" a Docker daemon inside the job container. Jobs build images with docker build, exactly like on your laptop, but what executes it is the daemon inside the docker:dind service container:
build_image:
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_TLS_CERTDIR: "/certs"
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
tags:
- dockerIts advantage: the docker build, docker login, and docker push commands work as-is — familiar, fully supported, and fast for first builds because a full daemon is ready inside. But there's a price to pay: DinD requires the runner to be in privileged mode.
Warning
Privileged mode means the job container has full access to the runner host's kernel. If the DinD daemon is compromised, an attacker can take over the runner machine itself. That's why DinD should only run on dedicated runners you control yourself — never put it on a shared runner owned by another team. For most teams, the risk isn't worth the convenience.
Kaniko is Google's image build tool that works without a daemon at all. It executes Dockerfile instructions directly as a user-space process inside the container — so it doesn't need privileged mode. This makes it safe to run even on shared runners, and it's GitLab's official recommendation for building images.
build_image:
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- kaniko --context $CI_PROJECT_DIR \
--dockerfile $CI_PROJECT_DIR/Dockerfile \
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
tags:
- dockerTwo important details in the config above. First, entrypoint: [""] is overridden because the Kaniko image has a built-in entrypoint that runs the executor directly — without that override, your script won't be executed. Second, GitLab Runner automatically injects the GitLab registry credentials into the Kaniko container, so the image can be pushed straight to the GitLab Container Registry without a manual docker login — this is why the official GitLab template looks so simple.
Kaniko also supports layer caching via the --cache=true flag with --cache-repo to store intermediate layers in the registry — a concept we'll dive into in episode 8 on caching.
| Aspect | DinD | Kaniko |
|---|---|---|
| Runner requirement | Must be privileged | No privileged needed |
| Architecture | Docker daemon inside a container | User-space process without a daemon |
| Security | High risk if compromised | Safe on shared runners |
| Commands used | docker build + manual push | Single kaniko command |
| First build | Fast (full daemon) | Slightly slower |
| Layer caching | Needs BuildKit / --cache-from | --cache=true + --cache-repo |
Tip
Rule of thumb: if your runner is your own and dedicated, DinD is convenient. If the pipeline can run on shared, group, or SaaS runners — choose Kaniko. For teams just starting out, go straight to Kaniko: better security and a shorter config.
Every GitLab project has a built-in Container Registry — collaborators don't need to create their own registry. The predefined variables you should know: $CI_REGISTRY_IMAGE (the project image path, e.g. registry.gitlab.com/team/my-app), $CI_REGISTRY_USER, and $CI_REGISTRY_PASSWORD for authentication.
A common industry pattern: tag images with the commit SHA so every commit has a unique, traceable image, then add extra tags for convenience:
kaniko --context $CI_PROJECT_DIR \
--dockerfile $CI_PROJECT_DIR/Dockerfile \
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA \
--destination $CI_REGISTRY_IMAGE:$CI_COMMIT_BRANCH \
--destination $CI_REGISTRY_IMAGE:latestOnce the image is in the registry, the next stage can use it directly as image: in a job:
deploy:
stage: deploy
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
script:
- docker run --rm $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA testNotice how $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA links the entire pipeline: the build_image job builds and pushes, the deploy job pulls the exact same image from the registry. No stale images, no version guessing — everything is wired together by automatically populated GitLab variables.
Note
For production pipelines, don't tag latest from every commit push — it gets overwritten continuously and loses its meaning. Let latest follow the main branch only, for example through a rules condition that only runs that step when a commit lands on main. We already covered rules configuration in episode 4.
In this episode 6, you've learned the containerization foundations of GitLab CI/CD:
image: determines which container a job uses, and image versions must be pinned for reproducible pipelines.$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA tag links build and deploy traceably.The "build the image in CI, push to the registry, then use it in the next stage" pattern is the backbone of enterprise pipelines. But building images is only part one — other build outputs (binaries, zips, reports) also need to move between jobs in a controlled way, and sometimes you'll want to publish packages for other teams to use. In episode 7 we'll cover artifact management — how to store, transfer, and download build outputs between jobs — along with GitLab Package Registry integration for publishing npm, Maven, PyPI, Go, and Helm chart packages. See you in episode 7!