Dagger : La CI/CD Portable qui Tourne Partout grâce aux Containers

Dagger, la CI/CD portable créée par le fondateur de Docker. Pipelines en code (Python, Go, TypeScript), test local, pas de vendor lock-in et caching intelligent.

Introduction : Le Problème du Vendor Lock-in en CI/CD

Si vous avez déjà migré un projet de Jenkins vers GitLab CI, de GitLab CI vers GitHub Actions, ou de CircleCI vers n'importe quoi d'autre, vous connaissez la douleur. Chaque plateforme de CI/CD possède sa propre syntaxe, son propre système de plugins, ses propres abstractions. Vos pipelines, qui représentent parfois des semaines voire des mois de travail d'ingénierie, deviennent du code jetable à chaque migration.

Ce problème de vendor lock-in en CI/CD est celui que Dagger ambitionne de résoudre. Et le projet n'est pas porté par n'importe qui : son fondateur est Solomon Hykes, le créateur de Docker. L'homme qui a révolutionné le déploiement d'applications avec les conteneurs s'attaque désormais à la CI/CD avec la même philosophie : standardiser et rendre portable.

Architecture en un coup d'œil

Diagramme - Dagger : La CI/CD Portable qui Tourne Partout grâce aux Containers

Lancé en 2022 et ayant atteint sa maturité en 2025-2026, Dagger propose une approche radicalement différente : écrire vos pipelines CI/CD dans un vrai langage de programmation (Go, Python, TypeScript, PHP), les exécuter dans des conteneurs via un moteur basé sur BuildKit, et les faire tourner partout — localement sur votre machine, dans GitHub Actions, GitLab CI, Jenkins, CircleCI, ou n'importe quel environnement disposant d'un runtime de conteneurs.

Qu'est-ce que Dagger ?

Dagger est un moteur de CI/CD programmable et portable. Plutôt que d'écrire des fichiers YAML déclaratifs spécifiques à une plateforme, vous écrivez vos pipelines dans un langage de programmation standard, en utilisant un SDK Dagger.

Les Principes Fondamentaux

  • Portabilité : Un pipeline Dagger fonctionne identiquement partout — en local, en CI, sur n'importe quel cloud
  • Programmabilité : Utilisez un vrai langage (Go, Python, TypeScript, PHP) avec autocomplétion, typage, tests unitaires, et toutes les bibliothèques de l'écosystème
  • Reproductibilité : Chaque étape s'exécute dans un conteneur, garantissant un environnement identique partout
  • Caching intelligent : Dagger sait exactement ce qui a changé et ne ré-exécute que le nécessaire
  • Composabilité : Les modules Dagger (Daggerverse) sont réutilisables et partageables

L'Équipe Derrière Dagger

Solomon Hykes, après avoir quitté Docker Inc. en 2018, a fondé Dagger avec une vision claire : appliquer les leçons de Docker à la CI/CD. L'équipe comprend également d'anciens ingénieurs Docker, des contributeurs Moby/BuildKit, et des vétérans de l'écosystème cloud-native. Le projet a levé plus de 40 millions de dollars en financement et est open-source sous licence Apache 2.0.

Architecture de Dagger

L'architecture de Dagger se compose de trois couches distinctes :

1. Le Dagger Engine

Le cœur de Dagger est son moteur d'exécution, qui tourne comme un conteneur Docker. Il est construit sur BuildKit (le même moteur qui construit les images Docker) et offre :

  • Exécution conteneurisée : Chaque opération s'exécute dans un conteneur isolé
  • Graph d'exécution (DAG) : Les opérations sont organisées en un graphe acyclique dirigé, permettant la parallélisation automatique
  • Caching par contenu : Basé sur le content-addressable storage de BuildKit
  • Networking : Gestion des services (bases de données de test, serveurs mock, etc.)

2. L'API GraphQL

Le Dagger Engine expose une API GraphQL que les SDKs utilisent pour communiquer. Cette architecture permet :

  • L'ajout facile de nouveaux SDKs dans n'importe quel langage
  • L'introspection complète des capacités du moteur
  • La composition de modules écrits dans différents langages
  • Le debugging via des outils GraphQL standard

3. Les SDKs

Les SDKs sont les bibliothèques que vous utilisez dans votre code pour interagir avec le Dagger Engine. Chaque SDK fournit une API typée et idiomatique pour son langage :

SDK Langage Statut Gestionnaire de paquets
dagger-go-sdk Go Stable go modules
dagger-python-sdk Python Stable pip / uv
dagger-typescript-sdk TypeScript Stable npm / bun
dagger-php-sdk PHP Expérimental composer

Installation

Prérequis

  • Docker Engine (ou un runtime compatible OCI) installé et fonctionnel
  • Le langage de votre choix (Go 1.21+, Python 3.10+, Node.js 18+, ou PHP 8.2+)

Installer le CLI Dagger

# Installation sur Linux/macOS
curl -fsSL https://dl.dagger.io/dagger/install.sh | sh

