Dagger.io - Using dagger to automate your CI workflows
Automate your CI/CD workflows with Dagger.io and Python. Quickly run code quality tools (Pylint, Black, Ruff) and test your entire pipeline locally to simplify integration.
Changelog

This article walks through "daggerizing" a Python project with Dagger.io so CI steps (Pylint, Black, mypy, Ruff) can be defined once in Python functions and run identically both locally (via a Dockerized dagger CLI, wrapped in make lint/make format targets) and in a remote CI like GitLab — eliminating the slow push-and-pray loop of debugging a .gitlab-ci.yml only on the server. It covers dagger init, writing pipeline functions, running them concurrently with run-all, and configuring the GitLab runner to share the Docker socket.
Be careful, it's a bomb. Docker has revolutionized the world; let's not be afraid to say it loud and clear, and most probably Dagger.io, created by the same people as Docker, will follow in its footsteps.
Dagger.io aims to be a tool that lets you execute steps of a workflow in exactly the same way as a CI/CD (Continuous Integration / Continuous Delivery) system like the ones of GitHub, GitLab, Jenkins, ... does.
But what is a CI? It's a step carried out by your server once you've pushed a new version of your project. During a CI, you can validate the syntax of your code, make sure it conforms (e.g. to formatting rules), run code quality analysis tools such as checking that you don't have any undeclared or untyped variables or dead code (e.g. a function you no longer use).
During a CI, you can also launch your unit tests and run them each time you push out a new version of your project.
The aim of the CI is to ... crash as soon as something isn't in the expected state; e.g. you've forgotten a ;, you've got a violation (spaces used instead of tabs), a unit test no longer works, etc. So the aim of CI is to guarantee that your code is excellent.
The Result: A Working Pipeline, No YAML, No Push
Once Dagger is wired into a small Python project, dagger init scaffolds a working pipeline in about two minutes — no CI server involved, no .gitlab-ci.yml to push and wait on:
And the pipeline is immediately callable, right there on your machine:
Two sample functions (container-echo, grep-dir) already exist out of the box — proof that a Dagger pipeline is just Python functions you can run locally. The rest of this article turns those samples into real Pylint/Black/mypy/Ruff steps, then wires the exact same pipeline into a GitLab CI.
Why It Works
- CI steps are plain Python functions — no YAML DSL to learn, no syntax that only exists on the server.
- The exact same function runs on your laptop and inside GitLab/GitHub: same container, same output, no more surprises after a push.
- Dagger runs everything inside a container it controls, so the tools your pipeline needs (Pylint, Black, mypy, Ruff, ...) are pinned and reproducible, not "whatever happens to be on the runner."
- Functions compose: call one to lint, or call a
run-allfunction to fire lint/format/type-check/ruff concurrently and stop at the first failure. - The old push → wait → read the log → guess → push again loop can be exercised entirely offline, before anything ever reaches the server.
Installation
During this tutorial, we'll daggerize a repository, i.e. we'll start from zero, create a small Python script, create a Dagger Docker image, then initialize our project to use Dagger.
Let's create a temporary folder for Dagger and jump in it: mkdir /tmp/dagger && cd $_.
Please create there a Python script in a subfolder src and let's call it src/main.py:
if random.randint(0, 1) == 0: print("Hello world")else: print("Bonjour le monde!")This code flips a coin by generating a random number that's either 0 or 1, then prints a greeting in English if it's 0, or in French if it's 1. It's a simple way to randomly choose between two different messages.
Amazing application to tell us, random, Hello world or Bonjour le monde!
Our objectives
Every time we will push our codebase to our versioning application (like GitLab), we want to:
- Run Pylint, Lint python scripts using Pylint - Run analyses your code without actually running it,
- Run Black, Format the script using Black,
- Run Mypy, Mypy is a program that will type check your Python code and
- Run Ruff, an extremely fast Python linter and code formatter
(I've covered these four tools in detail in Python - Code Quality tools.)
These steps are fired in our CI (GitLab, GitHub, ...) every time we push our code and, to do the same actions locally, we need to create f.i. some make actions (make lint, make format, ...).
We want Dagger

No surprise there; at some point we have to install Dagger. Install? Ouch no; we're not going to install it because, being Docker lovers, we're going to use Docker and create our Dagger image.
Please create a new subfolder called .docker and in that folder, a file called Dockerfile:
# cspell:disable
FROM python:3.11-slim-buster AS base
# Dagger is using the Docker technology so we need to install Docker in the image# Also install required Linux dependencies# hadolint ignore=DL3008RUN set -e -x \ && apt-get update -yqq \ && apt-get install -y --no-install-recommends docker.io wget \ && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/*
# Install DaggerARG VERSION=0.15.1ARG ARCHIVE=dagger_v${VERSION}_linux_amd64.tar.gzARG URL=https://github.com/dagger/dagger/releases/download/v${VERSION}/${ARCHIVE}
# hadolint ignore=DL3003RUN set -e -x \ && cd /tmp \ && wget --quiet --output-document ${ARCHIVE} ${URL} \ && tar xvfz ${ARCHIVE} \ && mv dagger /usr/local/bin/ \ && rm -rf /tmp/
WORKDIR /app/src
ENTRYPOINT [ "/usr/local/bin/dagger" ]This Dockerfile creates a Python 3.11 base image that installs Docker and the Dagger CLI tool. It downloads a specific version of Dagger from GitHub, extracts it, and places it in the system path so that running the container automatically starts Dagger.
We need to build our image so let's run docker build -t dagger_daemon -f .docker/Dockerfile .
Daggerize our application
As said above, we need to create some stuff to daggerize our application.
To do this, we have to run the dagger init command and since we're using a Docker image where dagger is the entry point, the command to start is: docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon init --sdk=python --source=./.pipeline
Let's understand this long command line:
- by using
-itwe will interact (if needed) with the container and we'll allocate a TTY terminal i.e. get the output of running command just like if we've started it on our machine, - you've to share your local
/var/run/docker.sockwith the container because Dagger will use Docker-out-of-Docker (akaDooD) and for this reason, the container should be able to interact with your instance of Docker (-v /var/run/docker.sock:/var/run/docker.sock) and - you've to mount your local current folder with the container (
-v .:/app/src). dagger_daemonis the name of our imageinit --sdk=python --source=./.pipelineis the Dagger command to start
It'll take around two minutes to download and initialize Dagger (for the first time). By looking at your file system, you'll see, oh, the owner is root and not you — that's the file listing shown above.
Please run sudo chown -R christophe:christophe . (and replace my first name with your Linux username).
Let's look at the tree structure:
Remember, dagger has been defined as our entrypoint (see our Dockerfile) so, to get the help screen of Dagger, simply run docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call --help — you'll get the same function list shown at the top of this article (this first time it'll take more time since Dagger needs to build the pipeline).
More Demos
Create our linting function
Functions are defined in the .pipeline/src/src/main.py file.
Please open that file and add a new function like below:
@functionasync def lint(self, source: str) -> str: """ Run Pylint on the codebase. """ return f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m"This is an async function decorated as a Dagger pipeline step that accepts source code and returns a message indicating it will lint that source. The function doesn't actually run Pylint yet—it just returns a formatted string with ANSI color codes to display a yellow message in the terminal.
Save the file and run docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call --help again. See, we've our new function:
And we also get the list of parameters for our lint function docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint --help:
Now, back to the .pipeline/src/src/main.py and replace the entire file (we don't need sample functions) with this content:
from dagger import dag, DefaultPath, Directory, function, object_typefrom typing import Annotated
@object_typeclass Src: @function async def lint(self, source: Annotated[Directory, DefaultPath("src")]) -> str: """ Run Pylint on the codebase. """ print(f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m")
return await ( dag.container() .from_("python:3.13-slim") .with_exec(["pip","install","pylint"]) .with_mounted_directory("/app/src", source) .with_workdir("/app/src") .with_exec(["pylint","."]) .stdout() )This code defines a Dagger object that runs Pylint (a Python code checker) on your source code. It creates a reusable pipeline component that takes a directory of Python files, spins up a container with Python installed, installs Pylint, and runs the linter to check for code quality issues.
We'll thus remove the two sample functions and we'll implement our linting function. We'll also define the src folder as the default one so we don't need to add --source src anymore when calling dagger.
By running docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint we'll then ask the pipeline to run our linter (Pylint here) on our current folder.
Since it's the first time, Dagger will need to make some initializations (like installing PyLint) then we'll get the output:

Yes! PyLint worked and alerted us about a missing module docstring.
Edit src/main.py and add some valid module docstring (or just ignore that warning):
# pylint: disable=missing-module-docstring
import random
if random.randint(0, 1) == 0: print("Hello world")else: print("Bonjour le monde!")This script randomly picks either 0 or 1, then prints a greeting in English if it gets 0, or French if it gets 1. It's a simple coin-flip program that demonstrates conditional logic.
Running docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call lint again will congratulate us now with a score of 10/10.
Edit the src/main.py file again, f.i. make a typo by updating the line else: and remove the final : and the linter won't be happy anymore.
Information
We've successfully created our first task and we've successfully fired it on our machine.
Create a makefile
It becomes quite difficult to remember all these docker xxx commands, no? Let's simplify this by creating a makefile (if you're new to this, read Linux Makefile - When to use a makefile first).
Thanks to the following makefile, we'll be able to just run make build to create our Dagger Docker image and make lint to run the lint function.
You'll also have a make bash action to jump into an interactive shell (type exit to quit the shell and come back to your host console). Easy, no?
And make help will show the Dagger help screen.
DAEMON_NAME=dagger_daemonDOCKER_SOCK=-v /var/run/docker.sock:/var/run/docker.sock
.PHONY:buildbuild: docker build -t ${DAEMON_NAME} -f .docker/Dockerfile .
.PHONY:bashbash: docker run -it --rm ${DOCKER_SOCK} -v .:/app/src --entrypoint /bin/bash ${DAEMON_NAME}
.PHONY:helphelp: docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call --help
.PHONY:lintlint: docker run -it --rm ${DOCKER_SOCK} -v .:/app/src ${DAEMON_NAME} call lintThis Makefile defines a set of convenient commands for working with a Docker container called 'dagger_daemon'. It sets up variables for the daemon name and Docker socket access, then creates shortcuts to build the image and run it with different entry points — whether you want an interactive bash shell, help documentation, or to run linting checks.
Formatting the code using Black
Edit the .pipeline/src/src/main.py file and add this new function:
from dagger import dag, DefaultPath, Directory, function, object_typefrom typing import Annotated
@object_typeclass Src: @function async def lint(self, source: Annotated[Directory, DefaultPath("src")]) -> str: """ Run Pylint on the codebase. """ print(f"\033[33mI'm inside your Dagger pipeline and I'll lint {source}\033[0m")
return await ( dag.container() .from_("python:3.13-slim") .with_exec(["pip","install","pylint"]) .with_mounted_directory("/app/src", source) .with_workdir("/app/src") .with_exec(["pylint","."]) .stdout() )
# highlight-start @function async def format(self, source: Annotated[Directory, DefaultPath("src")]) -> str: """ Format the codebase using Black. """ return await ( dag.container() .from_("python:3.13-slim") .with_exec(["pip","install","black"]) .with_mounted_directory("/app/src", source) .with_workdir("/app/src") .with_exec(["black","."]) .stdout() ) # highlight-endThis code defines a Dagger pipeline module with two methods that run Python code quality tools inside Docker containers. The `lint` method runs Pylint to check for code issues, and the `format` method runs Black to automatically format code. Both methods take a source directory, spin up a Python container, install the necessary tool, mount the source code, and execute the tool, returning the output as a string.
So, from now, you can run dagger call format (from inside the container i.e. run make bash first) or docker run -it --rm -v /var/run/docker.sock:/var/run/docker.sock -v .:/app/src dagger_daemon call format (from your host).
Towards the universe and infinity
Ok, we've now two tasks and we've to implement a lot more. We can do a lot of copy/paste or take time to start thinking:
- It would be nice to not specify every time the source folder,
- It would be nice to start all tasks at once, asynchronously
- It would be nice to have a configuration folder where we'll store configuration files
We need to make a little change to our Docker image:
# cspell:disable
FROM python:3.11-slim-buster AS base
# Dagger is using the Docker technology so we need to install Docker in the image# Also install required Linux dependencies# hadolint ignore=DL3008RUN set -e -x \ && apt-get update -yqq \ && apt-get install -y --no-install-recommends docker.io wget \ && apt-get clean \ && rm -rf /tmp/* /var/lib/apt/lists/*
# Install DaggerARG VERSION=0.15.1ARG ARCHIVE=dagger_v${VERSION}_linux_amd64.tar.gzARG URL=https://github.com/dagger/dagger/releases/download/v${VERSION}/${ARCHIVE}
# hadolint ignore=DL3003RUN set -e -x \ && cd /tmp \ && wget --quiet --output-document ${ARCHIVE} ${URL} \ && tar xvfz ${ARCHIVE} \ && mv dagger /usr/local/bin/ \ && rm -rf /tmp/
# highlight-start# anyio is required by Python to be able to run tasks concurrently and asynchronouslyRUN set -e -x \ && pip install anyio# highlight-end
WORKDIR /app/src
ENTRYPOINT [ "/usr/local/bin/dagger" ]This Dockerfile builds a Docker image based on Python 3.11 that includes Docker itself, the Dagger CLI tool (a workflow automation platform), and the anyio Python library for concurrent task execution. It sets up a working directory and configures the image to run Dagger as its default entry point.
This done, run make build to create a fresh Docker image with anyio installed.
Now, we'll update the .pipeline/src/src/main.py file and add some more new functions:
# cspell:ignore anyio,rcfile,pylintrc,workdirimport anyio
from typing import Annotated
import daggerfrom dagger import DefaultPath, Doc, dag, function, object_type, Directory
@object_typeclass Src:
# This is the directory where source files are located (f.i. "/app/src") source: Annotated[ Directory, Doc("The source folder; where the codebase is located"), DefaultPath("src") ]
# This is the directory where configuration files are located (f.i. "/app/.config") config: Annotated[ Directory, Doc("The folder container configuration files"), DefaultPath(".config") ]
@function async def lint(self) -> str: """ Lint python scripts using Pylint - Run analyses your code without actually running it (https://pypi.org/project/pylint/) """ return await ( dag.container() .from_("python:3.13-slim") .with_exec(["pip","install","pylint"]) .with_mounted_directory("/app/src", self.source) .with_mounted_directory("/app/config", self.config) .with_workdir("/app/src") .with_exec(["pylint",".","--rcfile","/app/config/.pylintrc"]) .stdout() )
@function async def format(self) -> str: """ Format the script using Black (https://black.readthedocs.io/en/stable/) """ return await ( dag.container() .from_("python:3.13-slim") .with_exec(["pip","install","black"]) .with_mounted_directory("/app/src", self.source) .with_mounted_directory("/app/config", self.config) .with_workdir("/app/src") .with_exec(["black",".","--config","/app/config/black.toml"]) .stdout() )
@function async def mypy(self)-> str: """ Mypy is a program that will type check your Python code (https://github.com/python/mypy/) """ mypy_cache = dag.cache_volume("python_mypy")
return await ( dag.container() .from_("python:3.13-slim") .with_exec(["pip","install","mypy"]) .with_mounted_directory("/app/src", self.source) .with_mounted_directory("/app/config", self.config) .with_mounted_cache("/tmp/mypy", mypy_cache) .with_workdir("/app/src") # .with_exec(["ls","-alhR"]) .with_exec(["mypy","--config-file",f"/app/config/mypy.ini","."]) .stdout() )
@function async def ruff(self) -> str: """ An extremely fast Python linter and code formatter (https://github.com/astral-sh/ruff) """ return await ( dag.container() .from_("python:3.13-slim") .with_exec(["pip","install","ruff"]) .with_mounted_directory("/app/src", self.source) .with_mounted_directory("/app/config", self.config) .with_workdir("/app/src") .with_exec(["ruff","check","--config","/app/config/pyproject.toml", "."]) .with_exec(["ruff","format","--config","/app/config/pyproject.toml", "."]) .stdout() )
@function async def run_all(self): """Run linter, type-checker, unit tests concurrently""" async with anyio.create_task_group() as tg: tg.start_soon(self.ruff) tg.start_soon(self.lint) tg.start_soon(self.format) tg.start_soon(self.mypy)This module defines a Dagger object that runs Python code quality tools (linting, formatting, type-checking) in isolated containers. Each method spins up a fresh Python container, installs a tool, mounts your source and config directories, and runs the tool—returning its output. The run_all method executes all four tools concurrently using async task groups instead of waiting for each one to finish sequentially.
This new file comes with a lot of changes:
We've defined two global variables called source and config. So, now, we don't pass the source folder to the lint function anymore (local parameter) but just need to set it once (global parameter). We've also added a config folder to be able to tell Dagger where our configuration files are stored.
source: Annotated[ Directory, Doc("The source folder; where the codebase is located"), DefaultPath("src")]
config: Annotated[ Directory, Doc("The folder container configuration files"), DefaultPath(".config")]This code defines two configuration variables using Python's type annotation system with metadata. Each variable specifies a directory path and includes documentation about its purpose and a default location if none is provided.
We have two new functions called mypy and ruff and a last one called run_all:
@function async def run_all(self): """Run linter, type-checker, unit tests concurrently""" async with anyio.create_task_group() as tg: tg.start_soon(self.ruff) tg.start_soon(self.lint) tg.start_soon(self.format) tg.start_soon(self.mypy)This method launches four development tools (a linter, formatter, and type checker) all at the same time rather than waiting for each one to finish before starting the next. It uses anyio's task group to manage running all these jobs concurrently, which is faster than running them sequentially.
That one will start all the four functions concurrently and will wait until one fails or the four succeed.
Under the Hood (skip this if you just want to use it)
Configuration files
Before running these new functions, we need to create some configuration files.
[MASTER]disable= broad-exception-caught, consider-using-assignment-expr, invalid-name, missing-module-docstring, too-few-public-methods, too-many-instance-attributes, too-many-positional-arguments
; Pickle collected data for later comparisons.persistent=yes
; List of plugins (as comma-separated values of python modules names) to load,; usually to register additional checkers.load-plugins= pylint.extensions.check_elif, pylint.extensions.bad_builtin, pylint.extensions.docparams, pylint.extensions.for_any_all, pylint.extensions.set_membership, pylint.extensions.code_style, pylint.extensions.overlapping_exceptions, pylint.extensions.typing, pylint.extensions.redefined_variable_type, pylint.extensions.comparison_placement, ; pylint.extensions.mccabe,
; Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the; number of processors available to use.jobs=0
; When enabled, pylint would attempt to guess common misconfiguration and emit; user-friendly hints instead of false-positive error messages.suggestion-mode=yes
; Minimum supported python versionpy-version = 3.13.0
; Specify a score threshold to be exceeded before program exits with error.fail-under=9.50
[FORMAT]; The same as in prospector.yaml, .pylintrc, black.toml and pyproject.tomlmax-line-length=120This is a Pylint configuration file that customizes how Python code is analyzed. It disables certain style warnings that the team doesn't care about, enables extra checking plugins for more thorough analysis, and sets performance and Python version requirements for the linter.
[tool.black]
# The same as in prospector.yaml, .pylintrc, black.toml and pyproject.tomlline-length = 120
# We'll format the code for Python 3.13.0; we can add multiple versions (the ones supported by# the script, all versions separated by a comma like in ['py312', 'py313'] (run "black --help" to get them)target-version = ['py313']This configuration section tells Black, a Python code formatter, to format code with a line length of 120 characters and target Python version 3.13. Black is a tool that automatically reformats Python code to follow consistent style rules.
[mypy]show_error_codes = true
; First we turn on *all the checks*, and then we turn off those that are too annoying.strict = True
; Don't know why but mypy returns errors like; Customer? has no attribute "input_path" [attr-defined]; when that attribute is well present. Ignore the error temporarilydisable_error_code = attr-defined
cache_dir = /tmp/mypy
; Disallows defining functions without type annotations or with incomplete type annotationsdisallow_untyped_defs = true
no_implicit_optional = true
strict_equality = truewarn_redundant_casts = truewarn_unused_ignores = true
python_version = 3.13This is a mypy configuration file that enables strict type checking for a Python project. It turns on all available type safety checks, then disables just one problematic check (attr-defined) that was causing false positives. The configuration targets Python 3.13 and stores its cache in a temporary directory.
[tool.ruff]
exclude=[]
# The same as in prospector.yaml, .pylintrc, black.toml and pyproject.tomlline-length = 120indent-width = 4This is a configuration file for Ruff, a Python linter and code formatter. It sets up basic formatting rules like line length and indentation, while starting with an empty exclusion list for files that shouldn't be checked.
Implementing a GitLab CI using Dagger
Let's go back to our objective: to simplify the pipeline process both at CI level (GitLab, GitHub, etc.) and locally.
We've just done the local part, let's tackle the remote CI.
For the next chapters, I'll suppose you're using a self-hosted GitLab server.
Allowing the GitLab runner to access Docker
I'm not an expert in GitLab runner configuration but the following configuration is working for me. I've detailed that setup, and the folder-sharing pitfall coming with it, in GitLab - Running Docker-out-of-Docker in your CI.
Do an SSH connection to your GitLab runner server and edit the /etc/gitlab-runner/config.toml file (you should be root). Just add /var/run/docker.sock:/var/run/docker.sock for the volumes property:
[runners.docker] # Adding /var/run/docker.sock:/var/run/docker.sock # will allow to use commands like "docker image list" i.e. running Docker commands # in CI scripts. This is called "Docker socket binding". volumes = ["/cache", "/var/run/docker.sock:/var/run/docker.sock"]This TOML configuration sets up Docker volumes for a CI runner, mounting two locations: a cache directory and the Docker socket file. The Docker socket binding allows CI scripts to execute Docker commands from within the container, essentially giving the container the ability to control Docker on the host machine.
Also, make sure the Linux user used by your GitLab runner (default username is gitlab-runner) is part of the docker group. This is done by running sudo usermod -aG docker gitlab-runner in the CLI (see https://docs.gitlab.com/ee/ci/docker/using_docker_build.html#use-the-shell-executor).
Information
Official Gitlab documentation about volumes.
To check if it's working, run sudo su gitlab-runner to switch to that user and run docker info and docker image list and check if it works. If yes, then your user is part of the Docker group.
Configure your CI
Please create a GitLab repository, push your existing project there.
Now, please create a file called .gitlab-ci.yml with this content:
.docker: image: docker:latest services: - docker:${DOCKER_VERSION}-dind variables: DOCKER_HOST: unix:///var/run/docker.sock # Docker Socket Binding.dagger: extends: [.docker] before_script: - apk add curl - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin shlint: extends: [.dagger] script: - dagger call --source src --config .config lintformat: extends: [.dagger] script: - dagger call --source src --config .config formatmypy: extends: [.dagger] script: - dagger call --source src --config .config mypyruff: extends: [.dagger] script: - dagger call --source src --config .config ruffThis YAML file defines a GitLab CI/CD pipeline configuration for running code quality and testing tools. It sets up a reusable Docker environment with Dagger (a containerized task runner), then defines four separate jobs (lint, format, mypy, and ruff) that each inherit that environment and run different code checks on the source code.
And push the changes to GitLab. The presence of the .gitlab-ci.yml file will tell GitLab to instantiate a pipeline after each commit and, here in our example, to start the four jobs.
Docker Socket Binding
The provided example is using the technique called Docker Socket Binding: we don't need to define the DOCKER_HOST variable for instance as we can see in the official Dagger documentation. Indeed, if not specified, DOCKER_HOST is set to unix:///var/run/docker.sock (doc).
Since we've shared the Docker daemon (/var/run/docker.sock) in our GitLab /etc/gitlab-runner/config.toml configuration file, we've allowed the CI to access the socket.
But, you can also use the asynchronous mode since we've implemented a run-all feature:
.docker: image: docker:latest services: - docker:${DOCKER_VERSION}-dind variables: DOCKER_HOST: unix:///var/run/docker.sock # Docker Socket Binding.dagger: extends: [.docker] before_script: - apk add curl - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin shrun-all: extends: [.dagger] script: - dagger call --source src --config .config run-allThis is a GitLab CI configuration file that sets up reusable job templates. It defines a Docker environment, extends it to add the Dagger tool (which is a pipeline automation framework), and then creates a job that uses both to run all steps defined in a Dagger configuration.
Conclusion
We've built a custom Dagger Docker image, daggerized an existing project, and defined four functions (lint, format, mypy and ruff) plus a run-all one to start them all concurrently. make actions spare us from remembering the long Docker CLI commands, and the exact same functions now also run inside a GitLab CI, sharing the Docker socket with the runner.
The push → wait → read the log → guess → push again loop from the introduction is gone: everything that fires in CI can be exercised and debugged locally first, in seconds, not minutes on a remote server.
If you haven't already, read Python - Code Quality tools for more detail on Pylint, Black, mypy and Ruff themselves.




