Skip to content

Dagger (Polyglot / CI)

A programmable, container-native automation engine: you write your CI/CD pipeline as ordinary Go, Python, TypeScript, PHP, Java, or .NET code that calls Dagger's GraphQL API, and a BuildKit-based engine executes it as a content-addressed DAG of containerized operations — cached automatically and identically whether it runs on your laptop, in CI, or in the cloud.

FieldValue
LanguageGo (~80% of the engine); SDKs in Go, Python, TypeScript, PHP, Java, .NET, Elixir, Rust
LicenseApache-2.0
Repositorydagger/dagger
Documentationdocs.dagger.io · Module configuration · dagger.json schema
CategoryContainer / CI-Oriented
Workspace modelModule graph — each component is a Dagger module (dagger.json); composition is by module dependencies, not a workspace root
First releasedv0.1.0, early 2022 (weekly auto-releases began January 18, 2022)
Latest releasev0.20.8 (May 6, 2026)

Latest release: v0.20.8, published May 6, 2026. The v0.13 line (Sep 2024) introduced what Dagger calls "first-class monorepo support"context-directory access and pre-call filtering (Dagger 0.13 blog); those two mechanisms, not a workspace manifest, are how Dagger addresses the monorepo problem. Recent v0.20.x releases focus on engine-side scaling (dagger generate no longer spikes to 30 GB+ RSS on large module graphs; long withDirectory chains no longer re-materialize at every step). See Caching & remote execution.

IMPORTANT

This is dagger/dagger, the CI/CD automation engine founded by the original Docker team — not Google's google/dagger dependency-injection framework for Java/Android, which is an unrelated project that shares the name.


Overview

What it solves

Dagger sits in a different category from every package-manager- and build-system-centric tool in this survey (Cargo, pnpm, Bazel, Nx). It does not resolve library dependencies, install a node_modules, or compile your code directly. It is a CI/CD engine: its job is to make the glue — "build the image, run the tests, push the artifact, deploy" — reproducible, cacheable, and runnable on a developer laptop instead of only inside a YAML-configured CI runner. Its README states the scope plainly:

"Automation engine to build, test and ship any codebase. Runs locally, in CI, or directly in the cloud."dagger/dagger README

The pain it targets is YAML-pipeline drift: a .github/workflows/*.yml or .gitlab-ci.yml that only runs on the CI provider, can only be debugged by pushing commits, re-runs the whole world on every change, and reimplements caching ad-hoc per-job. Dagger replaces that with code that calls an API, plus an engine that caches at the operation level.

Design philosophy

The core insight is that a CI/CD pipeline should be a regular program calling an API, executed as a content-addressed DAG of containerized operations. Every operation — pulling an image, running a command, copying files — becomes a node in a directed acyclic graph; the engine (a custom fork of BuildKit, the solver behind docker build) executes that graph with automatic caching and parallelism. The Dagger 0.13 announcement frames the monorepo angle of this:

"each logical component in your monorepo can get its own Dagger module, which cleanly encapsulates both the data and pipeline logic necessary to build, test and deploy it."Dagger 0.13: First-class monorepo support

Three consequences flow from this and shape everything below:

  1. Pipelines are modules, not config. A unit of automation is a Dagger module declared by a dagger.json and written in a real SDK language. Modules compose by depending on each other (dagger install), exactly as code libraries do — there is no separate "workspace manifest" concept.
  2. The DAG is content-addressed, so caching is automatic. Because each node is keyed by its inputs (call.ID in the engine's GraphQL layer), changing one file re-runs only the affected nodes; everything else is replayed from cache. This is the same property that makes Turborepo and Bazel fast, except here it is intrinsic to the execution substrate (BuildKit) rather than a bespoke task hasher.
  3. Local ≡ CI. The engine runs as a container; the same module graph executes the same way on a laptop and in a CI runner, eliminating the "works on CI only" debugging loop.

Within this survey Dagger is the canonical container/CI-oriented data point; compare it with the sibling tools Earthly (a Dockerfile-derived DSL with explicit targets) and Garden (Kubernetes-native stack orchestration), and with the task-runner family (Task, Just, mise) that Dagger out-scopes by being containerized and cached. For why dub lacks any of this, see the D landscape notes.


How it works

A Dagger program is a set of Dagger Functions — methods on a module's top-level type — that the engine exposes through a dynamically generated GraphQL schema. You invoke them with dagger call, chaining function outputs into the next call's inputs with a pipe.

The module: dagger.json + an SDK

dagger init scaffolds a module; dagger develop --sdk=<lang> generates the SDK client code. The module's metadata lives in dagger.json:

json
{
  "name": "ci",
  "engineVersion": "v0.20.8",
  "sdk": { "source": "go" },
  "source": "./dagger",
  "dependencies": [
    {
      "name": "hello",
      "source": "github.com/shykes/daggerverse/hello@54d86c6002d954167796e41886a47c47d95a626d"
    }
  ],
  "include": ["!**/testdata/large-fixtures"]
}

