CI that keeps up

How do I migrate from GitLab CI to Dagger?

You do not have to rewrite your GitLab CI jobs as Dagger code. Dagger’s official modules find the tests, linters, and builds in your repository and run them as checks. The checks run the same way on your machine and in CI. A small Dang module holds the steps that no official module covers, such as a migration check or a deploy. For a repository on GitLab, GitLab CI stays the trigger, and one job runs dagger check. Move in five steps:

  1. Install the modules.
  2. Run dagger check locally.
  3. Add one GitLab job that runs dagger check beside your current jobs.
  4. Compare the results.
  5. Remove the old jobs one at a time.

Do I have to rewrite my pipelines?

No. You do not rewrite the work that an official module does. A Dagger module is a package of functions that add work to your project, such as tests or lint. The Dagger team maintains official modules for common tools. Each official module finds its tool’s files in your repository. For example, the Go module finds each go.mod and makes the tests of each package a check. A check is a function that passes or fails, such as a test run or a linter (Checking your code).

Today the official modules cover Go, golangci-lint, Staticcheck, Deno, Jest, Vitest, Playwright, ESLint, Prettier, Biome, pytest, ShellCheck, PSScriptAnalyzer, and Helm (module reference). You do not copy the image:, script:, and stage: lines of your .gitlab-ci.yml into Dagger.

You write code only for work that no official module does. This guide uses a Go project with this pipeline as its example:

stages: [lint, test, deploy]

lint:
  stage: lint
  image: golangci/golangci-lint:v2.1
  script:
    - golangci-lint run

test:
  stage: test
  image: golang:1.24
  script:
    - go test ./...

check-migrations:
  stage: test
  image: alpine:3.22
  script:
    - sh scripts/check-migrations.sh

deploy:
  stage: deploy
  image: alpine:3.22
  script:
    - sh scripts/deploy.sh production
  environment: production
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Official modules replace the lint and test jobs. The check-migrations and deploy jobs become functions in your own module.

Install the modules and run the checks locally

Install the Dagger CLI. Then go to the root of your repository and create the workspace:

dagger init

The workspace is your repository’s Dagger configuration, in a file named dagger.toml (Quickstart). In a terminal, dagger init asks which modules to install. Without a terminal, it prints the next commands:

Workspace configuration initialized at ./dagger.toml

To continue setup, run these commands in order:

#!/bin/sh

# 1. Find and install suitable modules
dagger module recommend

# 2. Enable cloud checks
dagger cloud checks on

The second command is for GitHub repositories. The section Where the checks run after the move explains what to do for a GitLab repository. Install the modules that Dagger recommends. Add -y to install all of them with no prompt:

dagger module recommend -y

In the example project, the command installs the Go and golangci-lint modules:

✔ dagger module install dagger.io/go 0.2s
✔ dagger module install dagger.io/go/golangci-lint 0.3s

The command also writes dagger.lock, which pins each module and image to an exact version (Workspaces). Commit it with dagger.toml. If Dagger recommends no module for one of your tools, search for one by the tool’s name, for example dagger module search eslint.

List the checks that the modules found:

dagger check -l
MODULE         GO-PACKAGE  GO-TEST  GOLANGCI-LINT-PROJECT  CHECK  DESCRIPTION
go             *                                           stale  staleness check: run go generate in this package
go             *           *                               test   Run this test.
golangci-lint                       *                      lint   Run golangci-lint in this project.
# Use --all to expand collections and list each item.

A * stands for each item that the module found, such as each package or each test. Run all the checks with dagger check. The command exits with a non-zero code when a check fails, so a GitLab job that runs it fails too. To run one test, select it by name:

dagger check --go-test=TestEmpty
CHECKS  ✔ 1 passed
✔ dag://?check=test&go-package=.&go-test=TestEmpty 1.4s OK
  TESTS
    ✔ example.com/shop  1 passed

    ✔ 1 passed

Compare this list with your jobs. Later, you remove each job that a check replaces.

Move custom jobs into a Dang module

Put each job that no official module covers into a function of your project’s dev module. Dang is Dagger’s native language for modules. You can also write modules in Go, Python, TypeScript, Java, PHP, or Elixir (SDKs). Install the Dang SDK and create the module:

dagger module install dagger.io/sdk/dang
dagger module init dang -y

The command creates .dagger/modules/shop-dev, named after the project directory, and makes it the main module of the workspace. In the code below, shop-dev and ShopDev stand in for the names that dagger module init generated. Replace .dagger/modules/shop-dev/main.dang with this code:

