Products Consulting About Blog Contact Us Česky
arrow_back Back to blog

Dagger: Promising CI-as-Code (For Backends, Not Mobile)

Dagger: Promising CI-as-Code (For Backends, Not Mobile)

This year I went to Devoxx with colleagues after a long pause — the last time was Devoxx Krakow, a few years back. Aside from a great (and very funny) talk by Josh Long, one session really resonated with me: Paul Dragoonis on Dagger. It’s not a fit for mobile development, but for multi-platform builds — think Intel and ARM architectures — it could simplify things quite a lot.

The Matrix Problem

Every CI pipeline starts simple. Then it grows a matrix.

YAML
strategy:
  matrix:
    os: [macos-latest, windows-latest]
    version: [12, 14, 16]
    environment: [staging, production]
    exclude:
      - os: macos-latest
        version: 12
      # ... a growing list of special cases

This is the slide Paul Dragoonis put up at Devoxx, and it’s the moment the YAML pretense breaks down. What’s really happening here is a Cartesian product (2 × 3 × 2 = 12 jobs), filtered by a special-case list, plus an implicit contract about how each axis flows into the build steps below. It works — until you need any of:

  • A parameter (“run this matrix but with extra debug flags this week”)
  • Shared logic across cells (“every cell needs the same setup, except cell X”)
  • Deduplication (the same matrix appears in three workflow files)
  • Conditional behavior richer than exclude: rules

YAML can’t give you any of these without copy-paste or some templating preprocessor nobody on the team likes. It’s a config file pretending to be a programming language, and the seams show the moment the pipeline grows past trivial.

In Dagger, the matrix is just a list, and the build is just a loop. Here’s the multi-platform Go build straight from the talk:

Python
# platforms to build for and push in a multi-platform image
platforms = [
    dagger.Platform("linux/amd64"),  # a.k.a. x86_64
    dagger.Platform("linux/arm64"),  # a.k.a. aarch64
    dagger.Platform("linux/s390x"),  # a.k.a. IBM S/390
]

for platform in platforms:
    my_container = (
        dag.container(platform=platform)
        .from_("golang:1.20-alpine")
        .with_directory("/src", src)
        .with_directory("/output", dag.directory())
        .with_env_variable("CGO_ENABLED", "0")
        .with_workdir("/src")
        .with_exec(["go", "build", "-o", "/output/hello"])
    )

The matrix is a Python list. The build is a for loop. dag.container(platform=platform) tells Dagger to pull the Go image for that architecture — Buildkit handles the cross-architecture work underneath. Add a target, the loop covers it. Skip one conditionally, it’s just an if. Extract the body into a helper, unit-test it, set breakpoints — it’s just Python.

The same model in TypeScript reads how you’d expect:

TYPESCRIPT
// define build matrix
const gooses = ["linux", "darwin"]
const goarches = ["amd64", "arm64"]

let outputs = dag.directory()

const golang = dag
  .container()
  .from("golang:latest")
  .withDirectory("/src", src)
  .withWorkdir("/src")

// ... nested loop over gooses × goarches, setting GOOS/GOARCH per build

Pick the SDK that matches your team — Python, TypeScript, Java, Go, .NET, PHP. The pipeline keeps the same shape.

The takeaway: the pipeline is no longer a hidden program that only exists when a runner spins up — it’s a real program, invoked by dagger call. The same call works on your laptop and in CI, bit-for-bit identical, because the execution environment is the container, not the runner.

(One asterisk: the os: axis from the original YAML. Dagger handles architecture differences — amd64, arm64, even s390x — cleanly via multi-arch containers, which is genuinely useful for Intel/ARM matrix builds. Host OSes like macOS and Windows are a different story; more on that below.)

What Dagger Actually Is

Dagger has two parts:

  1. An engine that runs everything inside containers, with content-addressable caching and a Buildkit-style DAG underneath.
  2. SDKs for Go, Python, TypeScript, Java, PHP, and .NET that let you describe pipelines as code.

Your pipeline becomes a program. It compiles, you can import libraries, you can write unit tests for it, you can set breakpoints in it. When you run it, the SDK talks to the Dagger engine, which schedules each step as a container operation.

The CI vendor (GitHub Actions, GitLab, etc.) becomes a thin shell: clone the repo, install Dagger, run one command. All the actual logic lives in your repository, in your language.