The fields (Module configuration, dagger.json schema):

FieldRole
nameModule identifier (defaults to the directory name)
sdkThe SDK language — go, python, typescript, php, java, .net, …
sourcePath to the module's source subdirectory (commonly ./dagger)
engineVersionPins the Dagger API/engine version the module was written against
dependenciesArray of { name, source } records — other modules this one calls
includeExtra include/exclude globs for pre-call filtering of the module's context directory (see below)
blueprintA template module a project inherits its automation from
clientsGenerated-client configuration

Functions are ordinary methods. A Go example:

go
// dagger/main.go
func (m *Ci) Test(
    // +ignore=["*", "!**/*.go", "!go.mod", "!go.sum"]
    source *dagger.Directory,
) *dagger.Container {
    return dag.Container().
        From("golang:1.24").
        WithMountedDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"go", "test", "./..."})
}

The engine: BuildKit, LLB, and DAGql

The pipeline code does not run the containers itself. It calls the Dagger Engine — a daemon running a custom build of BuildKit inside a container — through a GraphQL API. The engine translates each high-level API call into BuildKit's Low-Level Build (LLB) representation and lets BuildKit's solver execute it. The GraphQL layer is Dagger's own server, dagql.Server, which supports dynamic schema modification at runtime so that each installed module can register new types and fields (architecture overview).

The unit of content addressing is call.ID in dagql: every operation is keyed by its inputs, and the engine distinguishes handle IDs (runtime pointers) from recipe IDs (canonical semantic descriptions of an operation), using the latter to decide what must re-execute. Container execution is lazy: withExec builds up metadata (mounts, env, command), defers execution (ContainerExecLazy), and only forces a solve when a result is actually read — so independent branches of the DAG solve in parallel and unchanged branches are skipped entirely.

bash
# A chained pipeline: build a container, add a package, drop into a terminal.
dagger -m github.com/dagger/dagger/modules/wolfi@v0.16.2 \
    call container --packages="cowsay" terminal

The five dimensions below analyze where this model lands relative to the rest of the catalog.

1. Workspace declaration & topology

Dagger has no workspace root manifest in the sense Cargo's [workspace], go.work, or pnpm's pnpm-workspace.yaml have one. There is no glob array enumerating members. Instead, topology is the module dependency graph: each component is a module with its own dagger.json, and the monorepo's structure is expressed by which modules dagger install which others. The docs describe the recommended pattern (Monorepos best practices):

"create a top-level Dagger module for the monorepo, attach sub-modules for each component of the monorepo, and model the Dagger module dependencies on the logical dependencies between components."

So the "workspace" is a tree of modules rooted at a top-level orchestrator module that imports per-component sub-modules — discovery is explicit, by dependency edge, not by directory globbing. An alternative the docs also endorse is a single shared automation module that every project imports, which "reduces code duplication and ensures a consistent CI environment for all projects."

Discovery of the context is by dagger.json location: for a Git repo the context directory is the repository root (for absolute paths) or the directory containing dagger.json (for relative paths); outside Git it is the dagger.json directory. For security, "it is not possible to retrieve files or directories outside the context directory" (Directory filters).

NOTE

Because there is no member-enumerating root, Dagger does not "know" your whole repo the way Nx or Bazel do. It knows the module graph you wired by hand. A new component is integrated by giving it a module and dagger install-ing it into the parent — not by matching a members = ["libs/*"] glob.

2. Dependency handling & isolation