"""
Steps of the shop pipeline that no official module covers.
"""
type ShopDev {
  let source: Directory!

  new(ws: Workspace!) {
    self.source = ws.directory("/", include: ["scripts/**", "migrations/**"])
    self
  }

  let base: Container! {
    container
      .from("alpine:3.22")
      .withMountedDirectory("/src", source)
      .withWorkdir("/src")
  }

  """
  Check the SQL migrations. Replaces the check-migrations job.
  """
  pub checkMigrations: Void @check {
    base.withExec(["sh", "scripts/check-migrations.sh"]).sync
    null
  }

  """
  Deploy the shop to an environment. Replaces the deploy job.
  """
  pub deploy(environment: String! = "staging", token: Secret!): String! {
    base
      .withSecretVariable("DEPLOY_TOKEN", token)
      .withExec(["sh", "scripts/deploy.sh", environment])
      .stdout
  }
}

The @check directive makes checkMigrations a check. dagger check now runs it together with the checks of the official modules:

CHECKS  ✔ 5 passed
✔ dag://?check=check-migrations 1.7s OK
✔ dag://?check=dang-sdk/generate/stale 3.1s OK
✔ dag://?check=go/packages/generate/stale 1.8s OK
✔ dag://?check=golangci-lint/projects/lint 2.5s OK
✔ dag://?check=test&go-package=. 2.3s OK
  TESTS
    ✔ example.com/shop  2 passed

    ✔ 2 passed

The Dang SDK adds the dang-sdk check. It fails when the generated configuration of your module is out of date. deploy is not a check. You call it by name, and you give the token as a secret provider address, here a file:

dagger api call deploy --environment=production --token=file:/tmp/deploy-token
deploying shop to production

How stages, jobs, services, caches, and artifacts map

Each GitLab CI concept has a counterpart in Dagger, and you configure most of them in fewer places. This table summarizes them, and the two sections below explain each row:

GitLab CI Dagger
Stages and jobs Checks from modules, run in parallel
services Services bound to a container in a module
cache The engine caches each operation
artifacts Functions return files and directories
rules Stay in .gitlab-ci.yml for the jobs that remain
CI/CD variables Module settings in dagger.toml, and secret providers
include and templates [[include]] in dagger.toml, and shared modules
Runners Start the CLI, which connects to an engine
  • Stages and jobs. In GitLab, stages run in sequence, and the jobs in a stage run in parallel. dagger check runs all checks in the workspace in parallel (Checking your code). When one step needs the output of another, its function calls the other function and uses the result.
  • Services. A GitLab job uses services to start extra containers, such as Postgres. A Dagger module defines a service and binds it to the test container with withServiceBinding (Dang SDK). Dagger health-checks the service before any client can connect (Running services). How do I run integration tests against real databases? shows this setup.
  • Cache. A GitLab job sets cache paths and keys. To share a cache, GitLab says to use one runner, or runners with distributed caching or a shared network directory. The Dagger engine caches each operation, and you write no cache keys. If the inputs did not change, Dagger gets the result from the cache (Typed artifacts). For package manager caches, a module can mount a cache volume (Dang SDK).
  • Artifacts. In GitLab, job artifacts pass build output to jobs in later stages. In Dagger, a function returns a File or a Directory, and another function takes it as an input. To save one on the host, export it. For example, this command uses the Go module to build the main package in cmd/shop:
dagger -c 'go | packages | get cmd/shop | binary | export ./dist/shop'

How rules, variables, includes, and runners map

The configuration around the jobs maps to dagger.toml, secret providers, and the engine.

  • Rules. rules decide when GitLab creates a job. Keep them in .gitlab-ci.yml for the jobs that stay, such as a deploy from the default branch. A module can also find out if it runs on the main branch, and deploy to production or to a preview environment (Branch filtering).
  • CI/CD variables. GitLab says that variables in .gitlab-ci.yml “should store only non-sensitive project configuration”. In Dagger, non-secret values become module settings in dagger.toml, for example dagger module settings go version 1.26. Secrets stay in GitLab CI/CD variables or in your secret manager. The section about secrets explains how a function reads them.
  • Includes and templates. GitLab’s include imports shared YAML, such as templates in a central project or CI/CD components. A Dagger workspace inherits modules and settings from a shared config file with [[include]] in dagger.toml, from a path or a Git ref (Includes). Shared steps go in a module that each repository installs from Git.
  • Runners. GitLab Runner runs the jobs, on GitLab-hosted or self-managed machines. With Dagger, the runner only starts the Dagger CLI. The checks run on the Dagger engine, the program that runs the containers and caches their results. The CLI connects to the engine that DAGGER_ENGINE selects (Self-Hosting CI with Dagger).