# Ou via Homebrew (macOS/Linux)
brew install dagger/tap/dagger

# Vérifier l'installation
dagger version
# dagger v0.15.x (linux/amd64)

# Le Dagger Engine sera automatiquement téléchargé et démarré
# comme conteneur Docker lors de la première utilisation

Initialiser un Projet Dagger

# Se placer dans le répertoire de votre projet
cd /chemin/vers/mon-projet

# Initialiser un module Dagger en Python
dagger init --sdk=python --name=ci

# Cela crée :
# dagger.json        - Configuration du module
# dagger/            - Répertoire du module
# dagger/src/main.py - Point d'entrée du pipeline

Premier Pipeline avec le SDK Python

Commençons par un pipeline concret et réaliste : construire, tester et publier une application Python.

Structure du Projet

mon-projet/
├── src/
│   ├── __init__.py
│   ├── app.py
│   └── utils.py
├── tests/
│   ├── __init__.py
│   ├── test_app.py
│   └── test_utils.py
├── Dockerfile
├── pyproject.toml
├── requirements.txt
├── dagger.json
└── dagger/
    └── src/
        └── main.py    # Notre pipeline Dagger

Le Pipeline Complet en Python

# dagger/src/main.py
"""Pipeline CI/CD complet avec Dagger SDK Python."""

import dagger
from dagger import dag, function, object_type


@object_type
class Ci:
    """Module CI/CD pour notre application Python."""

    @function
    async def lint(self, source: dagger.Directory) -> str:
        """Exécute le linting avec ruff sur le code source."""
        return await (
            dag.container()
            .from_("python:3.12-slim")
            .with_exec(["pip", "install", "ruff"])
            .with_mounted_directory("/app", source)
            .with_workdir("/app")
            .with_exec(["ruff", "check", "src/", "tests/"])
            .with_exec(["ruff", "format", "--check", "src/", "tests/"])
            .stdout()
        )

    @function
    async def test(self, source: dagger.Directory) -> str:
        """Exécute les tests unitaires avec pytest."""
        return await (
            dag.container()
            .from_("python:3.12-slim")
            .with_mounted_directory("/app", source)
            .with_workdir("/app")
            .with_exec(["pip", "install", "-r", "requirements.txt"])
            .with_exec(["pip", "install", "pytest", "pytest-cov", "pytest-asyncio"])
            .with_exec([
                "pytest", "tests/",
                "-v",
                "--cov=src",
                "--cov-report=term-missing",
                "--cov-fail-under=80",
            ])
            .stdout()
        )

    @function
    async def test_with_db(
        self,
        source: dagger.Directory,
        db_password: dagger.Secret,
    ) -> str:
        """Exécute les tests d'intégration avec une base PostgreSQL éphémère."""
        # Démarrer un service PostgreSQL
        postgres = (
            dag.container()
            .from_("postgres:16-alpine")
            .with_env_variable("POSTGRES_DB", "testdb")
            .with_env_variable("POSTGRES_USER", "testuser")
            .with_secret_variable("POSTGRES_PASSWORD", db_password)
            .with_exposed_port(5432)
            .as_service()
        )

        # Exécuter les tests avec la DB comme service
        return await (
            dag.container()
            .from_("python:3.12-slim")
            .with_mounted_directory("/app", source)
            .with_workdir("/app")
            .with_service_binding("db", postgres)
            .with_env_variable("DATABASE_URL", "postgresql://testuser:secret@db:5432/testdb")
            .with_exec(["pip", "install", "-r", "requirements.txt"])
            .with_exec(["pip", "install", "pytest"])
            .with_exec(["pytest", "tests/integration/", "-v"])
            .stdout()
        )

    @function
    async def build(self, source: dagger.Directory) -> dagger.Container:
        """Construit l'image Docker de l'application."""
        return (
            dag.container()
            .from_("python:3.12-slim")
            .with_exec(["apt-get", "update"])
            .with_exec(["apt-get", "install", "-y", "--no-install-recommends", "curl"])
            .with_exec(["apt-get", "clean"])
            .with_mounted_directory("/app", source)
            .with_workdir("/app")
            .with_exec(["pip", "install", "--no-cache-dir", "-r", "requirements.txt"])
            .with_entrypoint(["python", "-m", "uvicorn", "src.app:app", "--host", "0.0.0.0", "--port", "8000"])
            .with_exposed_port(8000)
        )

    @function
    async def publish(
        self,
        source: dagger.Directory,
        registry: str,
        image_name: str,
        tag: str,
        registry_user: str,
        registry_password: dagger.Secret,
    ) -> str:
        """Construit et publie l'image sur un registre de conteneurs."""
        container = await self.build(source)

        ref = f"{registry}/{image_name}:{tag}"

        digest = await (
            container
            .with_registry_auth(registry, registry_user, registry_password)
            .publish(ref)
        )

        return f"Image publiée : {digest}"

    @function
    async def ci(self, source: dagger.Directory) -> str:
        """Pipeline CI complet : lint + test + build."""
        # Lint
        lint_result = await self.lint(source)
        print(f"Lint OK:\n{lint_result}")

        # Tests
        test_result = await self.test(source)
        print(f"Tests OK:\n{test_result}")

        # Build
        container = await self.build(source)
        print("Build OK")

        return "Pipeline CI terminé avec succès !"

    @function
    async def build_multiplatform(
        self,
        source: dagger.Directory,
        registry: str,
        image_name: str,
        tag: str,
        registry_user: str,
        registry_password: dagger.Secret,
    ) -> str:
        """Construit et publie des images multi-architecture (amd64 + arm64)."""
        platforms = ["linux/amd64", "linux/arm64"]
        platform_variants = []

        for platform in platforms:
            ctr = (
                dag.container(platform=dagger.Platform(platform))
                .from_("python:3.12-slim")
                .with_mounted_directory("/app", source)
                .with_workdir("/app")
                .with_exec(["pip", "install", "--no-cache-dir", "-r", "requirements.txt"])
                .with_entrypoint(["python", "-m", "uvicorn", "src.app:app", "--host", "0.0.0.0"])
                .with_exposed_port(8000)
            )
            platform_variants.append(ctr)

        ref = f"{registry}/{image_name}:{tag}"

        digest = await dag.container().with_registry_auth(
            registry, registry_user, registry_password
        ).publish(ref, platform_variants=platform_variants)

        return f"Image multi-platform publiée : {digest}"