Two distinct dependency notions coexist, and it is important not to conflate them:

  • Module dependencies (Dagger's own). dagger install <ref> adds another Dagger module so your code can call its functions. The reference grammar is [proto://]host/repo[/subpath][@version] (Module dependencies); the /subpath segment is explicitly "optional subdirectory for monorepos." An install writes a pinned record into dagger.json:

    json
    "dependencies": [
        {
            "name": "hello",
            "source": "github.com/shykes/daggerverse/hello@54d86c6002d954167796e41886a47c47d95a626d"
        }
    ]

    Remote refs pin to a commit SHA (resolved from a tag/branch at install time); the dependent module is "added to the code-generation routines and can be accessed from your own module's code."

  • Local cross-module references. A module can depend on another module in the same Git repository by relative path:

    bash
    dagger install ./path/to/component

    This is the Dagger analogue of Yarn's workspace: protocol or a Cargo path = dependency — it lets sibling components reference each other without publishing. The constraint, per the docs, is that it "is only possible if your module is within the repository root (for Git repositories)."

Isolation of language-level dependencies (your app's pip/npm/cargo packages) is handled by the container model, not by a symlink tree or virtual store: each operation runs in its own container filesystem, and package downloads are kept warm with cache volumes (next section). There is no hoisting and no shared node_modules; isolation is the container boundary itself.

3. Task orchestration & scheduling

Orchestration is the strongest part of Dagger's design, and it is structural rather than declarative. There is no turbo.json-style task list with dependsOn rules (Turborepo) and no BUILD target graph (Bazel). Instead:

  • The DAG is built implicitly from data flow. Every API call (From, WithMountedDirectory, WithExec, …) is a node; an edge exists wherever one operation consumes another's output. The engine — BuildKit — "executes this graph with automatic caching and parallelism." Independent legs run concurrently with no user annotation; the topological order falls out of the data dependencies.
  • Cross-component orchestration is code. Because a top-level module imports sub-modules and calls their functions, "the top-level module of a project can orchestrate the sub-modules using the language's native concurrency features" (Monorepos best practices) — e.g. Go goroutines or Python asyncio.gather. Concurrency is whatever the SDK language offers, not a --jobs N flag.
  • Change detection is content hashing, not git-diff. Dagger does not compute an "affected set" from a git ref the way Turborepo's --affected or Nx's affected do. Instead, pre-call filtering plus content-addressing achieves the same effect at the operation level: a directory is filtered before upload, so "minor unrelated changes in the source directory don't invalidate Dagger's build cache", and only operations whose hashed inputs actually changed re-run. The 0.13 release pairs this with context-directory access so a function for one component only ever sees — and only re-runs on — that component's files.

NOTE

The practical upshot for monorepos, per the docs: "Even if unnecessary CI jobs are triggered, Dagger's layer cache allows most to finish almost instantly, as it quickly determines there's nothing to run." (Monorepos best practices). Affected-detection is emergent from caching rather than an explicit graph query.

4. Caching & remote execution

Caching is the engine's reason for existing, and it operates at two layers:

  • Operation / layer cache (automatic). Every DAG vertex is content-addressed, so "if you've already built a particular step with the same inputs, BuildKit skips it entirely," caching at the operation level across the whole graph. This is the BuildKit layer cache, working "automatically across local runs and CI." No configuration; it is intrinsic.

  • Cache volumes (explicit). A CacheVolume (dag.CacheVolume("go-mod"), mounted via WithMountedCache) "represents a directory whose contents persist across Dagger sessions." It is the idiomatic way to keep package-manager caches (npm, pip, maven, cargo) warm across runs. Under the hood it uses the same BuildKit primitive — llb.AsPersistentCacheDir() — that backs RUN --mount=type=cache in a Dockerfile (cache-volumes discussion).

    go
    func (m *Ci) Build(source *dagger.Directory) *dagger.Container {
        return dag.Container().
            From("golang:1.24").
            WithMountedDirectory("/src", source).
            WithMountedCache("/go/pkg/mod", dag.CacheVolume("go-mod")). // persists across sessions
            WithWorkdir("/src").
            WithExec([]string{"go", "build", "./..."})
    }

Remote / distributed caching is where the CI-oriented nature shows its seams. The persistent cache dir is "stored internally in BuildKit, which works great locally, however it's not persistent across CI runs (because we get a different BuildKit instance each time)" (persist-cache-volumes issue). The intended solutions are (a) import/export of cache contents"the same experience as cache imports/exports … for normal container layers," most useful with ephemeral buildkitds in CI — and (b) running a shared, persistent Dagger Engine (with persistent volumes or object-storage-backed caches, e.g. on Kubernetes) that all CI jobs connect to. Dagger Cloud layers observability (traces of cache imports/exports and whether caches were hit) on top. Dagger does not implement the Remote Execution API (REAPI) that Bazel/Buck2 backends like Buildbarn/NativeLink speak — its remote story is BuildKit cache import/export plus a shared engine, not REAPI-style action farming.

5. CLI / UX ergonomics

The command boundary is function-centric, not target- or filter-centric:

  • dagger call <function> [flags] invokes a Dagger Function; its arguments become CLI flags. Names are converted to a shell-friendly kebab-case (MyFunctionmy-function, gitRef--git-ref) (Using the Dagger CLI).

  • Chaining with |. Function outputs pipe into the next call — dagger call container --packages=cowsay terminal — which the docs call "one of Dagger's most powerful features." This is the closest analogue to a task pipeline, expressed inline rather than in config.

  • Module selection with -m. dagger -m <ref> call … runs a function from a remote or local module; the 0.13 dagger core command runs a function from the built-in Core API with no module to load.

  • Directory/Git arguments. A Directory-typed flag accepts "a local filesystem path or a remote Git reference," so the same pipeline can be pointed at a working tree or a tagged commit without code changes.

  • Per-argument filtering. The +ignore annotation (gitignore syntax) on a Directory argument controls exactly which files upload, both for performance and to keep unrelated edits from busting the cache (Directory filters):

    go
    // +ignore=["*", "!**/*.go", "!go.mod", "!go.sum"]
    source *dagger.Directory

    Order is significant: "the pattern "**", "!**" includes everything but "!**", "**" excludes everything."

There is no --filter, -p, :target, or --since vocabulary here — the selection unit is which function on which module you call, and "which component" is resolved by the directory/module you point it at, not by a package-filter flag. This is the inverse of Turborepo / pnpm ergonomics, and a direct consequence of pipelines being code rather than a declared task matrix.


Strengths

  • Local ≡ CI by construction. The same containerized module graph runs on a laptop and in CI, eliminating push-to-debug loops — the headline value over plain YAML pipelines and over task runners like Task/Just.
  • Automatic, intrinsic caching. Content-addressed operation caching is free and always on (BuildKit), not a bolt-on hasher; cache volumes keep package managers warm.
  • Real languages, real composition. Pipelines are Go/Python/TS/etc. code; modules compose like libraries (dagger install), including local cross-repo references by relative path and remote modules pinned to a SHA.
  • Polyglot and engine-agnostic to the codebase. Dagger doesn't care what language your app is — it orchestrates containers, so it can drive a D/dub build, an npm build, and a Maven build from one module graph.
  • Pre-call filtering for monorepos. Per-component context directories plus +ignore mean unrelated edits don't invalidate a component's cache and only its files upload — emergent affected-detection without an explicit graph query.
  • Implicit parallelism. Independent DAG branches solve concurrently with zero user annotation.

Weaknesses

  • Not a package manager or build system. Dagger resolves no library dependencies, produces no lockfile, and has no notion of a workspace root — it is orthogonal to Cargo/dub/pnpm, not a replacement. For dub's workspace problem it offers orchestration patterns, not manifest primitives.
  • No declarative workspace topology. Structure is hand-wired module dependencies; there is no member glob, no single-command "build everything that changed," and no affected-from-git-ref query (--since/--affected) — change detection is emergent from caching, which is harder to reason about explicitly.
  • Remote cache is BuildKit import/export, not REAPI. Cache persistence across ephemeral CI runs requires a shared engine or explicit cache import/export; cache volumes are not persistent across CI BuildKit instances out of the box. No REAPI/distributed-action-execution like Bazel/Buck2.
  • Engine/daemon required. Every run needs a running Dagger Engine container (Docker/Kubernetes), a heavier operational footprint than a static task runner.
  • CLI selection is function/path-based. No --filter pkg... / -p / :target ergonomics; "which component" is encoded in the module/path you call, which is less discoverable than a package-filter flag.
  • Scaling has been a moving target. Large module graphs historically stressed the engine (dagger generate RSS spikes, withDirectory re-materialization) — actively fixed through v0.20.x, but evidence the model is demanding at scale.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Pipelines as code calling a GraphQL API (vs YAML config)Real languages, testing, abstraction, and IDE support; same code runs locally and in CIAn engine/daemon must run; a learning curve beyond editing a YAML file
BuildKit/LLB content-addressed DAG as the substrateAutomatic operation-level caching and parallelism come for free, identical local↔CITied to container semantics; everything is an op in a container, even trivial steps
Modules compose by dependencies (vs a workspace root)Components encapsulate their own automation; reuse is library-like, including across reposNo member-enumerating manifest; no whole-repo view; topology is hand-wired, not globbed
Local module refs by relative path (within the repo)workspace:-style local-first composition without publishingConstrained to the repo root; not a general cross-repo path mechanism
Change detection via content hashing + pre-call filteringUnrelated edits don't bust a component's cache; affected work is skipped at the op levelNo explicit git-ref affected query (--since/--affected); "what will run" is emergent, opaque
Cache volumes on BuildKit AsPersistentCacheDir (vs REAPI)Reuses Docker's proven --mount=type=cache primitive; warm package cachesNot persistent across ephemeral CI BuildKit instances without a shared engine or cache import/export
Remote story = shared engine + cache import/export (no REAPI)Pragmatic reuse of BuildKit's cache transport; Dagger Cloud adds observabilityNo distributed action execution / REAPI farm like Bazel/Buck2 backends
Function-centric CLI (dagger call, chaining) in kebab-caseA uniform, shell-friendly surface generated from the module schemaNo package-filter (--filter/-p/:target) selection vocabulary

Sources