Skip to main content
Version: 1.0-beta

Dang SDK

Dang is Dagger's native DSL. It maps directly to the Dagger API, so what you write is what runs. There is no codegen, no generated client files to commit, no build step, and no language runtime to carry around. In the common case a Dang module is one main.dang file plus a dagger-module.toml.

Use Dang when your module mostly orchestrates the Dagger API: containers, files, directories, services, secrets, and other modules. If you need external libraries (a Go parser, a Python ML library, a Node.js bundler API), use the Go, Python, or TypeScript SDK instead. Those give you a full host language alongside the Dagger client.

Every SDK shares a few platform concepts. Read these first if they are new to you:

  • SDKs overview explains what a module is and how it fits into a workspace.
  • Types covers the types your functions accept and return.
  • Generating code shows how Dagger represents file diffs. init and generators both use them.

A note on tooling: Dang is delivered as a Dagger module

The tooling for developing Dang modules lives in a Dagger module of its own, github.com/dagger/dang-sdk, not in the dagger CLI. Install it into your workspace once. From then on you scaffold and maintain Dang modules by calling functions on that module by name:

# Install the Dang SDK into your workspace (once)
dagger install github.com/dagger/dang-sdk

# Then call its functions
dagger api call dang-sdk <function> [args]

The functions you will use most often:

FunctionPurpose
initCreate a new Dang SDK module. Returns a changeset of files to write.
modResolve the Dang module at or above a workspace path; entry point for deps, generate, engine, path.
mod depsAdd, remove, list, and update module dependencies.
mod generateRegenerate module metadata. For Dang this is close to a no-op, since there are no client bindings to regenerate.
mod engineRead or set the required Dagger engine version.
generate-allGenerate every discovered Dang SDK module in the workspace.
modulesList every Dagger module in the workspace whose runtime.source is "dang".
templatesList the init templates this version of dang-sdk ships.

Create a module

note

Run these commands from inside a Git repository. That is where Dagger creates the new module.

Install the Dang SDK into your workspace, then create a new module with its init function. By default init creates the module under the nearest .dagger directory visible from your current workspace path:

<nearest .dagger>/modules/<name>
dagger install github.com/dagger/dang-sdk
dagger api call dang-sdk init --name my-ci

init returns a changeset. Dagger shows it to you for review before writing any files into your workspace.

init takes these arguments:

  • --name is required. It sets the module name.
  • --path puts the module somewhere else. The target path must not already contain a Dagger module.
  • --template writes files from templates/<template>. Leave it empty to get the minimal built-in template. Run dagger api call dang-sdk templates to see what is available.
  • --ignore-generated configures generation to add generated SDK paths to .gitignore instead of checking them in. Dang has no generated client files, so this flag does little in practice. See Generate and module metadata.

Generated layout

.dagger/
modules/
my-ci/
dagger-module.toml
main.dang

The generated dagger-module.toml:

.dagger/modules/my-ci/dagger-module.toml
name = "my-ci"
engineVersion = "v1.0.0-beta.11"

[runtime]
source = "dang"

[codegen]
automaticGitignore = false

Setting runtime.source to "dang" tells Dagger to run this module with the Dang runtime. engineVersion declares the engine version the module requires. See Engine version.

The generated main.dang entry point:

.dagger/modules/my-ci/main.dang
"""
Starter Dang module generated by dang-sdk.
"""
type MyCi {
"""
Return a greeting from this Dang module.
"""
pub hello: String! {
"hello from Dang"
}
}

Once you apply the changeset, call your module by pointing -m at it, or run from inside the module's workspace:

dagger -m .dagger/modules/my-ci api call hello
# hello from Dang

Language basics

Dang is small on purpose. The whole language fits in a short list:

  • Types. Declare one with type Name { ... }. The first type in a module is the primary type and its entry point.
  • Public members. Use pub. Private members use let. Only pub members are visible to callers.
  • Functions. A function is a type member that returns a value: pub build: Container! { ... }.
  • Arguments. They go in parentheses: pub build(source: Directory!): Container! { ... }.
  • Non-null. Mark it with !. Nullable is the default and has no marker.
  • Directives. They modify behavior: @check, @generate, @up, @cache.
  • Descriptions. Put a triple-quoted string (""" ... """) above the thing it describes.
  • Comments. Start them with #.
  • Module metadata. A triple-quoted docstring at the top of the file, above the primary type.

A minimal module is a type with at least one public function:

"""
CI for my project.
"""
type MyCi {
"""
Say hello.
"""
pub hello: String! {
"Hello from Dagger!"
}
}

pub makes a member visible to callers. The docstring at the top of the file is the module's summary. dagger api functions and dagger api call --help show it. Per-member docstrings document individual functions and arguments.

Try it:

dagger api call hello
# Hello from Dagger!

Expressions and chaining

Dang chains method calls on the Dagger API. Each function body is a single expression, and the function returns whatever that last expression evaluates to. The chain reads top to bottom:

container
.from("node:20")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "install"])

Every method returns a new immutable value. Nothing mutates in place. Dagger caches each step by its inputs, so a re-run skips unchanged work. It is the same model as Docker layer caching, applied to the entire API. See the type reference for the underlying model.

Define objects and functions

A module is a type. Functions are its pub members. The function body returns a value of the declared return type:

"""
CI for my web application.
"""
type MyCi {
pub build: Container! {
container
.from("node:20")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "install"])
.withExec(["npm", "run", "build"])
}
}

Private state with let

let defines a private binding, such as internal state or a helper that callers cannot see. Dang evaluates it lazily and caches the result. Use let for shared setup that several functions reuse:

type Security {
pub source: Directory!

new(ws: Workspace!) {
self.source = ws.directory("/")
self
}

# Private: not callable by users
let trivyBase = container
.from("aquasec/trivy:0.68.2")
.withMountedCache(
path: "/root/.cache",
cache: cacheVolume("trivy-cache"),
sharing: CacheSharingMode.LOCKED,
)
.withWorkdir("/home/trivy")

# Public: callable by users
pub scanSource: Void {
trivyBase
.withMountedDirectory(".", source)
.withExec(["trivy", "fs", "--exit-code=1", "--severity=CRITICAL,HIGH", "."])
.sync
null
}
}

Custom types

Define additional types to model what your module produces, for example to return several related values from one function:

type MyCi {
"""
Build result containing the binary and metadata.
"""
type BuildResult {
pub binary: File!
pub version: String!
pub platform: String!
}

pub build(platform: String! = "linux/amd64"): BuildResult! {
let bin = container
.from("golang:1.22")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["go", "build", "-o", "/out/app", "."])
.file("/out/app")

BuildResult {
binary: bin,
version: "1.0.0",
platform: platform,
}
}
}

Dagger prefixes custom type names in the API schema (for example MyCiBuildResult) to avoid conflicts when several modules load together. You reach a custom type by chaining from a function on the primary type.

Enumerations

Use enum to restrict an argument to a fixed set of values:

type Security {
enum Severity {
UNKNOWN
LOW
MEDIUM
HIGH
CRITICAL
}

pub scan(ref: String!, severity: Severity!): String! {
container
.from("aquasec/trivy:latest")
.withExec(["trivy", "image", "--severity", severity, ref])
.stdout
}
}

An invalid value produces an error that lists the allowed choices:

dagger api call scan --ref=alpine:latest --severity=FOO
# Error: value should be one of UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL

Interfaces

Interfaces let your module accept types from other modules without depending on them. Declare an interface at the top level of the file, not nested inside a type. List the pub members you need as signatures only, with no body:

"""
Any object that can produce a container image.
"""
interface Buildable {
pub build: Container!
}

type Deployer {
pub deploy(app: Buildable!, registry: String!): String! {
app.build.publish(registry + "/app:latest")
}
}

A concrete type declares that it satisfies an interface with implements:

type WebApp implements Buildable {
pub source: Directory!

new(ws: Workspace!) {
self.source = ws.directory("/")
self
}

pub build: Container! {
container
.from("node:20")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "run", "build"])
}
}

Across module boundaries Dagger also matches structurally. You can pass any object from another module whose functions match Buildable where the interface is expected, even without an explicit implements declaration.

Arguments and return values

Functions accept typed arguments in parentheses. An argument with a default value is optional; an argument with a ! type and no default is required:

type MyCi {
pub build(
"""
Node.js version to use.
"""
nodeVersion: String! = "20",
): Container! {
container
.from("node:" + nodeVersion)
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "install"])
.withExec(["npm", "run", "build"])
}
}

An argument can carry:

  • Types. String!, Int!, Boolean!, Directory!, File!, Secret!, Container!, custom types, enums, interfaces, and so on.
  • Defaults. = "20" makes the argument optional.
  • Descriptions. A triple-quoted string above the argument.
  • Non-null markers. ! means required when there is no default. Without it the argument is nullable.
dagger api call build
dagger api call build --node-version=18

Constructor arguments, which are members on the primary type set in new(...), give users knobs they can override globally. The constructor also receives the user's Workspace. Dagger fills that in, and the module reads project files from it:

