DA

Dagger

Programmable CI/CD engine that runs pipelines as code

CI/CD & Build ★ 16.3k stars Medium setup Apache-2.0

Dagger is an open-source CI/CD engine that lets developers define pipelines as code in their preferred language and run them anywhere with a containerized engine. It can be self-hosted to power portable build pipelines.

Key features

  • Pipelines as code
  • Runs anywhere with containers
  • Multi-language SDKs
  • Built-in caching

Pros & cons

Strengths

  • Pipelines as real code
  • Runs identically anywhere
  • Container-level caching

Trade-offs

  • New concepts to learn
  • Container runtime required

Dagger replaces

Last reviewed Aug 26, 2026 · 846 words

Dagger replaces the YAML in your pipeline, not the server that triggers it. That sentence resolves most of the confusion about self-hosting it: there is no Dagger web UI to install, no runner pool to manage, and it will not watch your Git forge for pushes. What you get is an engine (a BuildKit-based daemon that runs as a container, Apache-2.0, 16,198 stars) and SDKs that let you write "build, test, publish" as ordinary functions in Go, Python or TypeScript, then run them identically on a laptop, on a Woodpecker agent, or inside a Gitea Actions job. If your pain is "the CI config only works on the CI server", Dagger is the fix. If your pain is "I need a CI server", it is not.

A pipeline as a function you can call

Dagger's unit is a module: a small package of functions where each function builds a graph of container operations and the engine executes it. A Python module that runs a test suite reads like this:

import dagger
from dagger import dag, function, object_type

@object_type
class Ci:
    @function
    async def test(self, src: dagger.Directory) -> str:
        return await (
            dag.container()
            .from_("python:3.12-slim")
            .with_directory("/src", src)
            .with_workdir("/src")
            .with_exec(["pip", "install", "-e", ".[test]"])
            .with_exec(["pytest", "-q"])
            .stdout()
        )

dagger init --sdk=python --name=ci scaffolds it; dagger call test --src=. runs it against the working tree. Nothing about that is CI-specific. The same command runs on a developer's machine before pushing, which kills the commit-push-wait loop that makes YAML pipelines expensive to debug. The Go and TypeScript SDKs express the same graph in their own idiom, and functions from other modules can be imported, so a community module for building a Docker image or running a linter is a dependency rather than a copy-pasted script.

Caching is why people stay

Every with_exec is a cached layer in BuildKit, so a step whose inputs have not changed is skipped rather than re-run. Package downloads get their own persistent volumes:

.with_mounted_cache("/root/.cache/pip", dag.cache_volume("pip"))

On a persistent engine that cache survives between runs, and a test job that took 4 minutes cold takes 30 seconds warm. The catch is where the engine lives. By default the CLI starts one inside the local Docker daemon, which is fine on a workstation and wasteful on a CI agent that is created fresh per job. The fix is a long-running engine container on the runner host that jobs connect to with _EXPERIMENTAL_DAGGER_RUNNER_HOST=docker-container://dagger-engine. That one setting is the difference between Dagger being faster than plain YAML on self-hosted CI and being slower.

Pairing it with a self-hosted forge

The forge provides the trigger and the logs; Dagger provides the work. A Woodpecker step needs the Docker socket, which means the repository has to be marked trusted:

steps:
  - name: ci
    image: docker:cli
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    commands:
      - apk add --no-cache curl
      - curl -fsSL https://dl.dagger.io/dagger/install.sh | sh
      - ./bin/dagger call test --src=.

The equivalent on a Gitea Actions runner is the same shell in a job step, or the dagger-for-github action if the runner is compatible with it. Either way the pipeline file shrinks to "install Dagger, call function", and the actual logic lives in versioned code next to the application. The self-hosted Git forges write-up covers picking the forge; the CI/CD category covers the runners it triggers.

The costs: RAM, a Docker dependency, and new concepts

The engine wants 1 GB at minimum and is comfortable with 2 to 4 GB on a runner, plus disk for the cache, which grows until you prune it. It requires a container runtime it can drive, so a runner without Docker socket access cannot use it, and handing CI jobs the Docker socket is a trust decision you should make deliberately. The learning curve is real: containers as values, directories as values, lazy execution and a module system are four ideas at once, and the first week is slower than writing YAML. Dagger Cloud, the hosted trace viewer, has a paid tier; the local terminal output is free and adequate. For a three-step static site build none of this pays off. For a monorepo with 6 services and a pipeline that nobody can run locally, it pays off in the first month.

What I'd do

Keep Woodpecker or Gitea Actions as the trigger. Move any pipeline over 50 lines of YAML into a Dagger module in the Python or Go SDK, whichever the team already writes, and make dagger call test the thing every developer runs before pushing. Run one persistent engine container per runner host and point jobs at it for the cache. Leave the small repositories on plain YAML; Dagger earns its place where the pipeline is the complicated part of the project.

Compare Dagger

10 head-to-head comparisons.

Similar ci/cd & build apps