DevSecOps

Dagger.io Supply Chain Pipelines

Dagger programmatic pipelines offer genuine supply chain benefits when used well. Here are the patterns and pitfalls from running Dagger in production.

Shadab Khan
Senior Security Engineer
6 min read

Dagger came out of the Docker-creator team as an attempt to make CI pipelines programmable in real languages rather than YAML. I have been running Dagger in production for a healthcare client since early 2024, and we are on Dagger 0.12.x. The supply chain angle on Dagger is genuinely interesting because it is both a pipeline framework and, effectively, a distributed package system for CI logic. Those two roles have different risk profiles, and not everyone who adopts Dagger realizes they are taking on both.

This post is the practitioner's view: what Dagger actually gets right, where the new attack surfaces are, and how to configure a production deployment that survives audit.

What Dagger Is, Structurally

Dagger is three things layered on top of each other:

  1. A query language (GraphQL) that describes pipeline operations: containers to build, files to process, services to run.
  2. A BuildKit-backed execution engine that evaluates the query graph against a local or remote engine.
  3. SDKs (Go, Python, TypeScript, Elixir, Java) that let you write pipelines in real code instead of YAML.

All three matter for supply chain. The query language is mostly hermetic. The BuildKit backend inherits BuildKit's security properties, same as Earthly. The SDKs are where the supply chain trust boundary actually lives, because a pipeline written in Go that imports github.com/some-org/dagger-module is pulling third-party code that runs in the build engine.

A minimal Go pipeline:

package main

import (
    "context"
    "dagger.io/dagger"
)

func main() {
    ctx := context.Background()
    client, err := dagger.Connect(ctx)
    if err != nil {
        panic(err)
    }
    defer client.Close()

    golang := client.Container().
        From("golang:1.22-alpine@sha256:0466223b8544fb7d4ff04748acd4d75a608234bf4e79563bff208d2060c0dd79").
        WithMountedDirectory("/src", client.Host().Directory(".")).
        WithWorkdir("/src")

    build := golang.WithExec([]string{"go", "build", "-o", "payments-api", "./cmd/server"})

    _, err = build.File("/src/payments-api").Export(ctx, "./dist/payments-api")
    if err != nil {
        panic(err)
    }
}

The code looks like a regular Go program. The Dagger SDK compiles it into a GraphQL query that the engine evaluates. Every operation -- From, WithMountedDirectory, WithExec -- becomes a node in the LLB graph.

Module Pinning in Dagger 0.11+

Dagger 0.11 (May 2024) introduced modules as a first-class concept. A module is a reusable pipeline component published to the Daggerverse or a private registry. The dagger.json at the root of a module project looks like:

{
  "name": "payments-pipeline",
  "engineVersion": "v0.12.3",
  "sdk": "go",
  "dependencies": [
    {
      "name": "golangci-lint",
      "source": "github.com/dagger/dagger/modules/golangci-lint@v0.12.3",
      "pin": "ac72f92f65c8a8f6b3e2e96a1c8a4c2d9f1e4b3c"
    },
    {
      "name": "cosign",
      "source": "github.com/sigstore/cosign-dagger@v0.4.1",
      "pin": "8f4e7a2c8d9e1b5f3a6c4b9e2d8f1c7a6b3e4d5f"
    }
  ]
}

The pin field is critical. It is a git SHA that locks the module to a specific commit regardless of what the tag points at. Dagger verifies the pin on every build; if the upstream has rewritten history, the build fails.

Without pinning, Dagger resolves to whatever the tag points at, which is a soft target for tag-hijack attacks. We have a repository-level policy that rejects dagger.json files with unpinned modules. A pre-commit hook parses the file and fails if any dependency is missing a pin field.

The Daggerverse and Third-Party Trust

The Daggerverse (daggerverse.dev) is Dagger's public module registry. It indexes modules published from any public GitHub repository. As of mid-2024, there are roughly 300 published modules, ranging from official Dagger-team modules to community contributions.

