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
acthelp, 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 GBnode_modulesfolder or raw.githistory 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 viaclient.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.

