Skip to content

Dagger / CI as Code - Primer

Why This Matters

CI/CD pipelines are typically defined in YAML specific to one platform — GitHub Actions, GitLab CI, Jenkins. This creates vendor lock-in, makes pipelines hard to test locally, and forces you to express complex logic in a configuration language not designed for it. Dagger solves this by letting you write pipelines as real code (Go, Python, TypeScript) that runs identically on your laptop and in any CI system. Built by Solomon Hykes (the creator of Docker), Dagger uses containers as the execution primitive — every pipeline step runs in a container, cached by content-addressable digests. The result: pipelines that are testable, portable, and composable.

Core Concepts

1. Architecture

Dagger has three layers: - Dagger Engine — a daemon that runs containers and manages caching (uses BuildKit under the hood) - Dagger SDK — language-specific libraries (Go, Python, TypeScript) for defining pipelines - Dagger CLI — command-line tool for running and managing Dagger functions

# Install Dagger CLI
curl -fsSL https://dl.dagger.io/dagger/install.sh | sh

# Verify
dagger version

# The engine starts automatically on first use (runs as a container)

2. Pipeline Definition (Go Example)

// ci/main.go
package main

import (
    "context"
    "dagger/ci/internal/dagger"
)

type Ci struct{}

// Build and test a Go application
func (m *Ci) Build(ctx context.Context, source *dagger.Directory) *dagger.Container {
    return dag.Container().
        From("golang:1.22-alpine").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"go", "build", "-o", "app", "."})
}

func (m *Ci) Test(ctx context.Context, source *dagger.Directory) (string, error) {
    return dag.Container().
        From("golang:1.22-alpine").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"go", "test", "-v", "./..."}).
        Stdout(ctx)
}

// Lint with golangci-lint
func (m *Ci) Lint(ctx context.Context, source *dagger.Directory) (string, error) {
    return dag.Container().
        From("golangci/golangci-lint:latest").
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"golangci-lint", "run", "--timeout", "5m"}).
        Stdout(ctx)
}

// Build and push a container image
func (m *Ci) Publish(
    ctx context.Context,
    source *dagger.Directory,
    registry string,
    username string,
    password *dagger.Secret,
) (string, error) {
    app := m.Build(ctx, source)
    return app.
        WithEntrypoint([]string{"/src/app"}).
        WithRegistryAuth(registry, username, password).
        Publish(ctx, registry+"/myapp:latest")
}

3. Pipeline Definition (Python Example)

# ci/src/main/__init__.py
import dagger
from dagger import dag, function, object_type

@object_type
class Ci:
    @function
    async def test(self, source: dagger.Directory) -> str:
        """Run pytest on the source code."""
        return await (
            dag.container()
            .from_("python:3.11-slim")
            .with_directory("/src", source)
            .with_workdir("/src")
            .with_exec(["pip", "install", "-r", "requirements.txt"])
            .with_exec(["pytest", "-v", "--tb=short"])
            .stdout()
        )

    @function
    async def lint(self, source: dagger.Directory) -> str:
        """Run ruff linter."""
        return await (
            dag.container()
            .from_("python:3.11-slim")
            .with_directory("/src", source)
            .with_workdir("/src")
            .with_exec(["pip", "install", "ruff"])
            .with_exec(["ruff", "check", "."])
            .stdout()
        )

    @function
    def build(self, source: dagger.Directory) -> dagger.Container:
        """Build a production container."""
        return (
            dag.container()
            .from_("python:3.11-slim")
            .with_directory("/app", source)
            .with_workdir("/app")
            .with_exec(["pip", "install", "--no-cache-dir", "-r", "requirements.txt"])
            .with_entrypoint(["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0"])
        )

4. Running Pipelines

# Initialize a Dagger module in your project
dagger init --sdk=go
dagger init --sdk=python
dagger init --sdk=typescript

# List available functions
dagger functions

# Run a function locally
dagger call test --source=.
dagger call build --source=.
dagger call lint --source=.

# Run with arguments
dagger call publish --source=. --registry=ghcr.io/myorg --username=bot \
  --password=env:REGISTRY_TOKEN

# Interactive shell for debugging
dagger call build --source=. terminal

# View the execution plan
dagger call test --source=. --focus=false

5. Caching

Dagger caches aggressively using content-addressable storage. Same inputs produce the same cache key.

// Cache Go module downloads across runs
func (m *Ci) Build(ctx context.Context, source *dagger.Directory) *dagger.Container {
    goCache := dag.CacheVolume("go-mod-cache")
    goBuildCache := dag.CacheVolume("go-build-cache")

    return dag.Container().
        From("golang:1.22-alpine").
        WithMountedCache("/go/pkg/mod", goCache).
        WithMountedCache("/root/.cache/go-build", goBuildCache).
        WithDirectory("/src", source).
        WithWorkdir("/src").
        WithExec([]string{"go", "build", "-o", "app", "."})
}
# Cache pip downloads in Python
@function
async def test(self, source: dagger.Directory) -> str:
    pip_cache = dag.cache_volume("pip-cache")
    return await (
        dag.container()
        .from_("python:3.11-slim")
        .with_mounted_cache("/root/.cache/pip", pip_cache)
        .with_directory("/src", source)
        .with_workdir("/src")
        .with_exec(["pip", "install", "-r", "requirements.txt"])
        .with_exec(["pytest", "-v"])
        .stdout()
    )

6. CI Integration

Dagger runs identically in any CI. The CI system just needs Docker and the Dagger CLI.

GitHub Actions:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dagger/dagger-for-github@v6
        with:
          verb: call
          args: test --source=.
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: dagger/dagger-for-github@v6
        with:
          verb: call
          args: lint --source=.

GitLab CI:

# .gitlab-ci.yml
test:
  image: docker:latest
  services:
    - docker:dind
  before_script:
    - apk add curl && curl -fsSL https://dl.dagger.io/dagger/install.sh | sh
  script:
    - dagger call test --source=.

7. Module Composition

Dagger modules can depend on other modules, creating reusable CI building blocks.

# Install a module dependency
dagger install github.com/purpleclay/daggerverse/golang@v0.5.0

# Use it in your module
dagger install github.com/dagger/dagger/modules/wolfi@main
// Use an installed module
func (m *Ci) Scan(ctx context.Context, source *dagger.Directory) (string, error) {
    image := m.Build(ctx, source)
    return dag.Trivy().
        ScanContainer(image).
        Output(ctx)
}

Quick Reference

# Setup
dagger init --sdk=go               # or python, typescript
dagger develop                      # regenerate SDK code after schema changes

# Run
dagger functions                    # list available functions
dagger call <function> [args]       # run a function
dagger call <function> terminal     # debug with interactive shell

# Key advantages over YAML CI
# 1. Test pipelines locally: dagger call test --source=.
# 2. Same code runs in GitHub Actions, GitLab CI, Jenkins, CircleCI
# 3. Automatic caching with content-addressable storage
# 4. Real programming language: loops, conditionals, error handling
# 5. Type-safe: SDK catches errors at compile time

Wiki Navigation

Prerequisites