GitLab CI’s Docker executor lets your jobs run in isolated containers, but setting it up is less about installing software and more about orchestrating a distributed system.
Imagine you’ve got a GitLab project, and you want to automate its build and test process. Instead of running these tasks directly on a server (which can lead to dependency conflicts and a messy environment), you want each job to spin up a fresh, clean Docker container. That’s where the GitLab CI Runner with the Docker executor comes in.
Here’s a GitLab project’s .gitlab-ci.yml file that uses the Docker executor. Notice how we specify image: docker:latest to ensure the job itself runs inside a Docker container.
stages:
- build
- test
build_app:
stage: build
image: docker:latest
services:
- docker:dind
script:
- docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
run_tests:
stage: test
image: $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # Use the image built in the previous stage
script:
- echo "Running tests..."
- make test
When this pipeline runs, GitLab CI spins up a runner. This runner, configured with the Docker executor, reads the .gitlab-ci.yml. For the build_app job, it will:
- Pull the
docker:latestimage. - Start a
docker:dind(Docker-in-Docker) service container. This is crucial because you need a Docker daemon inside the job’s container to build other Docker images. - Execute the
scriptcommands within thedocker:latestcontainer. These commands log into the GitLab container registry and build/push a new Docker image based on your project’s Dockerfile. - For the
run_testsjob, it pulls the image that was just built and pushed by thebuild_appjob. - Executes the test script inside this application-specific image.
The core idea is that each job gets its own ephemeral environment. This isolation prevents "it works on my machine" syndrome and ensures reproducible builds. The runner itself doesn’t need any build tools installed on its host OS; it just needs to be able to run Docker.
The config.toml file on the GitLab Runner machine is where you define how the runner interacts with Docker. Here’s a snippet of a typical configuration:
concurrent = 4
check_interval = 0
[session_server]
session_timeout = 1800
[[runners]]
name = "docker-runner-1"
url = "https://gitlab.example.com/"
token = "YOUR_RUNNER_TOKEN"
executor = "docker"
[runners.docker]
tls_cert_path = ""
image = "alpine:latest" # The default image if not specified in .gitlab-ci.yml
privileged = true # Often needed for Docker-in-Docker
disable_cache = false
volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"] # Crucial for Docker-in-Docker
shm_size = 0
Let’s break down the key parts:
executor = "docker": This tells the runner to use the Docker executor.image = "alpine:latest": This is the default image that will be used if a job in.gitlab-ci.ymldoesn’t specify its ownimage.privileged = true: When using Docker-in-Docker (docker:dind), thedocker:dindcontainer needs elevated privileges to manage its own Docker daemon. This is a common requirement for advanced Docker executor setups.volumes = ["/var/run/docker.sock:/var/run/docker.sock"]: This is perhaps the most critical part for Docker-in-Docker. It mounts the host machine’s Docker socket into the job container. This allows the job’s Docker client to communicate with the host’s Docker daemon, enabling commands likedocker buildanddocker runwithin the job. Without this, thedocker buildcommand inside your job would fail because it wouldn’t be able to talk to a Docker daemon.volumes = ["/cache"]: This is a standard volume mount for GitLab CI’s cache, allowing artifacts to persist between jobs.
The services keyword in .gitlab-ci.yml is how you bring additional containers into your job’s environment. docker:dind is a special service that provides a Docker daemon. When you use docker:dind as a service, the runner automatically mounts the host’s Docker socket (/var/run/docker.sock) into the service container, and then makes that socket available to your job container via the DOCKER_HOST environment variable (usually set to unix:///var/run/docker.sock). This is why you don’t explicitly need to mount /var/run/docker.sock in the volumes section of the [[runners.docker]] configuration if you are using docker:dind as a service and privileged = true. However, explicitly mounting it in the runner’s config.toml is a common practice that ensures Docker commands work correctly even in slightly different configurations.
The most surprising thing about configuring the Docker executor is that you often need to grant the runner’s Docker daemon more privileges than you might expect, especially when dealing with Docker-in-Docker. The privileged = true setting in config.toml is a strong indicator of this. It essentially tells the runner to bypass certain security restrictions when launching containers, allowing them to interact more deeply with the host system’s kernel and resources. This is necessary for docker:dind to function correctly, as it needs to start its own Docker daemon and manage its own containers as if it were a separate host.
You’re now ready to explore how to optimize image caching between jobs to speed up your pipelines.