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.
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 casesThis 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:
# 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:
// 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:
- An engine that runs everything inside containers, with content-addressable caching and a Buildkit-style DAG underneath.
- 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:
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 serviceTwo decorators are doing a lot of work here:
@functionmakesservecallable from the Dagger CLI. CI runsdagger call serveand gets the service.@upmakes the same function runnable as a local dev environment. Your laptop runsdagger call serve upand 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:
dagger install github.com/some-org/some-moduleThis 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.


