Dagger: Programmable CI/CD Pipelines in Code
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:
- Local execution:
dagger call testruns the same pipeline locally as in CI - Language-native: Write pipelines in TypeScript, Go, or Python with full IDE support
- Caching: Dagger caches container layers and outputs — unchanged steps don't re-run
- Composable: Reuse pipeline steps as functions; share modules across projects
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:
- Teams frustrated with YAML CI debugging
- Complex multi-step pipelines with conditional logic
- Need to run full pipelines locally
- Multi-language monorepos
Less suited:
- Simple one-command pipelines (GitHub Actions YAML is fine)
- Teams that prefer declarative configuration over code
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.