type MyCi {
pub source: Directory!
pub nodeVersion: String!
pub registry: String!

new(
ws: Workspace!,
nodeVersion: String! = "20",
registry: String! = "ghcr.io",
) {
self.source = ws.directory("/")
self.nodeVersion = nodeVersion
self.registry = registry
self
}

pub publish(tag: String!): String! {
build.publish(registry + "/myorg/myapp:" + tag)
}
}
# CLI override
dagger api call --node-version=18 build

# Or in dagger.toml
# [modules.my-ci.settings]
# nodeVersion = "18"
# registry = "docker.io"

Working with core Dagger types

Dang exposes the full Dagger API directly. These are the types you will use most:

Containers

pub build: Container! {
container
.from("node:20")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "install"])
.withExec(["npm", "run", "build"])
}

Files and directories

Functions can return File! or Directory!, and accept them as arguments. Reach into a container's filesystem with .file(path) or .directory(path):

pub binary: File! {
build.file("/app/dist/server.js")
}

Secrets

Accept secrets as the Secret type, never as plain strings. Dagger scrubs secret values from all output streams, including crash reports:

pub deploy(
"""
API token for deployment.
"""
token: Secret!,
): Void {
container
.from("alpine")
.withSecretVariable("DEPLOY_TOKEN", token)
.withExec(["sh", "-c", "deploy --token=$DEPLOY_TOKEN"])
.sync
null
}

Callers supply secrets through providers:

dagger api call deploy --token=env:DEPLOY_TOKEN        # environment variable
dagger api call deploy --token=file:./token.txt # file
dagger api call deploy --token=cmd:"gh auth token" # command output
dagger api call deploy --token=op://vault/item/field # 1Password
dagger api call deploy --token=vault://path/to/secret # HashiCorp Vault
dagger api call deploy --token=gcp://secret-name # Google Cloud Secret Manager

A secret is scoped to the module that defines it. To share one across modules, pass it as a function argument.

Services

Start services for integration tests or dev environments. Services are content-addressed, so the same definition always gets the same hostname and there are no port conflicts:

type MyCi {
pub source: Directory!

new(ws: Workspace!) {
self.source = ws.directory("/")
self
}

let db: Service {
container
.from("postgres:16")
.withEnvVariable("POSTGRES_PASSWORD", "test")
.withExposedPort(5432)
.asService
}

pub integrationTest: Void @check {
container
.from("golang:1.22")
.withDirectory("/app", source)
.withServiceBinding("db", db)
.withEnvVariable("DATABASE_URL", "postgres://postgres:test@db:5432/postgres")
.withExec(["go", "test", "-tags=integration", "./..."])
.sync
null
}
}

Cache volumes

Use cache volumes for package manager caches and other persistent data that should survive across runs. cacheVolume("name") is keyed by name:

pub build: Container! {
container
.from("node:20")
.withDirectory("/app", source)
.withWorkdir("/app")
.withMountedCache("/app/node_modules", cacheVolume("node-modules"))
.withExec(["npm", "install"])
.withExec(["npm", "run", "build"])
}

A cache volume is scoped to the module that defines it. To share one across modules, pass a reference as a function argument.

Module dependencies

A Dang module can depend on other Dagger modules, written in any SDK. Manage dependencies with mod deps, which operates on the module at or above the given workspace path.

Add a dependency:

dagger api call dang-sdk mod deps add \
--source github.com/shykes/daggerverse/hello@v0.3.0 \
--name hello

mod deps add returns a changeset that updates dagger-module.toml. --source is required and --name is optional.

List, update, and remove:

# List dependencies
dagger api call dang-sdk mod deps list

# Update one dependency by name (or all remote deps if omitted). Returns a changeset
dagger api call dang-sdk mod deps update --name hello

# Remove one by name. Returns a changeset
dagger api call dang-sdk mod deps remove --name hello

The result lands in dagger-module.toml:

dagger-module.toml
name = "my-ci"
engineVersion = "v1.0.0-beta.11"

[runtime]
source = "dang"

[[dependencies]]
name = "hello"
source = "github.com/shykes/daggerverse/hello@v0.3.0"

[[dependencies]]
name = "local"
source = "./path/to/module"

