Dagger takes a fundamentally different approach to CI/CD. Instead of writing YAML configurations for a specific platform, you write pipelines as code in Go, Python, or TypeScript. Each pipeline step runs in a container. The same pipeline runs identically on your laptop, in GitHub Actions, in GitLab CI, or anywhere else that has a container runtime.
From a security perspective, this architectural decision has significant implications — mostly positive, but with new considerations that traditional CI/CD platforms do not present.
The Security Case for Containerized Pipelines
Traditional CI/CD platforms have a shared infrastructure problem. Your builds run on shared runners with shared caches, shared network access, and shared filesystem state. The isolation between builds depends on the platform's implementation, which varies from solid (ephemeral VMs) to minimal (shared Docker daemons).
Dagger changes this equation. Every pipeline step runs in its own container, managed by BuildKit. Steps communicate through explicit data dependencies, not shared filesystems. The pipeline's execution graph is determined by your code, not by the platform's interpretation of YAML.
Hermetic Builds
A Dagger pipeline explicitly declares its inputs. If a step needs a file, a dependency, or a secret, it must be passed in programmatically:
func (m *MyPipeline) Build(ctx context.Context, source *Directory) *Container {
return dag.Container().
From("golang:1.21").
WithDirectory("/src", source).
WithExec([]string{"go", "build", "-o", "/app", "."})
}
The build container starts from a known base image, receives the source directory as an explicit input, and produces an output. There is no ambient environment to leak secrets from, no cache from a previous job to poison, and no shared filesystem to tamper with.
This hermeticity is not absolute — containers can still make network requests, and BuildKit has its own caching layer — but it is dramatically better than traditional CI/CD environments.
Platform Independence Reduces Lock-In Risk
When your pipeline is coupled to a specific platform (GitHub Actions, GitLab CI, Jenkins), your security posture is tied to that platform's security model. If CircleCI is breached, your CircleCI pipelines are compromised. If GitHub Actions has a vulnerability, your GitHub workflows are affected.
Dagger pipelines are platform-agnostic. Your pipeline code runs the same way everywhere. This means:
- You can migrate between platforms without rewriting security controls.
- Your security scanning, secret management, and artifact signing logic is portable.
- A platform-specific vulnerability does not necessarily compromise your pipeline logic — only the thin wrapper that triggers Dagger.
# GitHub Actions just triggers Dagger
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run Dagger Pipeline
uses: dagger/dagger-for-github@v5
with:
verb: call
args: build --source=.
The GitHub Actions workflow is minimal. The actual pipeline logic — including security checks — lives in your Dagger module.
Secret Management in Dagger
Dagger has a first-class Secret type that prevents accidental exposure:
func (m *MyPipeline) Deploy(
ctx context.Context,
source *Directory,
registryPassword *Secret,
) error {
_, err := dag.Container().
From("docker:latest").
WithSecretVariable("REGISTRY_PASSWORD", registryPassword).
WithExec([]string{"docker", "login", "-u", "deploy", "-p", "$REGISTRY_PASSWORD"}).
Sync(ctx)
return err
}
Key properties of Dagger secrets:
- Secrets are never included in the container's filesystem layer.
- Secrets are not visible in build logs or cache keys.
- Secrets are passed through a dedicated channel, not environment variable inheritance.
- The Dagger engine scrubs secrets from output.
This is meaningfully better than the env-var-based secret injection most CI/CD platforms use, where secrets can be accidentally logged, cached, or leaked through process environment inspection.
Supply Chain Security Integration
Because Dagger pipelines are code, you can integrate supply chain security tooling directly:
func (m *MyPipeline) SecureBuild(ctx context.Context, source *Directory) *Container {
// Scan dependencies
scanned := dag.Container().
From("aquasec/trivy:latest").
WithDirectory("/src", source).
WithExec([]string{"trivy", "fs", "--exit-code", "1", "--severity", "HIGH,CRITICAL", "/src"})
// Build after scan passes
built := dag.Container().
From("golang:1.21").
WithDirectory("/src", source).
WithExec([]string{"go", "build", "-o", "/app", "."})
// Sign the resulting image
signed := dag.Container().
From("gcr.io/projectsigstore/cosign:latest").
WithExec([]string{"cosign", "sign", "--key", "cosign.key", "registry.example.com/app:latest"})
return built
}
The dependency scanning, building, and signing steps are explicit code with explicit data flow. If the scan fails, the build does not proceed. There is no YAML configuration to misconfigure or bypass.
Caching Security
BuildKit's caching layer is powerful but has security implications:
- Cache layers may contain sensitive data if not handled carefully. Use Dagger's Secret type instead of baking secrets into container layers.
- Shared caches between Dagger runs on the same machine can be poisoned. Use separate BuildKit instances for different trust levels.
- Remote caching (pushing cache to a registry) requires securing the registry and trusting the cache source.
// Cache dependencies separately from application code
func (m *MyPipeline) Build(ctx context.Context, source *Directory) *Container {
goCache := dag.CacheVolume("go-mod-cache")
return dag.Container().
From("golang:1.21").
WithMountedCache("/go/pkg/mod", goCache).
WithDirectory("/src", source).
WithExec([]string{"go", "build", "-o", "/app", "."})
}
Cache volumes are isolated per Dagger module by default, reducing cross-project contamination.
Testing Pipeline Security Locally
One of Dagger's strongest security benefits is local testability. You can run your entire pipeline on your development machine:
dagger call build --source=.
dagger call security-scan --source=.
dagger call deploy --source=. --registry-password=env:REGISTRY_PASSWORD
This means:
- Security engineers can review and test pipeline changes before they reach CI.
- Pipeline changes can be validated in isolation without pushing to a shared CI environment.
- Security scanning configurations can be tuned locally without burning CI minutes.
Limitations to Consider
Dagger is not a silver bullet:
- BuildKit daemon access: Dagger requires a BuildKit daemon. On shared CI runners, this may mean a shared daemon with shared caches.
- Container escapes: While rare, container escapes in BuildKit would compromise the pipeline. Keep BuildKit updated.
- Network access: Containers in Dagger can make arbitrary network requests by default. Network policies depend on the host environment.
- Module trust: Dagger modules from external sources carry supply chain risks similar to GitHub Actions or npm packages. Vet before use.
How Safeguard.sh Helps
Safeguard.sh integrates with Dagger pipelines to provide security monitoring that spans the pipeline's entire lifecycle. It tracks which Dagger modules your pipelines depend on, monitors for vulnerabilities in base images used by pipeline steps, and ingests SBOM and vulnerability scan results regardless of which CI/CD platform triggers the Dagger run. Because Dagger pipelines produce consistent artifacts across platforms, Safeguard.sh can enforce consistent security policies — ensuring the same standards apply whether a pipeline runs locally, in GitHub Actions, or in your self-hosted infrastructure.