Automation

Use Dagger with GitHub Actions: CI/CD Pipeline Guide

Running Dagger inside GitHub Actions gives you the best of both worlds: GitHub handles triggers, runners, and PR status checks while Dagger handles the actual pipeline logic in typed, testable code. The official dagger/dagger-for-github action makes this straightforward.

Original content from computingforgeeks.com - post 165363

This guide sets up a complete CI/CD pipeline that tests a Go application, builds a container image, and pushes it to a registry. Every step uses Dagger functions called from a GitHub Actions workflow. The pipeline logic is portable (it runs the same locally and in CI), and the Dagger Engine’s content-addressed caching speeds up repeated runs.

Current as of April 2026. Dagger v0.20.3, dagger-for-github v8.4.1, GitHub Actions Ubuntu runners.

Prerequisites

  • A GitHub repository with a containerized application (this guide uses Go, but any language works)
  • Dagger CLI installed locally for testing (see the Ubuntu installation guide)
  • A container registry account (Docker Hub, GitHub Container Registry, or any OCI registry)
  • Docker installed locally for running Dagger functions during development

1. Set Up the Application

Create a simple Go web server. If you already have an application, skip to the Dagger module setup.

mkdir my-app && cd my-app
git init

Create the Go source:

cat > main.go << "EOF"
package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello from Dagger CI!")
    })
    fmt.Println("Listening on :8080")
    http.ListenAndServe(":8080", nil)
}
EOF

cat > go.mod << "EOF"
module my-app
go 1.22
EOF

2. Create the Dagger Module

Initialize a Dagger module with the Python SDK. This creates the pipeline logic that both your local machine and GitHub Actions will execute:

dagger init --name=my-app --sdk=python

Open the generated pipeline file and write functions for testing, building, and publishing:

vi .dagger/src/my_app/main.py

Replace the contents with:

import dagger
from dagger import dag, function, object_type


@object_type
class MyApp:
    @function
    async def test(self, source: dagger.Directory) -> str:
        """Run Go tests and vet"""
        return await (
            dag.container()
            .from_("golang:1.22-alpine")
            .with_mounted_directory("/src", source)
            .with_workdir("/src")
            .with_exec(["go", "vet", "./..."])
            .with_exec(["go", "test", "./..."])
            .stdout()
        )

    @function
    async def build(self, source: dagger.Directory) -> dagger.Container:
        """Build the Go binary"""
        return (
            dag.container()
            .from_("golang:1.22-alpine")
            .with_mounted_directory("/src", source)
            .with_workdir("/src")
            .with_exec(["go", "build", "-o", "/usr/local/bin/app", "."])
        )

    @function
    async def publish(
        self,
        source: dagger.Directory,
        registry: str,
        username: str,
        password: dagger.Secret,
        tag: str = "latest",
    ) -> str:
        """Build and push a container image to a registry"""
        build = await self.build(source)
        return await (
            dag.container()
            .from_("alpine:latest")
            .with_file("/usr/local/bin/app", build.file("/usr/local/bin/app"))
            .with_entrypoint(["/usr/local/bin/app"])
            .with_exposed_port(8080)
            .with_registry_auth(registry, username, password)
            .publish(f"{registry}/{username}/my-app:{tag}")
        )

The publish function takes registry credentials as typed arguments. The dagger.Secret type ensures the password is never logged in plaintext.

3. Test Locally First

Run the pipeline on your machine before pushing to GitHub. This is one of Dagger's core advantages: you catch pipeline errors locally instead of waiting for CI.

dagger call test --source=.

If the tests pass, try the build:

dagger call build --source=.

Both commands execute inside containers, using the same images and caching that GitHub Actions will use. If it works here, it works in CI.

4. Create the GitHub Actions Workflow

The workflow file is thin because all pipeline logic lives in the Dagger module. GitHub Actions only handles checkout, secret injection, and triggering:

mkdir -p .github/workflows

Create the workflow file:

vi .github/workflows/ci.yml

Add the following workflow definition:

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    name: Test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run tests
        uses: dagger/dagger-for-github@v8
        with:
          verb: call
          args: test --source=.

  build-and-push:
    name: Build and Push
    runs-on: ubuntu-latest
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    steps:
      - uses: actions/checkout@v4

      - name: Publish image
        uses: dagger/dagger-for-github@v8
        with:
          verb: call
          args: >-
            publish
            --source=.
            --registry=docker.io
            --username=${{ vars.DOCKER_USERNAME }}
            --password=env:REGISTRY_PASSWORD
            --tag=${{ github.sha }}
        env:
          REGISTRY_PASSWORD: ${{ secrets.DOCKER_TOKEN }}

