Cross-Platform CI/CD with Dagger: Programmable Build and Test Workflows Beyond YAML

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

The Frustration of Fragile YAML Workflows

Most DevOps engineers have burned entire afternoons doing the "git push and pray" dance. You commit, wait five minutes for a remote runner to pick up the job, watch it fail on a typo, and repeat. Your commit history quickly fills with messages like fix: trigger pipeline, fix: missing quote in step 4, and debug ci again (final).

Traditional CI systems force you into declarative YAML files that grow out of hand. What starts as a simple 20-line config inevitably mushrooms into a 600-line monster packed with string-interpolated bash commands, custom vendor syntax, and brittle glue code.

This YAML-centric approach creates two stubborn bottlenecks:

  • Zero local reproducibility: You cannot run GitHub Actions, GitLab CI, or CircleCI steps locally on your workstation with true fidelity. Tools like act help, but they frequently behave differently than production runners. Every small tweak requires a remote roundtrip.
  • Vendor lock-in: Moving a pipeline from GitLab to GitHub Actions or AWS CodeBuild usually means rewriting your workflow from scratch. That migration burns weeks of engineering time and introduces configuration drift.

The solution is simple: treat your pipeline like software. Dagger does this by letting you define automation workflows in standard programming languages such as Python, Go, or TypeScript.

How Dagger Rearchitects Delivery Logic

Dagger is a programmable CI/CD engine built on top of Moby BuildKit—the same battle-tested engine powering modern Docker builds. Rather than parsing static YAML definitions, Dagger exposes an embedded engine driven by a typed GraphQL API. You interact with this engine through client SDKs using your preferred language.

Containers as First-Class Software Objects

In Dagger, containers are not just runtime environments. They are immutable objects in your code. You instantiate a base image, mount directories, set environment variables, and chain operations. Each operation returns a fresh container reference, turning pipeline creation into straightforward functional programming.

Declarative DAG and Automatic Caching

BuildKit automatically compiles your function calls into a Directed Acyclic Graph (DAG). If an intermediate operation has not changed—like an untouched node_modules directory or a static binary dependency—Dagger skips execution entirely and reuses cached layers. A build that takes 8 minutes on a cold runner can finish in under 35 seconds once cached, whether it runs on your laptop or inside a remote runner.

A Unified Tooling Ecosystem

Why force your team to learn proprietary CI domain-specific languages when they already know Python or TypeScript? With Dagger, developers use existing IDE tooling. You get auto-completion, linting, type safety, modular imports, and standard unit-testing frameworks out of the box.

Hands-On: Building and Testing a Service with Python and Dagger

Let us build a reproducible pipeline for a Node.js TypeScript project using the Dagger Python SDK. The pipeline will isolate dependencies, run the test suite, and extract production build artifacts.

1. Setting Up the Local Environment

Ensure Docker or a compatible container runtime (such as OrbStack or Podman) is running on your machine. Create a clean workspace and install the SDK:

mkdir dagger-pipeline && cd dagger-pipeline
python3 -m venv .venv
source .venv/bin/activate
pip install dagger-io anyio

2. Writing the Pipeline Script

Create a file named ci.py. We will define an asynchronous workflow that pulls source code, installs packages, runs tests, and exports the compiled distribution folder.

import sys
import anyio
import dagger

async def main():
    # Initialize the Dagger engine connection
    async with dagger.connection(dagger.Config(log_output=sys.stderr)) as client:
        # Exclude local clutter to keep the build context light
        src = client.host().directory(".", exclude=[".venv", "node_modules", ".git", "build-artifacts"])

        # 1. Prepare base Node.js environment
        node = (
            client.container()
            .from_("node:20-alpine")
            .with_directory("/app", src)
            .with_workdir("/app")
        )

        # 2. Install dependencies
        installed = node.with_exec(["npm", "ci"])

        # 3. Run unit tests
        print("==> Running test suite...")
        test_runner = installed.with_exec(["npm", "test"])
        test_output = await test_runner.stdout()
        print(test_output)

        # 4. Generate build bundle and export to host
        print("==> Generating production bundle...")
        built = installed.with_exec(["npm", "run", "build"])
        dist_dir = built.directory("/app/dist")

        await dist_dir.export("./build-artifacts")
        print("Build artifacts successfully written to ./build-artifacts")

if __name__ == "__main__":
    anyio.run(main)

3. Running and Debugging Locally

Execute the pipeline from your terminal:

python ci.py

Dagger connects to BuildKit, executes every step inside isolated containers, and writes the compiled assets straight to ./build-artifacts. When a test fails, you get immediate terminal output with complete stack traces. No waiting in queue for shared cloud runners.

4. Wiring into GitHub Actions

Because your business logic now lives in standard code, your CI provider only needs to invoke Python. Here is the entire .github/workflows/ci.yml file:

name: Continuous Integration

on:
  push:
    branches: [main]
  pull_request:

jobs:
  pipeline:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'

      - name: Install Dagger SDK
        run: pip install dagger-io anyio

      - name: Execute Pipeline
        run: python ci.py

Need to run the exact same workflow in GitLab CI or an internal Jenkins agent? Just run python ci.py inside any Docker-enabled runner. The execution logic does not change by a single line.

Production Best Practices

Moving from experiments to production workloads requires a few architectural guardrails:

  • Trim your build context: Always use explicit exclusions in host().directory(). Uploading a 2 GB node_modules folder or raw .git history to the engine slows down execution and wastes disk I/O.
  • Test pipeline logic with Pytest: You do not need a running Docker daemon to verify basic configuration parameters. Write lightweight unit tests around your argument parsers, build matrix rules, and environment validation.
  • Persist BuildKit caches on ephemeral runners: Cloud CI runners destroy their local disks after every run. Export layer caches to an external OCI registry using Dagger cache flags so pull requests do not rebuild base images from scratch.
  • Protect sensitive credentials: Never pass secrets as raw command-line strings. Use client.set_secret() or read them via client.host().env_variable().secret(). Dagger scrubs these values from logs and omits them from intermediate container snapshots.

Summary

YAML works well for simple key-value parameters. It falls apart when forced to coordinate complex enterprise delivery pipelines. By moving to Dagger, you bring standard engineering practices—modularity, testing, and deterministic caching—to your build systems while wiping out the classic "works on my machine" excuse for good.

Share: