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.
initand generators both use them.
A note on tooling: Dang is delivered as a Dagger module
The Dang SDK is a Dagger module, dagger.io/sdk/dang. Install it once, then create and maintain modules with the CLI module commands:
# Install the Dang SDK into your workspace (once)
dagger module install dagger.io/sdk/dang
# Create a module
dagger module init dang --name my-ci
The commands you will use most often:
| Command | Purpose |
|---|---|
dagger module init dang | Create a module and generate its configuration. |
dagger module client add, rm, update, list | Manage module clients from the module directory. |
dagger generate | Regenerate configuration for registered modules. |
dagger sdk scope list --sdk=dang --is-module | List Dang modules. |
Create a module
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 dagger module init. By default it creates the module beside the active dagger.toml:
<dagger.toml directory>/.dagger/modules/<name>
dagger module install dagger.io/sdk/dang
dagger module init dang --name my-ci
dagger module init returns a changeset. Dagger shows it to you for review before writing any files into your workspace.
init takes these arguments:
--nameis optional. Without--nameor--path, Dagger infers<project>-devand installs the module as the workspace entrypoint.--pathselects a directory relative to the current directory. A custom path is registered for generation but is not installed; usedagger module install <path>to install it.--template minimalselects the starter template.minimalis the default.--fatalso generates adagger.jsonfile for older engines. It is disabled by default.
Generated layout
.dagger/
modules/
my-ci/
dagger-module.toml
main.dang
The generated dagger-module.toml:
name = "my-ci"
engineVersion = "v1.0.0-beta.14"
[runtime]
source = "dang"
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:
"""
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 uselet. Onlypubmembers 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 modules written in any SDK. Run the client commands from the module directory:
cd .dagger/modules/my-ci
dagger module client add github.com/shykes/daggerverse/hello@v0.3.0 --sdk=dang
dagger module client list --sdk=dang
dagger module client update --sdk=dang
dagger module client rm github.com/shykes/daggerverse/hello@v0.3.0 --sdk=dang
Client add and remove commands update targets in dagger.toml. Client update refreshes dagger.lock. All three regenerate the module configuration. Review and apply each changeset. The SDK writes the runtime dependencies shown below; generation replaces manual dependency edits in dagger-module.toml.
The result lands in dagger-module.toml:
name = "my-ci"
engineVersion = "v1.0.0-beta.14"
[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://orhttps://). If you omit it, Dagger chooses based on the authentication available.@versioncan 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.
dagger generate regenerates module configuration from the registered SDK scopes. Dang still produces no client source files.
dagger generate
dagger sdk scope list --sdk=dang --is-module
Engine version
Each module declares its required engine version with engineVersion in dagger-module.toml. The Dang SDK writes this field during generation. A manual edit can be replaced by the next generation. The value must be a concrete version (for example v1.0.0-beta.13).
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:
| Directive | Returns | Run by | Purpose |
|---|---|---|---|
@check | Void (or a value) | dagger check | Validate something, such as a lint, test, or scan. |
@generate | Changeset | dagger generate | Produce a diff of generated files for review. |
@up | Service! | dagger up | Start 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
@generategenerators 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:
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 no build step. Run dagger generate to update the module configuration before publishing.
To release:
- Commit
dagger-module.toml,main.dang, and any other source files. - Check the engine requirement in the generated
dagger-module.toml. - Tag the repository (
git tag v0.1.0 && git push --tags).
Consumers install your module into their workspace with the dagger CLI:
dagger module 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. Check
dagger module list. A module created with--pathneeds a separatedagger module install <path>, or use-m <path>for one command. - SDK not found. Install
dagger.io/sdk/dang, then checkdagger sdk list. - 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
Voidfunction must end innull. AVoidfunction usually calls.syncon a container or service to force evaluation, then returnsnullas its final expression. - Changeset not written.
dagger module init, module client commands, anddagger generatepresent a changeset. Review and apply it to write the files. - 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.