Key points about this workflow:

  • The test job runs on every push and pull request. It calls the Dagger test function.
  • The build-and-push job only runs on pushes to main (not on PRs). It calls the publish function.
  • Secrets are passed using the env: prefix. Setting REGISTRY_PASSWORD as an environment variable and referencing it as --password=env:REGISTRY_PASSWORD tells Dagger to read the value from the environment. The dagger.Secret type prevents it from appearing in logs.
  • The image tag uses the Git commit SHA (${{ github.sha }}) for traceability.

5. Configure GitHub Secrets

In your GitHub repository, go to Settings > Secrets and variables > Actions and add:

NameTypeValue
DOCKER_TOKENSecretYour Docker Hub access token (or GHCR token)
DOCKER_USERNAMEVariableYour Docker Hub username

For GitHub Container Registry (GHCR), use --registry=ghcr.io, --username=${{ github.actor }}, and set REGISTRY_PASSWORD to ${{ secrets.GITHUB_TOKEN }} (automatically available in every workflow).

6. Connect Dagger Cloud (Optional)

Dagger Cloud provides pipeline visualization, trace debugging, and distributed caching. The free tier includes 1 million events per month.

Sign up at dagger.cloud, generate an API token, and add it as a GitHub secret named DAGGER_CLOUD_TOKEN. Then add the cloud-token input to each Dagger step:

      - name: Run tests
        uses: dagger/dagger-for-github@v8
        with:
          verb: call
          args: test --source=.
          cloud-token: ${{ secrets.DAGGER_CLOUD_TOKEN }}

With Dagger Cloud connected, every pipeline run generates a trace URL in the action output. The trace shows the full execution DAG, timing for each operation, and cache hit/miss details.

Dagger Action Input Reference

The dagger/dagger-for-github action supports these inputs:

InputDescriptionDefault
verbCLI verb: call, run, functions, shell, checkcall
argsArguments passed to the CLI verb""
versionDagger CLI version (vX.Y.Z or latest)latest
cloud-tokenDagger Cloud authentication token""
moduleModule reference (local path or Git URL)""
workdirWorking directory.
dagger-flagsExtra CLI flags--progress plain

The action also provides two outputs: output (stdout from the command) and traceURL (Dagger Cloud trace link).

Caching on GitHub Actions Runners

GitHub's standard runners are ephemeral. When the runner terminates, the Docker volumes (including Dagger's cache) are destroyed. This means every run starts with a cold cache.

Three options to solve this:

  • Dagger Cloud ($50/mo Team plan): distributes the cache across 26 global regions. Ephemeral runners get cache hits because the cache is stored in Dagger Cloud, not on the runner
  • Self-hosted runners: Docker volumes persist between jobs if you manage your own runners. The Dagger Engine stays running and its cache survives job boundaries
  • Larger runners with persistent storage: GitHub's larger runners or third-party providers like Depot offer persistent cache volumes

For most teams, Dagger Cloud is the simplest option. The free tier works for individual developers. The $50/mo Team plan covers up to 10 users with distributed caching.

Using Remote Modules from Daggerverse

You don't have to write every function from scratch. The Daggerverse has 1,500+ public modules you can call directly from your workflow. For example, to use a community Go module:

      - name: Test with remote module
        uses: dagger/dagger-for-github@v8
        with:
          verb: call
          module: github.com/kpenfound/dagger-modules/[email protected]
          args: test --source=.

The module input points to a Git-hosted Dagger module. The action downloads it, loads its functions, and executes the specified function against your source code.

Production Hardening

Pin the Dagger version

Using version: latest is fine for testing. In production, pin to a specific version to avoid unexpected changes:

        with:
          version: "0.20.3"

Use GITHUB_TOKEN for GHCR

For GitHub Container Registry, you don't need to create a separate token. The built-in GITHUB_TOKEN works with the right permissions:

permissions:
  packages: write

steps:
  - name: Publish to GHCR
    uses: dagger/dagger-for-github@v8
    with:
      verb: call
      args: >-
        publish
        --source=.
        --registry=ghcr.io
        --username=${{ github.actor }}
        --password=env:GITHUB_TOKEN
        --tag=${{ github.sha }}
    env:
      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Add timeout and concurrency controls

Prevent runaway builds and redundant pipeline runs:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    timeout-minutes: 15
    runs-on: ubuntu-latest

The concurrency setting cancels in-progress runs when a new commit is pushed to the same branch. The timeout prevents hung builds from consuming unlimited runner minutes.

For a deeper comparison of when Dagger adds value versus using GitHub Actions alone, see our Dagger vs GitHub Actions comparison.

Related Articles

Containers Fix “error: Metrics API not available” in Kubernetes Automation Fix GitLab Local Network Requests Blocked Error Automation Deploy OpenContrail on KVM with Ansible Ansible Manage Docker Containers with Ansible on Rocky Linux and Ubuntu

Leave a Comment

Press ESC to close