The Daggerverse is a soft-trust index. It does not vet modules. A module published to the Daggerverse is exactly as trustworthy as its source repository, which is not always obvious from the Daggerverse UI. Before we added any Daggerverse module to a production pipeline, we:

  1. Reviewed the source repo for maintainer reputation.
  2. Read the module's source code.
  3. Forked the repo internally and pinned to our fork.
  4. Added vulnerability scanning to the forked module's dependencies.

This is the same hygiene you would apply to any third-party library, but the Daggerverse's "just drop this URL in" UX makes it easy to skip. The convenience is a supply chain smell.

BuildKit-Backed Execution

Dagger runs against a BuildKit engine. The engine can be local (auto-provisioned in a container), remote (self-hosted on dedicated hardware), or cloud-hosted (Dagger Cloud's managed engine).

Security properties inherit from BuildKit:

  • Content-addressed caching keyed by LLB graph hash
  • Secret mounts that do not persist in layers
  • Process isolation via runc

For the healthcare client, we run a self-hosted BuildKit engine cluster on GKE with mTLS between clients and workers. The engine pods run in a dedicated namespace with NetworkPolicies restricting egress to approved registries and internal services only.

The engine endpoint config goes in ~/.config/dagger/engine.json:

{
  "engine": {
    "address": "tcp://dagger-engine.internal.corp:4001",
    "tls": {
      "ca": "/etc/ssl/internal-ca.pem",
      "cert": "/etc/ssl/client.pem",
      "key": "/etc/ssl/client.key"
    }
  }
}

Developers use the same config with different client certs tied to their identity. Every build is attributable to a user; unauthorized attempts fail at mTLS.

Caching and Cache Poisoning

Dagger's cache is the BuildKit cache. A shared cache is a powerful accelerator and a non-trivial attack surface. If an attacker can write to the cache, they can inject outputs that clients consume based on hash lookup.

Mitigations we use:

  • Separate cache scopes per trust tier. Pull request builds read from the cache but cannot write. Only protected branch builds on trusted runners write.
  • Cache hash verification. Dagger 0.12 added optional re-verification of cached outputs against expected hashes. Enable it with DAGGER_CACHE_VERIFY=strict.
  • Cache aging. Expire cache entries older than 30 days to bound the blast radius of any undetected poisoning.

Writing Secure Pipelines

A Dagger pipeline is real code. Everything you know about secure code applies. Specific practices we enforce:

// Pin base images by digest, never by tag
const golangImage = "golang:1.22-alpine@sha256:0466223b..."

// Use secret mounts, never ARG or env-var secrets
secret := client.SetSecret("github-token", os.Getenv("GITHUB_TOKEN"))
ctr := client.Container().
    From(golangImage).
    WithSecretVariable("GITHUB_TOKEN", secret)

// Do not execute untrusted host commands
// BAD:  client.Host().Directory(userInput)
// GOOD: client.Host().Directory("./src")

// Use WithServiceBinding for cross-container services, not host networking
db := client.Container().From("postgres:16-alpine@sha256:...").
    WithExposedPort(5432).
    AsService()
app := client.Container().From(golangImage).
    WithServiceBinding("db", db)

The Host() directives are where pipeline code escapes the build sandbox. Treat them with the same suspicion as shell-outs. If a pipeline takes user input that flows into Host().Directory() or Host().File(), you have a local file disclosure or TOCTOU primitive.

Attestations

Dagger 0.11 added SLSA v1.0 provenance generation. Every build can emit an attestation describing the pipeline code, the engine version, the base images used, and the output digests. Combined with cosign signing, this closes the provenance loop.

We generate attestations via a cosign Dagger module and push them to Rekor alongside every image. Deployments verify the attestation chain before running any container.

How Safeguard Helps

Safeguard ingests Dagger pipeline metadata and the module dependency graph from dagger.json to produce CycloneDX 1.5 SBOMs that capture every Dagger module, base image, and output artifact with integrity context. Policy gates can block pipelines that use unpinned modules, reference Daggerverse sources outside an approved allowlist, or skip digest pinning on base images. For teams running self-hosted BuildKit engines, Safeguard correlates cache action digests with source commits to detect cache poisoning and ties SLSA attestations from Dagger to the deployed artifact. The combination extends Dagger's execution model with the module governance and attestation verification needed for regulated production use.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.