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:

  1. Pull the docker:latest image.
  2. 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.
  3. Execute the script commands within the docker:latest container. These commands log into the GitLab container registry and build/push a new Docker image based on your project’s Dockerfile.
  4. For the run_tests job, it pulls the image that was just built and pushed by the build_app job.
  5. 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.yml doesn’t specify its own image.
  • privileged = true: When using Docker-in-Docker (docker:dind), the docker:dind container 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 like docker build and docker run within the job. Without this, the docker build command 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.

Want structured learning?

Take the full Gitlab-ci course →