Exécuter le Pipeline Localement

# Exécuter le lint
dagger call lint --source=.

# Exécuter les tests
dagger call test --source=.

# Exécuter le build
dagger call build --source=.

# Exécuter le pipeline CI complet
dagger call ci --source=.

# Exécuter les tests avec une base de données
dagger call test-with-db --source=. --db-password=env:DB_PASSWORD

# Publier l'image
dagger call publish \
  --source=. \
  --registry=ghcr.io \
  --image-name=monorg/monapp \
  --tag=v1.2.3 \
  --registry-user=monuser \
  --registry-password=env:GITHUB_TOKEN

Le point crucial ici : ces commandes fonctionnent exactement de la même manière sur votre laptop, dans GitHub Actions, dans GitLab CI, ou dans n'importe quel environnement avec Docker.

Le Daggerverse : Modules Réutilisables

Le Daggerverse est l'écosystème de modules Dagger réutilisables, comparable aux GitHub Actions Marketplace ou aux orbs CircleCI, mais avec une différence fondamentale : les modules Dagger sont des programmes compilés et typés, pas des scripts YAML.

Utiliser des Modules Existants

# Découvrir les modules disponibles
# https://daggerverse.dev

# Installer un module dans votre projet
dagger install github.com/purpleclay/daggerverse/golang@v0.5.0
dagger install github.com/sagikazarmark/daggerverse/helm@main

# Utiliser un module directement en ligne de commande
dagger -m github.com/shykes/daggerverse/wolfi@main \
  call container --packages=python3,pip \
  terminal

# Lister les modules installés
cat dagger.json

Créer un Module Réutilisable

# Exemple d'un module de notification Slack
# dagger/src/main.py

import dagger
from dagger import dag, function, object_type


@object_type
class Notify:
    """Module de notification pour les pipelines CI/CD."""

    @function
    async def slack(
        self,
        webhook_url: dagger.Secret,
        message: str,
        channel: str = "#ci-cd",
        status: str = "success",
    ) -> str:
        """Envoie une notification Slack via webhook."""
        color = "#36a64f" if status == "success" else "#ff0000"
        icon = ":white_check_mark:" if status == "success" else ":x:"

        payload = f'{{"channel":"{channel}","attachments":[{{"color":"{color}","text":"{icon} {message}"}}]}}'

        return await (
            dag.container()
            .from_("curlimages/curl:latest")
            .with_secret_variable("WEBHOOK_URL", webhook_url)
            .with_exec([
                "sh", "-c",
                f"curl -s -X POST \"$WEBHOOK_URL\" -H 'Content-Type: application/json' -d '{payload}'"
            ])
            .stdout()
        )

    @function
    async def email(
        self,
        smtp_host: str,
        smtp_port: int,
        smtp_user: str,
        smtp_password: dagger.Secret,
        to: str,
        subject: str,
        body: str,
    ) -> str:
        """Envoie une notification par email via SMTP."""
        return await (
            dag.container()
            .from_("python:3.12-alpine")
            .with_exec(["pip", "install", "secure-smtplib"])
            .with_secret_variable("SMTP_PASSWORD", smtp_password)
            .with_env_variable("SMTP_HOST", smtp_host)
            .with_env_variable("SMTP_PORT", str(smtp_port))
            .with_env_variable("SMTP_USER", smtp_user)
            .with_exec([
                "python", "-c",
                f"""
import smtplib, os
from email.mime.text import MIMEText
msg = MIMEText('{body}')
msg['Subject'] = '{subject}'
msg['From'] = os.environ['SMTP_USER']
msg['To'] = '{to}'
with smtplib.SMTP(os.environ['SMTP_HOST'], int(os.environ['SMTP_PORT'])) as s:
    s.starttls()
    s.login(os.environ['SMTP_USER'], os.environ['SMTP_PASSWORD'])
    s.send_message(msg)
print('Email envoyé')
"""
            ])
            .stdout()
        )