The Killer Feature: Repeatability

Here’s the scenario Dagger is built for. Your test suite needs:

  • Postgres (with a specific extension installed)
  • Redis
  • A migration step before the tests run
  • Three test groups that can run in parallel

In YAML-land, you write a services: block, hope the CI provider’s networking matches your local Docker Compose, and discover the divergence when something breaks at 2am.

In Dagger, you write this once. Here’s the shape that landed during the Devoxx demo:

Python
import dagger
from dagger import dag, function, object_type, up

@object_type
class Book:

    @function
    @up
    def serve(self) -> dagger.Service:
        """Serves the application"""
        postgresdb = (
            dag.container()
            .from_("postgres:alpine")
            .with_env_variable("POSTGRES_DB", "app")
            .with_env_variable("POSTGRES_PASSWORD", "app_secret")
            .with_exposed_port(5432)
            .as_service(args=[], use_entrypoint=True)
        )
        # ... wire postgresdb into the app container and return the service

Two decorators are doing a lot of work here:

  • @function makes serve callable from the Dagger CLI. CI runs dagger call serve and gets the service.
  • @up makes the same function runnable as a local dev environment. Your laptop runs dagger call serve up and gets Postgres plus the app, with ports forwarded, ready to hit from a browser.

The punchline the speaker landed on: no more docker-compose.yml. The same Python file that defines your CI integration-test setup also defines your local dev stack. One source of truth, one execution model, one container topology — whether you’re hitting cmd+R on your laptop or watching a runner spin up in CI.

That’s the repeatability YAML pipelines can never give you, because YAML pipelines aren’t a thing that exists outside the CI provider’s runner.

What I Liked

Beyond the repeatability story already covered, two things stuck with me after the talk.

The caching. Dagger’s cache is content-addressable — every step has a hash derived from its inputs (parent container state, command, mounted files), so unchanged steps are skipped entirely rather than re-run. With Dagger Cloud the cache is shared across machines, so a run picks up at the layer where the last one — yours or a teammate’s, local or CI — left off. That’s a different model from per-job runner caches in GitHub Actions or GitLab, and on a non-trivial pipeline it adds up fast.

The Dagger Cloud UI is also genuinely neat: a visual DAG of the pipeline, per-step timing, cache hit/miss indicators at a glance. The free tier is generous enough to get real value out of for a small team or a side project. The enterprise plan will cost you — but you can do a lot before you hit that ceiling.

The Daggerverse

Daggerverse is Dagger’s module registry — reusable pipeline modules published by the community. Need to publish a Maven artifact? There’s a module. Need to run Trivy on a container? Module. Need to deploy to Fly.io? Module.

You import them like you’d import a library:

Bash
dagger install github.com/some-org/some-module

This is genuinely new. GitHub Actions has marketplace actions, but they’re YAML-glued bash scripts with no type safety. Dagger modules are typed, composable, and you can read them as code.

What Dagger Doesn’t Solve

I’m developing a fair number of iOS and Android apps these days, and that’s where Dagger’s container-first model hits a hard limit. iOS builds need Xcode, Xcode needs macOS, and macOS doesn’t run inside a Linux container. Until there’s a meaningful iOS-runner story for Dagger (and I’m not holding my breath, given Apple’s tooling), its usefulness in mobile is diminished. Android is a little more forgiving — the SDK runs in Linux — but the toolchain still drags in emulators and signing flows that don’t always live happily in ephemeral containers.

For server-side apps with a Vue (or any web) frontend on top, though, the fit is exactly right. Build, test, integration-test against real services, package the container, deploy — every step is a Linux container Dagger can orchestrate. That’s where I’d reach for it first.

Bottom Line

Dagger is the first CI tool in years that made me reconsider how I write pipelines. The YAML treadmill is a tax everyone pays and nobody questions — pipelines should be code, and Dagger makes that practical without forcing you off your existing CI provider.

It’s not for every workload. If your build is gradle build && deploy, you don’t need a programmable engine — you need a 20-line workflow file. But the moment your pipeline grows services, parallel test groups, conditional steps, or shared logic across repos, Dagger starts paying for itself.

For my next backend project with a non-trivial integration test setup, I’m starting there.


Cover photo by frank mckenna on Unsplash.

More from the Blog