GitLab - Running Docker-out-of-Docker in your CI
Learn how to set up Docker-out-of-Docker (DooD) in your GitLab CI/CD pipeline. This guide covers configuring your GitLab Runner to share the host's Docker socket and project files, allowing containers to run `docker run` commands.
This article is over a year old but was reviewed on July 30, 2026 — the content is still accurate.

To run Docker commands within a GitLab CI job (Docker-out-of-Docker), you need to configure your GitLab Runner to share the host's Docker socket. This is achieved by modifying the runner's /etc/gitlab-runner/config.toml file. In the [[runners.docker]] section, you must add "/var/run/docker.sock:/var/run/docker.sock" to the volumes list. This allows the CI container to communicate with the host's Docker daemon. Additionally, to share project files with nested containers, map the builds directory by adding "/builds:/builds" to the volumes as well.
At work, I'm running my CI using Docker. Each time I push changes to GitLab, a CI is running: a Docker container is started by GitLab, pulling my code and running a set of tools.
The container started by GitLab can be based on a PHP, Python, or plain Alpine Docker image. GitLab is then pulling my code inside the container and f.i. run a tool like phplint and it's all good.
For a few months now, I've been creating a Docker image to run Dagger (see Dagger.io - Using dagger to automate your CI workflows). So, here, I run into trouble: my GitLab CI Docker container should be able to run docker run ... my_dagger_image and by default, it's not possible.
But I'm in even more trouble when, in my Dagger container, I have to launch another container (one per tool).
Yes, you've guessed it: I'm faced with a situation of running Docker from inside a Docker container already running inside another container.
Let's see how to solve this problem.
Here's the payoff once the runner is configured: a CI job that runs docker run — a container starting another container — without the "Cannot connect to the Docker daemon" error you'd otherwise get.
Why It Works
- The GitLab Runner shares its own Docker socket (
/var/run/docker.sock) with the CI container, sodocker runinside the job talks to the host's Docker daemon instead of needing its own — no nested Docker-in-Docker engine to install or maintain. - Sharing the
buildsfolder too means a container started from inside the CI job can mount the same project files the job itself was checked out into — without it, paths like$CI_PROJECT_DIRwould point to a folder invisible to the new container. - It's two lines added to
config.toml, not a different CI image or a privileged Docker-in-Docker service — the technique is called Docker Socket Passthrough.
As prerequisites, you should have access to the server where your GitLab instance is running (aka the GitLab Runner) since you'll need to make changes to the configuration of the runner.
The runner configuration file
On my GitLab runner server, my current /etc/gitlab-runner/config.toml configuration defines Docker as the executor because I have this entry:
[[runners]] executor = "docker"This TOML configuration defines a runner — a worker that executes jobs — and specifies that it should use Docker as its execution environment. Runners are typically used in CI/CD systems to perform tasks in isolated containers.
So, when running my CI, first GitLab will create a Docker container. The default base image can be forced in /etc/gitlab-runner/config.toml like below or you just have to specify the base image in your project's .gitlab-ci.yml using the image tag (doc).
[[runners]] executor = "docker"
// highlight-next-line [[runners.docker]] // highlight-next-line image = "php8.4"This TOML configuration defines a single runner that uses Docker as its executor, and specifies that Docker jobs should run using the php8.4 image. It's setting up a GitLab CI runner (or similar CI system) to spin up PHP 8.4 containers when running jobs.
Understanding the process
For this part, I'll take a very basic example. For sure, it's not needed to run Docker-in-Docker for a simple "php lint" action. I'll just use that scenario to not over-complicate things.
So, based on the config.toml file here above, I can have a .gitlab-ci.yml file like the one below:
phplint: script: - find . -name "*.php" -print0 | xargs -0 -n1 php -lThis is a CI/CD configuration that runs PHP syntax validation on all PHP files in the project. It uses the find command to locate every .php file, then passes each one to PHP's built-in linter to check for syntax errors without actually executing the code.
It'll retrieve all .php files and run php -l <filename.php> for each inside a Docker container based on PHP 8.4.
Easy and straight-forward.
Running Docker-out-of-Docker
Based on the same example, we can also use an existing Docker image; like phpqa/jakzal (I've dedicated an article to it: Docker image that provides static analysis tools for PHP):
phplint: image: docker:28.1.1 variables: DOCKER_HOST: unix:///var/run/docker.sock DOCKER_TLS_CERTDIR: "" script: - docker run --rm --volume "$CI_PROJECT_DIR:/app" --workdir /app jakzal/phpqa phplint .This is a GitLab CI job that runs phplint, a PHP syntax checker, inside a Docker container. It mounts the current project directory into the container and checks all PHP files for syntax errors.
Now, to allow this, we'll need to update our GitLab Runner server configuration file:
[[runners]] executor = "docker"
[[runners.docker]] image = "php8.4"
// highlight-next-line volumes = [ ..., "/var/run/docker.sock:/var/run/docker.sock","/builds:/builds"]This configuration block sets up a GitLab Runner that uses Docker to execute jobs. It specifies that jobs should run in a PHP 8.4 container and mounts special volumes so the container can access the Docker daemon and the build files from the host machine.
Caution
The syntax volumes = [ ..., "/var/run/docker.sock:/var/run/docker.sock","/builds:/builds"] used above is to inform you to append the two values to the list you already have. So, if, right now, you've volumes = ["/cache", "/certs/client"] then append values to get volumes = ["/cache", "/certs/client", "/var/run/docker.sock:/var/run/docker.sock","/builds:/builds"]
We need to allow the Docker daemon which is running on the GitLab runner server to share its socket (the file /var/run/docker.sock) so, the GitLab CI Docker container can run docker run statements like f.i. docker run --rm --volume "$CI_PROJECT_DIR:/app" --workdir /app jakzal/phpqa phplint .
As you can see, there is also a flag --volume "$CI_PROJECT_DIR:/app". This will share the folder referenced by the variable $CI_PROJECT_DIR with the container but here, be careful.
Let's say $CI_PROJECT_DIR is initialized to /builds/your-group/your-project. The /builds/your-group/your-project is the one from your Gitlab runner server (so /builds/your-group/your-project is a folder on the server). In your GitLab CI Docker container, the /builds/your-group/your-project also exists but when you run docker run --rm --volume "$CI_PROJECT_DIR:/app" --workdir /app jakzal/phpqa phplint ., you're thus trying to share the host directory with your new container and it'll not work unless you've allowed it.
Refer to the update we've already made in the config.toml file here above. We've shared "/var/run/docker.sock:/var/run/docker.sock" to allow sharing the Docker socket but we've also shared "/builds:/builds" and here, it's to share the builds folder.
Information
This technique is called Docker Socket Passthrough.
Now, it'll work: we'll share our Docker daemon and also share the folder on the server where our codebase was pulled before running the CI.
From now, we can not only run docker run [...] statements but also share our folder with the newly created container.
Why do I need this? Let's take just one example: documentation generation. I'm pushing my documentation as a set of .md files (in a folder called documentation) then, during my CI, I'm running Quarto (see Quarto - How I Built a Self-Documenting Ecosystem for 50+ Projects) to process these Markdown files and based on my configuration (the _quarto.yml file), I can generate offline files (like .docx, .pdf, ...) but also a static HTML site. In this last situation, I'll export the static site somewhere (./public/documentation) then publish it using GitLab pages.
Conclusion
Two lines in config.toml — the Docker socket and the builds folder — are enough to turn a GitLab CI job into one that can start its own containers, without a nested Docker-in-Docker engine to install or maintain. It's the same trick that makes a Bruno CLI Docker image or a Dagger pipeline runnable straight from your CI, and it's what I now rely on to run Quarto's documentation build as a container from inside a container.
Loading comments…