Tester Localement Avant de Push

L'un des avantages majeurs de Dagger est de pouvoir exécuter et déboguer vos pipelines localement, exactement comme ils s'exécuteront en CI. Fini le cycle infernal « commit → push → attendre la CI → lire les logs → corriger → recommencer ».

# Exécuter une fonction spécifique
dagger call test --source=.

# Mode interactif : ouvrir un terminal dans le conteneur
dagger call build --source=. terminal

# Inspecter un conteneur intermédiaire
dagger call build --source=. directory --path=/app export --path=./output

# Visualiser le DAG d'exécution
dagger query <<EOF
{
  container {
    from(address: "python:3.12-slim") {
      withExec(args: ["python", "--version"]) {
        stdout
      }
    }
  }
}
EOF

# Activer le debug verbose
dagger call --debug test --source=.

Cette capacité de test local transforme radicalement le workflow de développement des pipelines CI/CD. Vous pouvez itérer en quelques secondes au lieu de plusieurs minutes, avec un feedback immédiat et la possibilité de debugger interactivement.

Intégration avec les Plateformes CI/CD

Dagger s'intègre avec n'importe quelle plateforme CI/CD existante. Le principe est simple : votre plateforme CI appelle les commandes Dagger, qui s'exécutent dans le Dagger Engine conteneurisé. Le fichier de configuration CI devient minimal — juste un wrapper autour de vos fonctions Dagger.

GitHub Actions

# .github/workflows/ci.yml
name: CI/CD Pipeline

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  ci:
    name: CI Pipeline
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Install Dagger CLI
        uses: dagger/dagger-for-github@v7
        with:
          version: "latest"

      - name: Run Lint
        run: dagger call lint --source=.

      - name: Run Tests
        run: dagger call test --source=.

      - name: Run Integration Tests
        run: |
          dagger call test-with-db \
            --source=. \
            --db-password=env:DB_TEST_PASSWORD
        env:
          DB_TEST_PASSWORD: testpassword

      - name: Build and Publish (main only)
        if: github.ref == 'refs/heads/main'
        run: |
          dagger call publish \
            --source=. \
            --registry=ghcr.io \
            --image-name=${{ github.repository }} \
            --tag=${{ github.sha }} \
            --registry-user=${{ github.actor }} \
            --registry-password=env:GITHUB_TOKEN
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

GitLab CI

# .gitlab-ci.yml
stages:
  - ci
  - deploy

variables:
  DAGGER_VERSION: "latest"

.dagger-base:
  image: docker:27-dind
  services:
    - docker:27-dind
  variables:
    DOCKER_HOST: tcp://docker:2376
    DOCKER_TLS_CERTDIR: "/certs"
    DOCKER_TLS_VERIFY: 1
    DOCKER_CERT_PATH: "$DOCKER_TLS_CERTDIR/client"
  before_script:
    - apk add --no-cache curl
    - curl -fsSL https://dl.dagger.io/dagger/install.sh | sh
    - export PATH=$PATH:/root/.local/bin

lint:
  extends: .dagger-base
  stage: ci
  script:
    - dagger call lint --source=.

test:
  extends: .dagger-base
  stage: ci
  script:
    - dagger call test --source=.

build-and-publish:
  extends: .dagger-base
  stage: deploy
  only:
    - main
  script:
    - |
      dagger call publish \
        --source=. \
        --registry=$CI_REGISTRY \
        --image-name=$CI_PROJECT_PATH \
        --tag=$CI_COMMIT_SHA \
        --registry-user=$CI_REGISTRY_USER \
        --registry-password=env:CI_REGISTRY_PASSWORD
  variables:
    CI_REGISTRY_PASSWORD: $CI_REGISTRY_PASSWORD

Jenkins

# Jenkinsfile
pipeline {
    agent {
        docker {
            image 'docker:27-dind'
            args '--privileged -v /var/run/docker.sock:/var/run/docker.sock'
        }
    }

    environment {
        REGISTRY_PASSWORD = credentials('registry-password')
    }

    stages {
        stage('Install Dagger') {
            steps {
                sh 'curl -fsSL https://dl.dagger.io/dagger/install.sh | sh'
                sh 'export PATH=$PATH:$HOME/.local/bin'
            }
        }

        stage('Lint') {
            steps {
                sh 'dagger call lint --source=.'
            }
        }

        stage('Test') {
            steps {
                sh 'dagger call test --source=.'
            }
        }

        stage('Build and Publish') {
            when {
                branch 'main'
            }
            steps {
                sh '''
                    dagger call publish \
                        --source=. \
                        --registry=registry.exemple.fr \
                        --image-name=monapp \
                        --tag=${BUILD_NUMBER} \
                        --registry-user=jenkins \
                        --registry-password=env:REGISTRY_PASSWORD
                '''
            }
        }
    }

    post {
        always {
            cleanWs()
        }
    }
}

