GitLab CI Docker Workflows
Learn GitLab CI Docker pipelines, per-job and global images, Docker-in-Docker, Kaniko, GitLab Container Registry authentication, Docker Hub pushes, tagging strategies, and a complete build and push example.
Docker and GitLab CI/CD are a natural pair. GitLab jobs often run inside containers, and many delivery workflows also need to build Docker images, tag them, and push them to a registry. That creates two related but different concepts that beginners sometimes confuse:
- using a Docker image as the environment for a CI job
- building a new Docker image inside the CI job itself
Understanding both is crucial if you want to design reliable GitLab CI Docker pipelines. This tutorial explains how the image keyword works, how to build and push images, why Docker-in-Docker exists, when it is risky, when Kaniko is a better alternative, and how to authenticate to the GitLab Container Registry and Docker Hub.
Using Docker Images in CI Jobs with image
The simplest Docker feature in GitLab CI is the image keyword. It tells GitLab which container image to use as the runtime environment for a job.
lint:
image: node:20
script:
- npm ci
- npm run lint
In this example, the job itself runs inside the node:20 image. That means Node.js and npm are already available in the execution environment.
This pattern is ideal because it avoids installing language runtimes by hand during every pipeline run. Instead, the job starts from a purpose-built container image.
Why this matters
Different jobs often need different tools:
- Node.js for frontend tests
- Python for backend checks
- Docker CLI for image builds
- Terraform for infrastructure validation
By selecting the right image per job, you keep pipelines clean, reproducible, and easier to maintain.
Global Default Image vs Per-Job Image
GitLab lets you define an image globally or on individual jobs.
Global default image
default:
image: node:20
build:
script:
- npm ci
- npm run build
test:
script:
- npm ci
- npm test
This is convenient when most jobs use the same runtime.
Per-job image
default:
image: node:20
terraform-validate:
image: hashicorp/terraform:1.9
script:
- terraform validate
Use a per-job image when a specific job needs different tooling. This keeps each job minimal and precise.
When to choose each approach
- use a global image when most jobs share the same runtime
- use a per-job image when a job needs specialized tools
- mix both for a clean and flexible pipeline
This design is common in mature GitLab CI Dockerfile workflows because it reduces repetition without sacrificing clarity.
Pulling Images from Docker Hub, GitLab Container Registry, and Private Registries
The image keyword can point to multiple registry sources.
Docker Hub
image: python:3.12
This is the default experience most developers know.
GitLab Container Registry
image: registry.gitlab.com/my-group/my-project/build-image:latest
This is useful when your team publishes internal base images, such as a hardened Node.js image with preinstalled tools.
Private registries
You can also pull from private registries if the runner or job has permission. Depending on your setup, that may involve runner-level Docker authentication, predefined variables, or cloud-specific registry login steps.
For enterprise DevOps teams, internal base images are often the best practice because they centralize patching, security hardening, and tool standardization.
Building a Docker Image in CI: The Real Challenge
Running a job inside a Docker image is easy. Building a new Docker image in that job is trickier.
Why? Because docker build expects access to a Docker daemon. If your GitLab job already runs in a container, it does not automatically have a Docker daemon inside it.
That is the core challenge behind GitLab CI build Docker image pipelines: Docker usually needs Docker.
This leads to the classic Docker-in-Docker pattern.
Docker-in-Docker (DinD): How It Works
Docker-in-Docker, often called DinD or dind GitLab, means your CI job talks to a separate Docker daemon running as a service container.
A standard GitLab example looks like this:
build-image:
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
script:
- docker version
- docker build -t myapp:$CI_COMMIT_SHA .
What is happening here?
image: docker:27gives the job the Docker CLI.services: docker:27-dindstarts a sibling service container running the Docker daemon.DOCKER_HOSTtells the Docker CLI where the daemon is.DOCKER_TLS_CERTDIR: ""disables the default TLS directory behavior for simpler examples.
Now the job container can execute docker build, docker tag, and docker push against the daemon in the service container.
Why DinD Is Popular
DinD remains common because it is familiar. Many developers already know local Docker commands, so reusing them in CI feels natural. It supports a wide range of image build tasks, including:
docker builddocker pushdocker login- multi-stage target builds
- standard tagging strategies
For many teams, especially those starting with GitLab CI Docker build push workflows, DinD is the first solution they learn.
Security Concerns with Docker-in-Docker
DinD is useful, but it is not always the safest choice. Many DinD setups require privileged mode on the runner. Privileged mode grants broad kernel capabilities and weakens isolation.
Why is that concerning?
- a compromised build container has more power
- privileged mode increases the blast radius of malicious or buggy jobs
- production-grade multi-tenant environments should be extra careful
This does not mean DinD is always wrong. It means you should use it deliberately.
When to avoid DinD
Consider alternatives when:
- you run untrusted workloads
- you want stronger isolation
- you do not want privileged runners
- your security team restricts Docker daemon access
- you only need to build container images, not run arbitrary Docker workflows
That is where Kaniko becomes attractive.
Kaniko as a DinD Alternative
Kaniko is a tool designed to build container images from a Dockerfile without requiring a Docker daemon. That makes it a strong option for GitLab CI Kaniko pipelines.
A basic Kaniko job looks like this:
build-image-kaniko:
image:
name: gcr.io/kaniko-project/executor:latest
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo '{"auths":{"'$CI_REGISTRY'":{"username":"'$CI_REGISTRY_USER'","password":"'$CI_REGISTRY_PASSWORD'"}}}' > /kaniko/.docker/config.json
- /kaniko/executor --context "$CI_PROJECT_DIR" --dockerfile "$CI_PROJECT_DIR/Dockerfile" --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
Why teams like Kaniko
- no Docker daemon required
- works well in containerized CI environments
- avoids some privileged-mode concerns
- purpose-built for image building and pushing
Tradeoffs
- different operational model than standard Docker CLI
- some advanced Docker workflows may need adjustment
- teams must learn Kaniko-specific commands and debugging patterns
If your main goal is just to build and push images securely, Kaniko is often a strong fit.
Pushing to GitLab Container Registry
The GitLab Container Registry integrates naturally with GitLab CI/CD. GitLab exposes predefined variables such as:
CI_REGISTRYCI_REGISTRY_IMAGECI_REGISTRY_USERCI_REGISTRY_PASSWORD
These make GitLab CI push Docker image workflows straightforward.
Docker login example
script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
This works especially well when your source code and image destination both live in GitLab.
Why the GitLab registry is convenient
- built into the platform
- auth variables are already available in CI
- image history stays close to repository history
- good fit for GitLab-native release automation
Pushing to Docker Hub
You may also need to push to Docker Hub. In that case, store your credentials as masked CI/CD variables such as:
DOCKERHUB_USERNAMEDOCKERHUB_PASSWORD
Then authenticate during the job:
script:
- echo "$DOCKERHUB_PASSWORD" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- docker build -t myorg/myapp:$CI_COMMIT_SHA .
- docker push myorg/myapp:$CI_COMMIT_SHA
Best practices:
- keep credentials masked and protected
- scope access tokens to the minimum required permissions
- avoid personal passwords when registry tokens are available
Tagging Strategies for Docker Images in GitLab CI
Good image tags make releases traceable and predictable. A few common strategies are especially useful.
Commit SHA tag
docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
This is the most traceable tag because it points to an exact commit.
Git tag release
docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" .
This is ideal for versioned releases such as v1.4.0.
Latest tag
docker tag "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" "$CI_REGISTRY_IMAGE:latest"
This is convenient, but should not be your only tag because latest is mutable and less auditable.
Recommended strategy
In many real pipelines, push more than one tag:
- immutable
CI_COMMIT_SHA - semantic version tag when building a release
latestfor convenience if your deployment process expects it
This gives you both usability and traceability.
Multi-Stage Builds and --target
If your Dockerfile uses multi-stage builds, GitLab CI can build specific stages with --target.
Example Dockerfile:
FROM node:20 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS runtime
COPY --from=build /app/dist /usr/share/nginx/html
In CI, you might build only the intermediate stage for testing or caching purposes:
docker build --target build -t myapp-build:$CI_COMMIT_SHA .
Or build the full runtime image for publication:
docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" .
This is useful when your pipeline separates validation from release packaging.
Complete Working Example: Build, Tag, and Push to GitLab Container Registry
Here is a full GitLab CI Docker build push example using DinD.
stages:
- test
- build
default:
image: node:20
unit-tests:
stage: test
script:
- npm ci
- npm test
build-and-push-image:
stage: build
image: docker:27
services:
- docker:27-dind
variables:
DOCKER_HOST: tcp://docker:2375
DOCKER_TLS_CERTDIR: ""
before_script:
- echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
script:
- docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" -t "$CI_REGISTRY_IMAGE:latest" .
- docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA"
- docker push "$CI_REGISTRY_IMAGE:latest"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
How it works
unit-testsruns in a normal Node.js image.build-and-push-imageswitches to the Docker CLI image.- the DinD service provides a Docker daemon.
- GitLab registry credentials come from predefined variables.
- the image is built once and tagged twice.
- both the immutable SHA tag and the convenience
latesttag are pushed. - the job runs only on
main.
This is a practical, production-style starting point for many teams.
Example with Kaniko Instead of DinD
If you want to avoid privileged DinD, here is a concise Kaniko version:
build-and-push-image:
stage: build
image:
name: gcr.io/kaniko-project/executor:latest
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo '{"auths":{"'$CI_REGISTRY'":{"username":"'$CI_REGISTRY_USER'","password":"'$CI_REGISTRY_PASSWORD'"}}}' > /kaniko/.docker/config.json
- /kaniko/executor --context "$CI_PROJECT_DIR" --dockerfile "$CI_PROJECT_DIR/Dockerfile" --destination "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHA" --destination "$CI_REGISTRY_IMAGE:latest"
rules:
- if: '$CI_COMMIT_BRANCH == "main"'
This is often easier to justify in security-conscious environments.
Common Mistakes to Avoid
- Confusing job image with built image.
image:defines the runtime for the CI job. It does not publish your app image. - Using DinD without understanding privileged mode. Security tradeoffs matter.
- Pushing only
latest. You lose precise traceability. - Hardcoding registry credentials. Use CI variables.
- Skipping test stages before image publication. Broken images are still broken releases.
- Ignoring multi-stage builds. They often reduce image size and improve security.
Final Takeaway
GitLab CI and Docker work beautifully together when you separate the concerns clearly. Use the image keyword to choose the right runtime for each job. Use DinD when you need a familiar Docker CLI workflow and accept the security implications. Use Kaniko when you want a daemonless image build approach. Authenticate safely to the GitLab Container Registry or Docker Hub with CI variables, tag images in a traceable way, and keep your Docker pipelines honest by testing before you publish.
Knowledge Check
Question 1: The image Keyword
What does the image keyword do in a GitLab CI job?
Question 2: DinD vs Kaniko
Why do some teams prefer Kaniko over Docker-in-Docker for GitLab CI image builds?
Question 3: Tagging Strategy
Which image tagging approach gives the best traceability in a GitLab CI pipeline?