Where the checks run after the move

The place where checks run depends on where the repository lives. Cloud Checks run dagger check on Dagger’s Cloud Engines after each push and pull request, with no CI file. Cloud Checks need a GitHub repository, and its origin remote must point to it. If your repository is on GitHub, run dagger cloud checks on, and follow How do I migrate from GitHub Actions to Dagger? from that step.

For a repository on GitLab, keep GitLab CI as the trigger. Each job installs the CLI and runs one Dagger command. When every job has moved, .gitlab-ci.yml is this short file. The section Move one job at a time shows how to get there:

stages: [check, deploy]

variables:
  DAGGER_VERSION: "1.0.0-beta.21"
  DAGGER_ENGINE: cloud

default:
  image: alpine:3.22
  before_script:
    - apk add --no-cache curl
    - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh

check:
  stage: check
  script:
    - dagger check

deploy:
  stage: deploy
  script:
    - dagger api call deploy --environment=production --token=file:$DEPLOY_TOKEN
  environment: production
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

Set DAGGER_VERSION to the version that dagger version prints on your machine. The CLI in the job must have the same version as the engine.

DAGGER_ENGINE: cloud runs the checks on Cloud Engines, the engines that Dagger Cloud runs for you. This setting also needs DAGGER_CLOUD_TOKEN. Store the token as a masked CI/CD variable in the project settings, not in the file. Cloud Engines bill for the CPU, memory, and data that each pipeline uses, as Dagger pricing lists.

To keep the checks on your own machines, set DAGGER_ENGINE to a self-hosted engine, such as tcp://HOST:PORT or kube-pod://POD?namespace=NAMESPACE. The Dagger Helm chart works best when the runners also run in the cluster, for example with the GitLab Runner Kubernetes executor. Set DAGGER_ENGINE in every job. If it is not set, the CLI starts an engine on the runner. An ephemeral runner then loses the cache of that engine after each job (Self-Hosting CI with Dagger).

Secrets and deployments

Keep your secrets where they are. A function takes a secret as a provider address, and Dagger reads the value from that provider. Providers include environment variables, files, HashiCorp Vault, 1Password, AWS Secrets Manager, and Google Cloud Secret Manager (Secrets). Dagger redacts secret values from logs, never writes them to container layers, and leaves them out of cache keys.

In the file above, DEPLOY_TOKEN is a File type CI/CD variable. For this type, GitLab saves the value to a temporary file and puts the path of the file in the variable. The file: provider reads the secret from that path. Make the variable protected, so that only pipelines on protected branches and tags get it. If the token is in Vault, pass a vault:// address instead. The job then needs only the Vault credentials.

The deploy job keeps its GitLab environment, so GitLab still tracks the deployments to each environment. Manual jobs and rules work as before, because the job is a normal GitLab job.

Move one job at a time

Keep the old jobs while you add Dagger. Add a Dagger job beside them with allow_failure: true, so that its result does not fail the pipeline yet:

dagger-check:
  stage: test
  image: alpine:3.22
  variables:
    DAGGER_VERSION: "1.0.0-beta.21"
    DAGGER_ENGINE: cloud
  allow_failure: true
  before_script:
    - apk add --no-cache curl
    - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh
  script:
    - dagger check

GitLab’s allow_failure lets a job fail without failing the pipeline. Then do these steps:

  1. Compare the dagger-check results with the old jobs on several merge requests.
  2. Remove allow_failure: true. The job’s result then counts for Pipelines must succeed.
  3. Remove one old job, such as lint.
  4. Remove the next job when its check gives the same results.

When to keep GitLab CI as the trigger

Keep GitLab CI as the trigger when your repository is on GitLab, because Cloud Checks need a GitHub repository. GitLab CI also does work that this guide keeps. Merge request pipelines, merged results pipelines, and merge trains test changes against the target branch before a merge. On the Premium and Ultimate tiers, protected environments control who can run a deploy job. With a thin .gitlab-ci.yml, you keep these features, and the checks inside the job run the same way on a laptop.

What stays the same

GitLab stays your home for code, merge requests, review, environments, and CI/CD variables. Your tests stay as they are: official modules run them with no change to the test code (Test-native CI). Your secret managers stay the source of your secrets. Your runners stay too. They start the CLI, and the engine runs the checks.

Bottom line: You migrate from GitLab CI to Dagger without a rewrite. Install the official modules that dagger module recommend finds, move custom jobs into a small Dang module, and run dagger check locally. For a GitLab repository, a thin .gitlab-ci.yml runs the same command, and you remove each old job when its check gives the same results.