Dang SDK
Dang is Dagger's native DSL. It maps directly to the Dagger API: what you write is what runs. There is no codegen, no generated client files to commit, no build step, and no language-runtime overhead. A Dang module is, in the common case, a single main.dang file plus a dagger.json.
Use Dang when your module is primarily orchestrating the Dagger API — containers, files, directories, services, secrets, and other modules. If you need to reach for external libraries (a Go parser, a Python ML library, a Node.js bundler API), use the Go, Python, or TypeScript SDK instead, which give you a full host language alongside the Dagger client.
Before diving in, it helps to understand the platform concepts every SDK shares:
- Module Developer Guide — what a module is and how it fits into a workspace.
- Type System — the types your functions accept and return.
- Checks — how Dagger discovers and runs validation.
- Changesets — how Dagger represents file diffs (used by
initand generators).
A note on tooling: Dang is delivered as a Dagger module
There is no dagger init or dagger develop subcommand in the CLI. Module development tooling for Dang lives in a Dagger module of its own: github.com/dagger/dang-sdk. You scaffold and maintain Dang modules by calling functions on that module:
dagger -m github.com/dagger/dang-sdk call <function> [args]
The functions you will use most often:
| Function | Purpose |
|---|---|
init | Create a new Dang SDK module. Returns a changeset of files to write. |
mod | Resolve the Dang module at or above a workspace path; entry point for deps, generate, engine, path. |
mod deps | Add, remove, list, and update module dependencies. |
mod generate | (Re)generate module metadata. For Dang this is effectively a no-op — there are no client bindings to regenerate. |
mod engine | Read or set the required Dagger engine version. |
generate-all | Generate every discovered Dang SDK module in the workspace. |
modules | List every Dagger module in the workspace whose sdk.source is "dang". |
templates | List the init templates this version of dang-sdk ships. |
Create a module
Run init from inside a Git repository — that's where the new module is created.
init creates a new Dang module and returns a changeset — the same review-then-apply flow used by tools like dagger call prettier write. By default the module is created under the nearest .dagger directory visible from your current workspace path:
<nearest .dagger>/modules/<name>
Preview the changeset first by listing the paths it would add:
dagger -m github.com/dagger/dang-sdk call init --name my-ci added-paths
# .dagger/modules/my-ci/dagger.json
# .dagger/modules/my-ci/main.dang
# .dagger/modules/
# .dagger/modules/my-ci/
Review and apply the changeset to write the files into your workspace (pass -y / --auto-apply to skip the prompt):
dagger -m github.com/dagger/dang-sdk call init --name my-ci
init arguments:
--name(required) — the module name.--path— choose a different module location. The target path must not already contain a Dagger module.--template— materialize files fromtemplates/<template>. The empty default uses the minimal built-in template. Rundagger -m github.com/dagger/dang-sdk call templatesto see what is available.--ignore-generated— configure generation to add generated SDK paths to.gitignoreinstead of checking them in. (For Dang there are no generated client files, so this has little practical effect — see Generate / module metadata.)
Generated layout
.dagger/
modules/
my-ci/
dagger.json
main.dang
The generated dagger.json:
{
"name": "my-ci",
"engineVersion": "latest",
"sdk": {
"source": "dang"
},
"codegen": {
"automaticGitignore": false
}
}
sdk.source set to "dang" is what 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 My_ci {
"""
Return a greeting from this Dang module.
"""
pub hello: String! {
"hello from Dang"
}
}
Once applied, call your module by pointing -m at it (or running from inside the module's workspace):
dagger -m .dagger/modules/my-ci call hello
# hello from Dang
Language basics
Dang is intentionally small. The whole language fits in a short reference:
- Types are declared with
type Name { ... }. The first/primary type in a module is its entry point. - Public members use
pub; private members uselet. Onlypubmembers are visible to callers. - Functions are type members that return a value:
pub build: Container! { ... }. - Arguments go in parentheses:
pub build(source: Directory!): Container! { ... }. - Non-null is marked with
!; nullable is the default (no marker). - Directives modify behavior:
@check,@generate,@up,@cache. - Descriptions use triple-quoted strings (
""" ... """) placed above the thing they describe. - Comments use
#. - Module metadata is 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 things visible to callers. The top-of-file docstring is the module's summary, shown in dagger functions and dagger call --help. Per-member docstrings document individual functions and arguments.
Try it:
dagger call hello
# Hello from Dagger!
Expressions and chaining
Dang uses method chaining over the Dagger API. Each function body is a single expression that evaluates to the return value — the last expression is what's returned. The fluent builder 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 re-running skips unchanged work automatically (the same model as Docker layer caching, applied to the entire API). See How Dagger works and the type system 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 private bindings — internal state and helpers not exposed to callers. They are computed lazily and cached. Use them for shared setup that multiple 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 the artifacts your module produces — for example to return multiple 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 (e.g. MyCiBuildResult) to avoid conflicts when multiple modules are loaded together. Custom types are reached 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
}
}
Invalid values produce a clear error listing the allowed choices:
dagger call scan --ref=alpine:latest --severity=FOO
# Error: value should be one of UNKNOWN, LOW, MEDIUM, HIGH, CRITICAL
Interfaces
Interfaces let your module accept arbitrary types from other modules without being coupled to them:
type Deployer {
"""
Any object that can produce a container image.
"""
interface Buildable {
build: Container!
}
pub deploy(app: Buildable!, registry: String!): String! {
app.build.publish(registry + "/app:latest")
}
}
Any module with a build function returning Container! satisfies Buildable — no explicit "implements" declaration is needed on the other module. Dagger detects compatible types automatically.
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"])
}
}
Arguments can carry:
- Types —
String!,Int!,Boolean!,Directory!,File!,Secret!,Container!, custom types, enums, interfaces, etc. - Defaults —
= "20", which makes the argument optional. - Descriptions — a triple-quoted string above the argument.
- Non-null markers —
!means required (when no default); absence means nullable.
dagger call build
dagger call build --node-version=18
Constructor-style arguments (members on the primary type, set in new(...)) give users knobs they can override globally. The constructor also receives the user's Workspace — auto-populated by Dagger — from which the module reads project files:
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 call --node-version=18 build
# Or in .dagger/config.toml
# [modules.my-ci.settings]
# nodeVersion = "18"
# registry = "docker.io"
Working with core Dagger types
Dang exposes the full Dagger API directly. The most common building blocks:
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) / .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 provide secrets via providers:
dagger call deploy --token=env:DEPLOY_TOKEN # environment variable
dagger call deploy --token=file:./token.txt # file
dagger call deploy --token=cmd:"gh auth token" # command output
dagger call deploy --token=op://vault/item/field # 1Password
dagger call deploy --token=vault://path/to/secret # HashiCorp Vault
dagger call deploy --token=gcp://secret-name # Google Cloud Secret Manager
Secrets are scoped to the module that defines them. To share one across modules, pass it explicitly via a function argument.
Services
Start services for integration tests or dev environments. Services are content-addressed — the same definition always gets the same hostname, so 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"])
}
Cache volumes are scoped to the module that defines them. To share one across modules, pass a reference via 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 -m github.com/dagger/dang-sdk call mod deps add \
--source github.com/shykes/daggerverse/hello@v0.3.0 \
--name hello
mod deps add returns a changeset that updates dagger.json. Arguments are --source (required) and an optional --name.
List, update, and remove:
# List dependencies
dagger -m github.com/dagger/dang-sdk call mod deps list
# Update one dependency by name (or all remote deps if omitted) — returns a changeset
dagger -m github.com/dagger/dang-sdk call mod deps update --name hello
# Remove one by name — returns a changeset
dagger -m github.com/dagger/dang-sdk call mod deps remove --name hello
The result is reflected in dagger.json:
{
"name": "my-ci",
"engineVersion": "latest",
"sdk": { "source": "dang" },
"dependencies": [
{ "name": "hello", "source": "github.com/shykes/daggerverse/hello@v0.3.0" },
{ "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 omitted, Dagger chooses based on available authentication.@versioncan be a tag, branch, or commit; if omitted, the default branch is used.- 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 / 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 and for module discovery/metadata. For a Dang module it is effectively a no-op: it returns an (empty) changeset rather than rewriting source.
# Regenerate a single module (no client bindings for Dang; changeset is typically empty)
dagger -m github.com/dagger/dang-sdk call mod generate
# Regenerate every Dang module discovered in the workspace
dagger -m github.com/dagger/dang-sdk call generate-all
# List every module in the workspace whose sdk.source is "dang"
dagger -m github.com/dagger/dang-sdk call modules
You can suppress generation for a module (or subtree) by placing the configured skip-marker file at or above the module root. The marker filename is reported by:
dagger -m github.com/dagger/dang-sdk call skip-generate-filename
Because there is nothing to commit from generation, the --ignore-generated flag on init has no meaningful effect for Dang — there are no generated SDK paths to add to .gitignore.
Engine version
Each module declares the Dagger engine version it requires via engineVersion in dagger.json. Manage it with mod engine:
# Read the required version (without the leading "v")
dagger -m github.com/dagger/dang-sdk call mod engine required
# Pin to a specific version — returns a changeset
dagger -m github.com/dagger/dang-sdk call mod engine require --version v1.0.0-beta.2
# Pin to the current engine
dagger -m github.com/dagger/dang-sdk call mod engine require-current
# Track the latest stable release
dagger -m github.com/dagger/dang-sdk call mod engine require-latest
Pinning a concrete version makes a module reproducible across machines and CI; latest keeps it floating.
Workspace inputs
A module reads the surrounding project's files through a Workspace argument on its constructor. Dagger auto-populates this argument from the current workspace — the caller passes nothing, and nothing is uploaded up front. The module then reads project content lazily, on demand as it actually uses it:
type MyCi {
"""The source directory for the project."""
pub source: Directory!
new(ws: Workspace!) {
self.source = ws.directory("/")
self
}
}
The Workspace exposes three readers:
ws.directory(path)— read a directory from the workspace.ws.file(path)— read a single file.ws.findUp(name:, from:)— search upward from a start path for a file or directory by name (useful for locating a config file that may live in a parent directory); returns a nullable path.
Path resolution: relative paths resolve from the workspace cwd (where the user invoked dagger); absolute paths (beginning 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
}
}
Ignore patterns: ws.directory accepts an exclude list to filter out files you don't need. This is important for cache efficiency — 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
}
}
Principle: 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 minimize cache invalidations. Because reads are lazy, content the module never touches is never uploaded.
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 marked with a directive and run by its own verb. A useful, reusable module provides at least one of them:
| Directive | Returns | Run by | Purpose |
|---|---|---|---|
@check | Void (or a value) | dagger check | Validate something — lint, test, 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 will discover and run it. A check passes if it completes without error and fails if any withExec returns a non-zero exit code. See Checks.
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 unified changeset for review:
pub generateProto: Changeset @generate {
container
.from("bufbuild/buf:latest")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["buf", "generate"])
.directory(".")
.changes(source)
}
Note: these
@generategenerators are your module's code-generation pipelines (e.g. protobuf, OpenAPI). They are unrelated to SDK client codegen — which, as noted above, Dang does not have.
Services
A service function returns a long-running Service!. Mark it with @up and dagger up will start it. Build the service from a container with .asService, exposing the ports it should listen on:
@up
pub web: Service! {
container
.from("nginx:alpine")
.withExposedPort(80)
.asService
}
A module can expose several @up services — dagger up starts each one. This differs from the private let db: Service { ... } pattern shown under Services above: a let service is internal plumbing (e.g. a database wired into a check via withServiceBinding), while an @up service is a public entry point 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 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, failing the build if any check fails.
Packaging and release
A Dang module is just source — main.dang and dagger.json (and any extra .dang files). There is nothing to build or generate before publishing.
To release:
- Commit
dagger.json,main.dang, and any other source files. - Pin a concrete engine version with
mod engine requirefor reproducibility. - Tag the repository (
git tag v0.1.0 && git push --tags).
Consumers install your module into their workspace with the dagger CLI:
dagger mod install github.com/yourorg/yourrepo/path@v0.1.0
They can then call its functions (dagger call ...) and run its checks (dagger check).
Troubleshooting
no Dagger module found containing path: .—modand its sub-operations resolve the Dang module at or above the given workspace path. Run from inside the module's workspace, point-W/--workspaceat it, or pass--path. Pass--find-upto allow--pathto point inside the module.initfails with "workspace not loaded" —initwrites into the nearest.daggerdirectory of a loaded workspace. Run it from within a workspace (a directory tree containing or under.dagger), or use-W/--workspace.- "The target path must not already contain a Dagger module" — choose a different
--path, or remove the existing module first. - Parser/type errors — Dang surfaces these directly from the module source. Check non-null markers (
!), that the last expression in a function body matches the declared return type, and that custom-type/enum names referenced actually exist. - A
Voidfunction must end innull—Voidfunctions typically.synca container (or service) to force evaluation, then returnnullas the final expression. - Changeset not written — most maintenance functions (
init,mod deps *,mod engine require*,mod generate,generate-all) return a changeset and do nothing to disk until you apply it. Add-y/--auto-apply, or inspect it first withadded-paths/modified-paths/as-patch. - 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, that step does not apply to Dang.