← All articles
DEVOPS Dagger: Programmable CI/CD Pipelines in Code 2026-03-04 · 3 min read · dagger · ci-cd · pipelines

Dagger: Programmable CI/CD Pipelines in Code

DevOps 2026-03-04 · 3 min read dagger ci-cd pipelines docker github-actions devops typescript go open-source

YAML-based CI/CD pipelines have a persistent problem: they're hard to test locally, they diverge between CI providers, and they grow into unmanageable walls of configuration. Dagger runs CI pipelines in containers using your programming language. The same pipeline that runs in GitHub Actions runs on your laptop with zero changes.

How Dagger Works

Dagger wraps every pipeline step in a container. The container image, commands, environment variables, and outputs are all specified in code. Dagger executes these through its engine (which runs as a container itself).

Benefits:

Getting Started

Install Dagger CLI:

curl -L https://dl.dagger.io/dagger/install.sh | BUN_VERSION=latest sh
# or: brew install dagger/tap/dagger

Initialize a TypeScript project:

dagger init --sdk=typescript my-pipeline
cd my-pipeline

Writing a Pipeline (TypeScript)

src/index.ts:

import { dag, Container, Directory, object, func } from "@dagger.io/dagger";

@object()
export class MyPipeline {
  /**
   * Run tests
   */
  @func()
  async test(source: Directory): Promise<string> {
    return await dag
      .node()
      .withNpm()
      .withSource(source)
      .run(["npm", "test"])
      .stdout();
  }

  /**
   * Build and lint the application
   */
  @func()
  async build(source: Directory): Promise<Container> {
    return await dag
      .node()
      .withNpm()
      .withSource(source)
      .run(["npm", "run", "build"])
      .container();
  }

  /**
   * Full CI pipeline: lint → test → build → publish
   */
  @func()
  async ci(
    source: Directory,
    token: Secret,
  ): Promise<void> {
    // Run lint and test in parallel
    await Promise.all([
      this.lint(source),
      this.test(source),
    ]);

    // Build
    const image = await this.build(source);

    // Push to registry
    await image
      .withRegistryAuth("ghcr.io", "username", token)
      .publish("ghcr.io/my-org/my-app:latest");
  }
}

Run locally:

# Test against current directory
dagger call test --source=.

# Full CI pipeline
dagger call ci --source=. --token=env:GITHUB_TOKEN

A Complete Node.js Pipeline

import {
  dag,
  Container,
  Directory,
  Secret,
  object,
  func,
} from "@dagger.io/dagger";

@object()
export class NodeApp {
  private base(source: Directory): Container {
    return dag
      .container()
      .from("node:20-alpine")
      .withDirectory("/app", source, { exclude: ["node_modules", "dist"] })
      .withWorkdir("/app")
      .withMountedCache("/app/node_modules", dag.cacheVolume("node-modules"))
      .withExec(["npm", "ci"]);
  }

  @func()
  async lint(source: Directory): Promise<string> {
    return await this.base(source)
      .withExec(["npm", "run", "lint"])
      .stdout();
  }

  @func()
  async typecheck(source: Directory): Promise<string> {
    return await this.base(source)
      .withExec(["npx", "tsc", "--noEmit"])
      .stdout();
  }

  @func()
  async test(source: Directory): Promise<string> {
    return await this.base(source)
      .withExec(["npm", "test", "--", "--run"])
      .stdout();
  }

  @func()
  async build(source: Directory): Promise<Container> {
    return await this.base(source)
      .withExec(["npm", "run", "build"]);
  }

  @func()
  async publish(
    source: Directory,
    registryToken: Secret,
    tag: string = "latest",
  ): Promise<string> {
    await Promise.all([
      this.lint(source),
      this.typecheck(source),
      this.test(source),
    ]);

    const built = await this.build(source);

    return await built
      .withRegistryAuth("ghcr.io", "username", registryToken)
      .publish(`ghcr.io/my-org/app:${tag}`);
  }
}

GitHub Actions Integration

Dagger pipelines work with any CI provider. For GitHub Actions:

# .github/workflows/ci.yml
name: CI
on: [push, pull_request]

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Dagger
        uses: dagger/dagger-action@v1
        with:
          version: latest

      - name: Run pipeline
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          dagger call publish \
            --source=. \
            --registry-token=env:GITHUB_TOKEN \
            --tag=${{ github.sha }}

The same dagger call publish --source=. command works locally. No more "works in CI but not locally" debugging.

Dagger Modules

Dagger has a module registry. Reuse community-maintained modules:

// Use the community Go module
@func()
async goBuild(source: Directory): Promise<Container> {
  return await dag.golang().buildContainer({
    source,
    packages: ["./cmd/server"],
  });
}

// Use the Helm module for Kubernetes deployments
@func()
async deploy(chart: Directory, kubeconfig: Secret): Promise<string> {
  return await dag
    .helm()
    .install({
      name: "my-app",
      chart,
      kubeconfig,
      namespace: "production",
    })
    .stdout();
}

Browse modules at daggerverse.dev.

Caching

Dagger's caching is one of its strongest features:

// Mount a cache volume (persists across runs)
const container = dag
  .container()
  .from("golang:1.22")
  .withMountedCache("/go/pkg/mod", dag.cacheVolume("go-mod-cache"))
  .withMountedCache("/root/.cache/go-build", dag.cacheVolume("go-build-cache"));

Cache volumes persist between runs. On CI, Dagger can export/import cache to S3 or GitHub Actions cache.

When to Use Dagger

Strong fit:

Less suited:

Dagger adds complexity upfront (learning Dagger concepts, TypeScript pipeline code) in exchange for more maintainable, testable pipelines long-term. For pipelines beyond ~50 lines of YAML, the trade-off typically favors Dagger.