CircleCI

# .circleci/config.yml
version: 2.1

jobs:
  ci:
    machine:
      image: ubuntu-2404:current
    resource_class: medium
    steps:
      - checkout

      - run:
          name: Install Dagger
          command: |
            curl -fsSL https://dl.dagger.io/dagger/install.sh | sh

      - run:
          name: Lint
          command: dagger call lint --source=.

      - run:
          name: Test
          command: dagger call test --source=.

      - run:
          name: Build and Publish
          command: |
            dagger call publish \
              --source=. \
              --registry=ghcr.io \
              --image-name=monorg/monapp \
              --tag=${CIRCLE_SHA1} \
              --registry-user=${REGISTRY_USER} \
              --registry-password=env:REGISTRY_PASSWORD

workflows:
  main:
    jobs:
      - ci

Remarquez la beauté de l'approche : dans chaque plateforme CI, le fichier de configuration est minimal et quasi identique. La logique métier du pipeline est dans votre code Dagger, pas dans la configuration CI. Si vous migrez de GitHub Actions vers GitLab CI, seul le wrapper change — votre pipeline reste intact.

Caching Intelligent

Le caching est l'un des aspects les plus puissants de Dagger. Grâce à BuildKit, Dagger implémente un cache par contenu (content-addressable) qui sait exactement ce qui a changé et ne ré-exécute que le minimum nécessaire.

Cache Automatique

Par défaut, Dagger cache automatiquement :

  • Les images de base : from_("python:3.12-slim") n'est téléchargé qu'une fois
  • Les couches intermédiaires : Si vos dépendances n'ont pas changé, pip install est caché
  • Les résultats d'exécution : Les commandes avec les mêmes entrées produisent un cache hit

Cache Explicite avec cache_volume

# Utilisation de volumes de cache pour les dépendances
@function
async def test_with_cache(self, source: dagger.Directory) -> str:
    """Tests avec cache des dépendances pip."""
    pip_cache = dag.cache_volume("pip-cache")

    return await (
        dag.container()
        .from_("python:3.12-slim")
        .with_mounted_cache("/root/.cache/pip", pip_cache)
        .with_mounted_directory("/app", source)
        .with_workdir("/app")
        .with_exec(["pip", "install", "-r", "requirements.txt"])
        .with_exec(["pytest", "tests/", "-v"])
        .stdout()
    )

# Pour Node.js
@function
async def test_node(self, source: dagger.Directory) -> str:
    """Tests Node.js avec cache npm."""
    npm_cache = dag.cache_volume("npm-cache")
    node_modules = dag.cache_volume("node-modules")

    return await (
        dag.container()
        .from_("node:20-slim")
        .with_mounted_cache("/root/.npm", npm_cache)
        .with_mounted_cache("/app/node_modules", node_modules)
        .with_mounted_directory("/app", source)
        .with_workdir("/app")
        .with_exec(["npm", "ci"])
        .with_exec(["npm", "test"])
        .stdout()
    )

Optimisation de l'Ordre des Opérations

Comme avec les Dockerfiles, l'ordre des opérations impacte l'efficacité du cache :

# MAUVAIS : Le cache est invalidé à chaque changement de code
dag.container()
    .from_("python:3.12-slim")
    .with_mounted_directory("/app", source)       # Change souvent
    .with_exec(["pip", "install", "-r", ...])     # Ré-exécuté inutilement
    .with_exec(["pytest", ...])

# BON : Les dépendances sont cachées indépendamment du code
dag.container()
    .from_("python:3.12-slim")
    .with_file("/app/requirements.txt",
               source.file("requirements.txt"))   # Change rarement
    .with_workdir("/app")
    .with_exec(["pip", "install", "-r", ...])     # Caché si requirements.txt n'a pas changé
    .with_mounted_directory("/app", source)        # Monté après l'install
    .with_exec(["pytest", ...])                    # Seuls les tests sont ré-exécutés

Gestion des Secrets

Dagger traite les secrets comme des citoyens de première classe. Les secrets ne sont jamais écrits dans les logs, le cache, ou les couches d'images.

# Passer un secret depuis une variable d'environnement
dagger call publish \
  --registry-password=env:REGISTRY_TOKEN

# Passer un secret depuis un fichier
dagger call deploy \
  --ssh-key=file:~/.ssh/id_ed25519

# Passer un secret depuis une commande
dagger call deploy \
  --api-token=cmd:"vault kv get -field=token secret/api"

# Dans le code, les secrets sont typés
@function
async def deploy(
    self,
    source: dagger.Directory,
    api_token: dagger.Secret,    # Type dédié, jamais exposé
    ssh_key: dagger.Secret,
) -> str:
    return await (
        dag.container()
        .from_("alpine:latest")
        .with_secret_variable("API_TOKEN", api_token)  # Variable d'env secrète
        .with_mounted_secret("/root/.ssh/id_ed25519", ssh_key)  # Fichier secret
        .with_exec(["sh", "-c", "echo 'Le token ne sera JAMAIS dans les logs'"])
        .stdout()
    )