A dependency reference follows [proto://]host/repo[/subpath][@version]:

github.com/shykes/daggerverse/hello@v0.3.0
# ^^^^^^ ^^^^^^^^^^^^^ ^^^^^ ^^^^^^
# host repo path version
  • proto:// is optional (ssh:// or https://). If you omit it, Dagger chooses based on the authentication available.
  • @version can be a tag, branch, or commit. If you omit it, Dagger uses the default branch.
  • Local dependencies use a relative path (./path/to/module).

Once added, call a dependency in your code by its name, like a function:

type MyCi {
pub source: Directory!

new(ws: Workspace!) {
self.source = ws.directory("/")
self
}

pub devContainer: Container! {
# 'go' is the dependency module. Call it like a function
go(source: source).env.withWorkdir("/app")
}

pub test: Void @check {
devContainer.withExec(["go", "test", "./..."]).sync
null
}
}

Generate and module metadata

Most SDKs use a generate step to produce client bindings from the Dagger API schema, which you then commit. Dang has no such step. Because Dang maps directly to the Dagger API, there are no generated client files and nothing language-specific to check in. What you write in main.dang is what runs.

The mod generate operation still exists, for consistency with the other SDKs and for module discovery and metadata. For a Dang module it is close to a no-op. It returns a changeset, normally an empty one, rather than rewriting source.

# Regenerate a single module (no client bindings for Dang; changeset is typically empty)
dagger api call dang-sdk mod generate

# Regenerate every Dang module discovered in the workspace
dagger api call dang-sdk generate-all

# List every module in the workspace whose runtime.source is "dang"
dagger api call dang-sdk modules

To skip generation for a module or subtree, place the configured skip-marker file at or above the module root. This reports the marker filename:

dagger api call dang-sdk skip-generate-filename

Because generation produces nothing to commit, the --ignore-generated flag on init has no real effect for Dang. There are no generated SDK paths to add to .gitignore.

Engine version

Each module declares the Dagger engine version it requires with engineVersion in dagger-module.toml. Manage it with mod engine:

# Read the required version (without the leading "v")
dagger api call dang-sdk mod engine required

# Pin to a specific version. Returns a changeset
dagger api call dang-sdk mod engine require --version v1.0.0-beta.11

# Pin to the current engine
dagger api call dang-sdk mod engine require-current

# Pin to the latest stable release
dagger api call dang-sdk mod engine require-latest

engineVersion must be a concrete version (for example v1.0.0-beta.11). require-latest resolves the latest stable release to its concrete version and pins that, so the module stays reproducible across machines and CI.

Workspace inputs

A module reads the surrounding project's files through a Workspace argument on its constructor. Dagger fills in this argument from the current workspace. The caller passes nothing, and nothing uploads up front. The module reads project content lazily, only when it uses it:

type MyCi {
"""The source directory for the project."""
pub source: Directory!

new(ws: Workspace!) {
self.source = ws.directory("/")
self
}
}

Workspace has three readers:

  • ws.directory(path) reads a directory from the workspace.
  • ws.file(path) reads a single file.
  • ws.findUp(name:, from:) searches upward from a start path for a file or directory by name and returns a nullable path. Use it to locate a config file that may live in a parent directory.

Relative paths resolve from the workspace cwd, where the user invoked dagger. Absolute paths, which begin with /, resolve from the workspace root.

type MyCi {
pub source: Directory!
pub config: File!

new(ws: Workspace!) {
# Absolute: from the workspace root
self.source = ws.directory("/src")
# Relative: from the workspace cwd
self.config = ws.file("tsconfig.json")
self
}
}

ws.directory accepts an exclude list to filter out files you don't need. This matters for caching. Excluding node_modules, .git, build output, and similar paths avoids needless cache invalidations:

type MyCi {
pub source: Directory!

new(ws: Workspace!) {
self.source = ws.directory("/", exclude: [
"node_modules",
".git",
"dist",
])
self
}
}

Read only what you need. Don't load the whole repo if you only need src/. Read specific paths and use tight exclude lists to keep cache invalidations down. Reads are lazy, so content the module never touches never uploads.

A complete example, modeled on dagger/eslint:

type Eslint {
"""The source directory for the project."""
pub source: Directory!
pub baseImageAddress: String!

new(
ws: Workspace!,
baseImageAddress: String! = "node:25-alpine",
) {
self.source = ws.directory("/")
self.baseImageAddress = baseImageAddress
self
}

pub lint: Void @check {
nodejs(source, baseImageAddress).base.withExec(["npx", "eslint", "."]).sync
null
}
}

Checks, generators, services, directives

Dang has three first-class function types. Each has a directive that marks it and a verb that runs it. A useful module provides at least one of them:

DirectiveReturnsRun byPurpose
@checkVoid (or a value)dagger checkValidate something, such as a lint, test, or scan.
@generateChangesetdagger generateProduce a diff of generated files for review.
@upService!dagger upStart a long-running service.

Checks

A check validates something without requiring arguments. Mark it with @check and dagger check discovers and runs it. A check passes if it completes without error. It fails if any withExec returns a non-zero exit code.

type MyCi {
pub source: Directory!

new(ws: Workspace!) {
self.source = ws.directory("/")
self
}

"""
Lint the code.
"""
pub lint: Void @check {
container
.from("golangci/golangci-lint:latest")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["golangci-lint", "run"])
.sync
null
}
}

