docs/current_docs/extending/sdks/dang.mdx
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:
init and generators).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. |
:::note
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 from templates/<template>. The empty default uses the minimal built-in template. Run dagger -m github.com/dagger/dang-sdk call templates to see what is available.--ignore-generated — configure generation to add generated SDK paths to .gitignore instead of checking them in. (For Dang there are no generated client files, so this has little practical effect — see Generate / module metadata.).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
Dang is intentionally small. The whole language fits in a short reference:
type Name { ... }. The first/primary type in a module is its entry point.pub; private members use let. Only pub members are visible to callers.pub build: Container! { ... }.pub build(source: Directory!): Container! { ... }.!; nullable is the default (no marker).@check, @generate, @up, @cache.""" ... """) placed above the thing they describe.#.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!
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.
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"])
}
}
letlet 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
}
}
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.
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 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.
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:
String!, Int!, Boolean!, Directory!, File!, Secret!, Container!, custom types, enums, interfaces, etc.= "20", which makes the argument optional.! 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"
Dang exposes the full Dagger API directly. The most common building blocks:
pub build: Container! {
container
.from("node:20")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "install"])
.withExec(["npm", "run", "build"])
}
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")
}
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.
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
}
}
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.
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/[email protected] \
--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/[email protected]" },
{ "name": "local", "source": "./path/to/module" }
]
}
A dependency reference follows [proto://]host/repo[/subpath][@version]:
github.com/shykes/daggerverse/[email protected]
# ^^^^^^ ^^^^^^^^^^^^^ ^^^^^ ^^^^^^
# host repo path version
proto:// is optional (ssh:// or https://); if omitted, Dagger chooses based on available authentication.@version can be a tag, branch, or commit; if omitted, the default branch is used../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
}
}
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.
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.
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
}
}
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. |
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"])
}
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.
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.
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.
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.
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.
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:
dagger.json, main.dang, and any other source files.mod engine require for reproducibility.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/[email protected]
They can then call its functions (dagger call ...) and run its checks (dagger check).
no Dagger module found containing path: . — mod and its sub-operations resolve the 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 allow --path to point inside the module.init fails with "workspace not loaded" — init writes into the nearest .dagger directory of a loaded workspace. Run it from within a workspace (a directory tree containing or under .dagger), or use -W/--workspace.--path, or remove the existing module first.!), that the last expression in a function body matches the declared return type, and that custom-type/enum names referenced actually exist.Void function must end in null — Void functions typically .sync a container (or service) to force evaluation, then return null as the final expression.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 with added-paths / modified-paths / as-patch.