Services : Bases de Données de Test et Plus

Dagger permet de démarrer des services éphémères (bases de données, caches, APIs mock) comme partie intégrante de votre pipeline. Ces services sont automatiquement démarrés, connectés au réseau du pipeline, et nettoyés à la fin.

# Pipeline avec PostgreSQL + Redis comme services de test
@function
async def integration_tests(self, source: dagger.Directory) -> str:
    """Tests d'intégration avec PostgreSQL et Redis."""

    # Service PostgreSQL
    postgres = (
        dag.container()
        .from_("postgres:16-alpine")
        .with_env_variable("POSTGRES_DB", "testdb")
        .with_env_variable("POSTGRES_USER", "test")
        .with_env_variable("POSTGRES_PASSWORD", "testpass")
        .with_exposed_port(5432)
        .as_service()
    )

    # Service Redis
    redis = (
        dag.container()
        .from_("redis:7-alpine")
        .with_exposed_port(6379)
        .as_service()
    )

    # Service API mock (WireMock)
    wiremock = (
        dag.container()
        .from_("wiremock/wiremock:latest")
        .with_exposed_port(8080)
        .with_mounted_directory(
            "/home/wiremock/mappings",
            source.directory("tests/wiremock-mappings"),
        )
        .as_service()
    )

    # Exécuter les tests avec tous les services
    return await (
        dag.container()
        .from_("python:3.12-slim")
        .with_service_binding("postgres", postgres)
        .with_service_binding("redis", redis)
        .with_service_binding("api-mock", wiremock)
        .with_env_variable("DATABASE_URL", "postgresql://test:testpass@postgres:5432/testdb")
        .with_env_variable("REDIS_URL", "redis://redis:6379/0")
        .with_env_variable("EXTERNAL_API_URL", "http://api-mock:8080")
        .with_mounted_directory("/app", source)
        .with_workdir("/app")
        .with_exec(["pip", "install", "-r", "requirements.txt"])
        .with_exec(["pip", "install", "pytest"])
        .with_exec(["pytest", "tests/integration/", "-v", "--tb=long"])
        .stdout()
    )

Builds Multi-Platform

Construire des images pour plusieurs architectures (amd64, arm64) est natif avec Dagger, grâce à BuildKit et QEMU :

@function
async def build_all_platforms(
    self,
    source: dagger.Directory,
    registry: str,
    image: str,
    tag: str,
    username: str,
    password: dagger.Secret,
) -> str:
    """Build et push multi-architecture."""
    platforms = [
        dagger.Platform("linux/amd64"),
        dagger.Platform("linux/arm64"),
        dagger.Platform("linux/arm/v7"),
    ]

    variants = []
    for platform in platforms:
        ctr = (
            dag.container(platform=platform)
            .from_("python:3.12-slim")
            .with_file("/app/requirements.txt", source.file("requirements.txt"))
            .with_workdir("/app")
            .with_exec(["pip", "install", "--no-cache-dir", "-r", "requirements.txt"])
            .with_mounted_directory("/app", source)
            .with_entrypoint(["python", "-m", "uvicorn", "src.app:app"])
            .with_exposed_port(8000)
        )
        variants.append(ctr)

    ref = f"{registry}/{image}:{tag}"
    digest = await (
        dag.container()
        .with_registry_auth(registry, username, password)
        .publish(ref, platform_variants=variants)
    )

    return f"Manifest multi-arch publié: {digest}"

Comparaison avec les Outils CI/CD Traditionnels

Critère Dagger GitHub Actions GitLab CI Tekton
Langage de définition Go, Python, TS, PHP YAML YAML YAML (CRDs K8s)
Portabilité Totale GitHub uniquement GitLab uniquement Kubernetes
Exécution locale Oui (natif) Partiel (act) Partiel (gitlab-runner) Oui (si K8s local)
Typage et autocomplétion Oui Non (YAML) Non (YAML) Non (YAML)
Tests unitaires du pipeline Oui (tests classiques) Non Non Non
Caching Automatique (BuildKit) Manuel (actions/cache) Manuel (cache:) Manuel
Secrets Type dédié, jamais exposés Secrets GitHub Variables CI/CD Secrets K8s
Services éphémères Natif services: services: Sidecars
Debugging Breakpoints, terminal interactif SSH debug (payant) SSH debug kubectl logs
Courbe d'apprentissage Moyenne (SDK) Faible (YAML) Faible (YAML) Élevée (K8s)
Communauté En croissance rapide Très large Large Moyenne