A check can also return Container. Dagger syncs it and uses the exit code:

pub lint: Container @check {
container
.from("golangci/golangci-lint:latest")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["golangci-lint", "run"])
}

Generators

A generator produces a changeset, a diff between the current source and freshly generated output. Mark it with @generate. .changes(source) computes the diff against the original source. dagger generate runs all generators and presents the combined changeset for review:

pub generateProto: Changeset @generate {
container
.from("bufbuild/buf:latest")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["buf", "generate"])
.directory(".")
.changes(source)
}

These @generate generators are your module's own code generation pipelines, such as protobuf or OpenAPI. They have nothing to do with SDK client codegen, which Dang does not have.

Services

A service function returns a long-running Service!. Mark it with @up and dagger up starts it. Build the service from a container with .asService, and expose the ports it should listen on:

pub web: Service! @up {
container
.from("nginx:alpine")
.withExposedPort(80)
.asService
}

A module can expose several @up services, and dagger up starts each one. This differs from the private let db: Service { ... } pattern shown under Services above. A let service is internal plumbing, such as a database wired into a check with withServiceBinding. An @up service is a public entry point that users start directly.

Caching directives

By default Dagger caches function results for up to 7 days, keyed by inputs (arguments, parent state, module source). Tune it per function with @cache:

# Cache for 10 minutes (e.g. external data that changes)
pub latestRelease: String! @cache(ttl: "10m") { ... }

# Cache only for the current session
pub sessionId: String! @cache(policy: "PerSession") { ... }

# Never cache (always re-execute)
pub currentTime: String! @cache(policy: "Never") { ... }

The policy values are Default, PerSession, and Never. A function cache hit skips the function entirely. A miss runs it, but individual operations inside may still hit the layer cache. @cache(policy: "Never") forces the function to run every call but does not disable layer caching for the operations inside it.

Testing Dang modules

The most direct way to test a Dang module is to call its functions and run its checks:

# Smoke test. Does it build?
dagger api call build

# Run all checks
dagger check

# Run generators and verify there's no drift
dagger check --generate

For more thorough testing, write a separate test module (in any SDK) that depends on yours, exercises its functions, and asserts on the results.

CI

Wire dagger check into CI. Pin the engine version (see Engine version) for reproducibility:

.github/workflows/ci.yml
jobs:
dagger:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dagger/dagger-for-github@v6
with:
verb: check

dagger check runs every @check function in the module and fails the build if any check fails.

Packaging and release

A Dang module is just source: main.dang, dagger-module.toml, and any extra .dang files. There is nothing to build or generate before publishing.

To release:

  1. Commit dagger-module.toml, main.dang, and any other source files.
  2. Pin a concrete engine version with mod engine require for reproducibility.
  3. Tag the repository (git tag v0.1.0 && git push --tags).

Consumers install your module into their workspace with the dagger CLI:

dagger install github.com/yourorg/yourrepo/path@v0.1.0

They can then call its functions (dagger api call ...) and run its checks (dagger check).

Troubleshooting

  • Module not found. no Dagger module found containing path: . means mod and its sub-operations could not resolve a Dang module at or above the given workspace path. Run from inside the module's workspace, point -W/--workspace at it, or pass --path. Pass --find-up to let --path point inside the module.
  • Workspace not loaded. init writes into the nearest .dagger directory of a loaded workspace. If it fails with "workspace not loaded", run it from within a workspace (a directory tree that contains or sits under .dagger), or use -W/--workspace.
  • Target path already has a module. If init reports "The target path must not already contain a Dagger module", choose a different --path or remove the existing module first.
  • Parser and type errors. Dang reports these straight from the module source. Check the non-null markers (!), that the last expression in a function body matches the declared return type, and that every custom type and enum name you reference exists.
  • A Void function must end in null. A Void function usually calls .sync on a container or service to force evaluation, then returns null as its final expression.
  • Changeset not written. Most maintenance functions (init, mod deps *, mod engine require*, mod generate, generate-all) return a changeset and touch nothing on disk until you review and apply it.
  • Stale generation expectations. Dang has no client codegen, so there are no generated bindings to regenerate. If a tutorial tells you to commit generated SDK files, skip that step.

Next steps