Quand Choisir Dagger ?

  • Votre pipeline est complexe : Si votre CI/CD dépasse les cas simples « build → test → deploy », la programmabilité de Dagger prend tout son sens
  • Vous voulez tester localement : Le « commit → push → attendre → vérifier » vous rend fou
  • Multi-plateforme CI : Vous avez des projets sur GitHub, GitLab, et Jenkins et voulez unifier vos pipelines
  • Équipe DevOps/Platform : Vous créez des pipelines réutilisables pour plusieurs équipes
  • Migration à venir : Vous savez que vous changerez de plateforme CI et ne voulez pas réécrire vos pipelines

Quand les Outils Traditionnels Suffisent

  • Pipelines simples : Un build → test → deploy basique est plus rapide à écrire en YAML
  • Petite équipe mono-plateforme : Si vous êtes 100% GitHub et satisfait, l'overhead de Dagger n'est pas justifié
  • Pas de Docker disponible : Dagger nécessite un runtime de conteneurs

Cas d'Usage Concrets

1. Monorepo avec Builds Sélectifs

@function
async def ci_monorepo(self, source: dagger.Directory) -> str:
    """Pipeline CI pour un monorepo : ne build que ce qui a changé."""
    results = []

    # Vérifier si le service API a changé
    api_dir = source.directory("services/api")
    try:
        api_result = await (
            dag.container()
            .from_("golang:1.23-alpine")
            .with_mounted_directory("/app", api_dir)
            .with_workdir("/app")
            .with_exec(["go", "test", "./..."])
            .stdout()
        )
        results.append(f"API: OK\n{api_result}")
    except Exception as e:
        results.append(f"API: FAILED\n{e}")

    # Vérifier si le frontend a changé
    web_dir = source.directory("services/web")
    try:
        web_result = await (
            dag.container()
            .from_("node:20-slim")
            .with_mounted_directory("/app", web_dir)
            .with_workdir("/app")
            .with_exec(["npm", "ci"])
            .with_exec(["npm", "run", "lint"])
            .with_exec(["npm", "test"])
            .stdout()
        )
        results.append(f"Web: OK\n{web_result}")
    except Exception as e:
        results.append(f"Web: FAILED\n{e}")

    return "\n---\n".join(results)

2. Pipeline de Déploiement Kubernetes

@function
async def deploy_k8s(
    self,
    source: dagger.Directory,
    kubeconfig: dagger.Secret,
    image_tag: str,
    namespace: str = "production",
) -> str:
    """Déploie sur Kubernetes avec kubectl."""
    return await (
        dag.container()
        .from_("bitnami/kubectl:latest")
        .with_mounted_secret("/root/.kube/config", kubeconfig)
        .with_mounted_directory("/manifests", source.directory("k8s/"))
        .with_workdir("/manifests")
        .with_exec([
            "sh", "-c",
            f"sed -i 's|IMAGE_TAG|{image_tag}|g' deployment.yaml && "
            f"kubectl apply -f . -n {namespace} && "
            f"kubectl rollout status deployment/app -n {namespace} --timeout=300s"
        ])
        .stdout()
    )

3. Security Scanning

@function
async def security_scan(self, source: dagger.Directory) -> str:
    """Scanne les vulnérabilités avec Trivy et les secrets avec Gitleaks."""
    # Scan de vulnérabilités avec Trivy
    trivy_result = await (
        dag.container()
        .from_("aquasec/trivy:latest")
        .with_mounted_directory("/app", source)
        .with_exec([
            "trivy", "fs",
            "--severity", "HIGH,CRITICAL",
            "--exit-code", "1",
            "--format", "table",
            "/app"
        ])
        .stdout()
    )

    # Détection de secrets avec Gitleaks
    gitleaks_result = await (
        dag.container()
        .from_("zricethezav/gitleaks:latest")
        .with_mounted_directory("/app", source)
        .with_exec([
            "gitleaks", "detect",
            "--source=/app",
            "--no-git",
            "--report-format=json",
            "--exit-code=1",
        ])
        .stdout()
    )

    return f"Trivy:\n{trivy_result}\n\nGitleaks:\n{gitleaks_result}"

Performances

Dagger offre d'excellentes performances grâce à plusieurs mécanismes :

Parallélisation Automatique

Le DAG d'exécution de Dagger identifie automatiquement les opérations indépendantes et les exécute en parallèle. Si votre lint et vos tests n'ont pas de dépendances, ils tourneront simultanément.

Cache Incrémental

Le cache BuildKit est extrêmement efficace. Lors des exécutions suivantes, seules les étapes dont les entrées ont changé sont ré-exécutées. Pour un pipeline typique, un second run est 5 à 10 fois plus rapide que le premier.

Benchmarks Typiques

Opération Premier run Runs suivants (cache)
Build Python + dépendances 45-90s 5-15s
Suite de tests (pytest) 30-60s 30-60s (toujours exécuté)
Build image Docker 60-120s 5-10s (si code seul change)
Push vers registre 30-60s 5-10s (couches cachées)
Pipeline complet 3-5 min 1-2 min

Limites et Considérations

Dagger n'est pas exempt de limitations qu'il est important de connaître :

Limites Techniques

  • Dépendance à Docker : Un runtime de conteneurs est obligatoire. Pas de Docker = pas de Dagger.
  • Overhead du moteur : Le Dagger Engine consomme des ressources. Pour des pipelines très simples, c'est de l'overhead inutile.
  • Courbe d'apprentissage : Maîtriser le SDK demande plus d'effort que d'écrire du YAML basique. L'investissement se rentabilise sur les pipelines complexes.
  • Maturité des SDKs : Le SDK Go est le plus mature. Python et TypeScript sont stables mais certains cas limites existent. PHP reste expérimental.
  • Debugging complexe : Quand un problème survient dans le Dagger Engine lui-même (et non dans votre code), le debugging peut être ardu.

Limites Organisationnelles

  • Adoption en équipe : Si votre équipe maîtrise déjà bien GitHub Actions, la migration vers Dagger représente un changement de paradigme qui nécessite formation et adhésion.
  • Écosystème plus jeune : Le Daggerverse est beaucoup moins fourni que le GitHub Actions Marketplace. Vous devrez parfois écrire vos propres modules.
  • Documentation : Bien qu'en amélioration constante, la documentation est parfois en retard par rapport aux fonctionnalités.

Bonnes Pratiques

1. Structurer Vos Modules

# Structure recommandée pour un projet Dagger
mon-projet/
├── dagger/
│   └── src/
│       └── main.py         # Pipeline principal
├── dagger.json              # Configuration du module
├── src/                     # Code applicatif
├── tests/                   # Tests applicatifs
└── .github/workflows/
    └── ci.yml               # Wrapper CI minimal

2. Séparer les Fonctions

# Préférez des fonctions atomiques et composables
# plutôt qu'une seule fonction monolithique

@function
async def lint(self, source: dagger.Directory) -> str: ...

@function
async def test(self, source: dagger.Directory) -> str: ...

@function
async def build(self, source: dagger.Directory) -> dagger.Container: ...

@function
async def publish(self, container: dagger.Container, ...) -> str: ...

@function
async def ci(self, source: dagger.Directory) -> str:
    """Compose les fonctions atomiques."""
    await self.lint(source)
    await self.test(source)
    ctr = await self.build(source)
    return "CI OK"

3. Toujours Tester Localement

# Prenez l'habitude de tester chaque fonction localement
dagger call lint --source=.
dagger call test --source=.
dagger call build --source=.

# Avant de commit, lancez le pipeline complet
dagger call ci --source=.

4. Versionner le SDK

# Fixez la version de Dagger dans votre CI
# .github/workflows/ci.yml
- uses: dagger/dagger-for-github@v7
  with:
    version: "0.15.1"  # Version fixe, pas "latest"

Conclusion

Dagger représente un changement de paradigme dans la façon de concevoir les pipelines CI/CD. En remplaçant les fichiers YAML propriétaires par du code dans un vrai langage de programmation, exécuté dans des conteneurs via un moteur standardisé, Dagger apporte les bénéfices que Docker a apportés au déploiement : portabilité, reproductibilité et standardisation.

Le projet n'est pas sans défauts — la courbe d'apprentissage est réelle, la dépendance à Docker est contraignante dans certains environnements, et l'écosystème est encore jeune comparé aux mastodontes du marché. Mais pour les équipes qui gèrent des pipelines complexes, qui travaillent sur plusieurs plateformes CI, ou qui sont fatiguées du cycle « push → attendre → debug », Dagger est une proposition de valeur difficile à ignorer.

En 2026, avec des SDKs stables en Go, Python et TypeScript, un Daggerverse en pleine expansion, et le soutien de Solomon Hykes et de son équipe, Dagger est positionné pour devenir un standard de l'industrie. La recommandation est simple :

  • Évaluez Dagger sur un projet existant — commencez par conteneuriser votre pipeline actuel
  • Testez localement — c'est la fonctionnalité qui convertit la plupart des développeurs
  • Adoptez progressivement — Dagger n'est pas du tout-ou-rien, il coexiste avec vos outils existants
  • Contribuez au Daggerverse — Partagez vos modules pour enrichir l'écosystème

La CI/CD programmable et portable n'est plus un concept théorique — c'est une réalité que vous pouvez adopter dès aujourd'hui.

Ressources utiles :
Site officiel Dagger
Documentation Dagger
GitHub Dagger
Daggerverse (modules)
Discord Dagger
Vous vous êtes abonné avec succès à CodeClan
Parfait ! Ensuite, complétez le paiement pour obtenir un accès complet à tout le contenu premium.
Erreur ! Impossible de s'inscrire. Lien invalide.
Bienvenue ! Vous vous êtes connecté avec succès.
Erreur ! Impossible de se connecter. Veuillez réessayer.
Succès ! Votre compte est entièrement activé, vous avez maintenant accès à tout le contenu.
Erreur ! Le paiement Stripe a échoué.
Succès ! Vos informations de facturation sont mises à jour.
Erreur ! La mise à jour des informations de facturation a échoué.