Dagger Design: Part 1 - Workspaces and Modules
This proposal is part 1 of an multi-part proposal to simplify the "design knot" - an interconnected set of design and ux problems blocking implementation of a wide range of features and improvements
Dagger currently conflates two distinct concepts in a single dagger.json file:
- Project configuration (what tools to use, how to configure them)
- Module definition (what code to package, what dependencies it needs)
This causes confusion about what files are accessible, how dependencies work, and what appears in the CLI.
This proposal establishes workspaces and modules as two fundamentally different things, each with their own configuration file and dependency model.
A workspace is a directory used as context for configuring and using Dagger. Typically it is a git repository, or a subdirectory within a larger repo.
The main way to configure a Dagger workspace is to add modules to it, possibly with custom workspace-level configuration.
A Dagger module is software packaged for the Dagger platform (engine & SDK). It implements functions and types to extend the capabilities of a Dagger workspace: typically with new ways to build, check, generate, deploy, or publish artifacts within the workspace.
Modules can access Dagger's powerful API to orchestrate system primitives (containers, secrets, etc), as well as interact with the contents of the workspace. This allows deep integration with the project's existing tools by parsing their configuration files and adapting behavior - the best of both worlds between native integration and cross-platform repeatability.
| Workspace | Module | |
|---|---|---|
| What it is | A directory (project context) | Packaged software |
| Configured via | .dagger/config.toml |
dagger.json |
| Contains | Modules added to it | Functions and types |
| Purpose | Configure Dagger for this project | Extend Dagger's capabilities |
| Analogy | A VS Code workspace | A VS Code extension |
Modules are added to workspaces. Once added, a module can access and interact with the workspace's contents.
There are two distinct ways to depend on a module:
| Relationship | Meaning | Configured in | Example |
|---|---|---|---|
| Workspace → Module | "Use this module in my project" | .dagger/config.toml |
Adding go-toolchain to build your Go code |
| Module → Module | "My code calls this module" | dagger.json |
Importing a helper library in your module's source |
These serve different purposes:
-
Workspace → Module is project configuration. The module gains access to your workspace and extends your CLI. This is how you set up your development environment.
-
Module → Module is code dependency. One module's implementation calls another module's functions. The dependency is internal - it doesn't affect the workspace or CLI.
A module added to a workspace is not the same as a module dependency. They live in different config files, are installed with different commands, and have different effects.
Configuration loading happens in the engine, not the CLI. When the CLI connects to the engine, the engine detects the workspace and loads modules before the CLI issues any commands.
-
Find workspace: Walk up from the client's working directory looking for a
.dagger/directory. The directory containing.dagger/is the workspace root.- If not found: check for
dagger.jsonwith legacy triggers → fail with migration error (see No Runtime Compat Mode). Otherwise, fall back to.gitdirectory or current directory as workspace root.
- If not found: check for
-
Parse config: Read
.dagger/config.tomlif it exists. If.dagger/exists but has noconfig.toml, the workspace is valid but empty. Resolve all paths relative to the.dagger/directory.
Workspace detection always runs, regardless of other flags. The engine always knows the workspace root and config. This is important for the future workspace API (Part 2), where modules can access workspace context.
After workspace detection, the engine loads modules based on connect-time parameters sent by the client as metadata headers. These parameters control what gets loaded into the schema before any queries are served.
| Parameter | Type | Default | Effect |
|---|---|---|---|
ExtraModules |
[]ExtraModule |
[] |
Additional modules to load. Each entry has a Ref (module source reference), optional Name override, and Alias flag (if true, the module's functions are promoted to the Query root as auto-aliases). |
SkipWorkspaceModules |
bool |
false |
When true, skip loading modules from .dagger/config.toml. Workspace detection still runs. |
IncludeCoreModule |
bool |
false |
When true, include core API functions (container, directory, git, etc.) at the Query root. When false (default), only module constructors and auto-aliased functions are available at the Query root. |
RemoteWorkdir |
string |
"" |
A git ref (e.g. github.com/foo/bar@v1.0) to use as the workspace source. When set, the engine clones this repo and performs workspace detection within it instead of using the client's local filesystem. See Remote Workspaces. |
These are the primitives. The CLI maps its flags onto them:
- No flags:
ExtraModules=[], SkipWorkspaceModules=false, IncludeCoreModule=false— workspace modules load from config, core API is not exposed at the Query root. -m <ref>:ExtraModules=[{Ref: "<ref>", Alias: true}], SkipWorkspaceModules=true, IncludeCoreModule=false— workspace modules skipped, explicit module loaded with its functions as top-level commands, core API not exposed.-C <git-ref>:RemoteWorkdir="<git-ref>"— workspace detection runs against the remote repo instead of the local filesystem. All other parameters apply normally.
Other clients (MCP servers, SDKs, custom tooling) can use these parameters directly to control module loading without going through CLI flags. For example, an MCP server could set IncludeCoreModule=true to expose both module functions and core API primitives to an AI agent.
Workspace modules (when SkipWorkspaceModules is false):
- Load workspace modules: For each entry in
[modules], resolve the source to a module and install its constructor as a top-level function in the GraphQL schema. Ifalias = true, the module's functions are also installed as top-level aliases (see Auto-Aliases). - Serve: The CLI dispatches
dagger call <name> <function>by looking up<name>in the schema — either a module constructor or an aliased function.
Extra modules (when ExtraModules is non-empty):
- Load extra modules: For each entry, resolve the ref to a module. If
Aliasis true, promote its functions to the Query root. IfNameis set, override the module's name. - Serve: The CLI dispatches
dagger call <function>against the module's functions at the Query root.
The CLI has a single initialization path — it never calls Serve() or manages module state. It simply reads whatever the engine served and builds commands from the schema's type definitions.
The -m flag provides:
- Backwards compatibility: Existing CI scripts using
dagger call -m ./ci testcontinue to work. The module loads in isolation, functions are top-level. - Ad-hoc module usage: Run any module by reference without installing it in the workspace:
dagger call -m github.com/foo/bar build. - No duplicate code paths: Both modes use the same engine-side module loading pipeline. The CLI has a single initialization path — it never calls
Serve()or manages module state. - Workspace is always available: Even with
-m, the workspace has been detected. Today this is unused, but it enables a future where-mmodules can access workspace context (e.g., reading workspace config, accessing workspace files toolchain-style).
The CLI always operates in workspace context. There is no "module context" at the CLI level — -m simply changes which modules the engine loads.
# Add a module to the workspace
dagger install github.com/dagger/go-toolchainThe module is registered under its name from dagger.json. Override with --name:
dagger install github.com/dagger/go-toolchain --name=goThis updates .dagger/config.toml. If no workspace exists yet:
- If in a git repository, creates
.dagger/config.tomlat the repo root - Otherwise, creates in the current directory
Once installed, module functions are available:
dagger call go build
dagger call go testConfig file: .dagger/config.toml (human-editable)
# Paths to ignore during workspace operations (extends .gitignore)
# ignore = ["docs/**", "marketing/**"]
# Modules added to this workspace
[modules.ci]
source = "modules/ci"
[modules.node]
source = "github.com/dagger/node-toolchain@v1.0"
[modules.go]
source = "github.com/dagger/go-toolchain@v1.0"
config.goVersion = "1.22"
config.lintStrict = trueEach module entry is a table with a required source and optional config.* keys. The table key (e.g., go) is the module's local name in this workspace — it determines the CLI namespace (dagger call go build) and is used by aliases and other references.
Paths are relative to the .dagger/ directory.
The config.* keys set default values for the module's constructor arguments:
[modules.go]
source = "github.com/dagger/go-toolchain@v1.0"
config.goVersion = "1.22"
config.lintStrict = true
config.tags = ["integration", "unit"]The config keys map directly to constructor argument names. Supported types: strings, booleans, numbers, arrays.
This replaces customizations in dagger.json. Only constructor arguments can be configured - this keeps the config surface simple and encourages module authors to expose important settings as constructor parameters.
Config values are evaluated by the engine the same way as .env defaults — variable expansion (${SYSTEM_VAR}), env:// references for secrets, and file/directory path resolution all work:
[modules.go]
source = "github.com/dagger/go-toolchain@v1.0"
config.goVersion = "1.22"
config.cacheDir = "${HOME}/.cache/go"
config.apiKey = "env://GO_API_KEY"This replaces .env-based user defaults for constructor arguments. The .env mechanism for setting constructor arg defaults is deprecated — use config.* in workspace config instead. The engine provides the same evaluation behavior regardless of whether values come from .env or config.toml.
.env files remain supported for non-constructor function argument defaults, which cannot be expressed in workspace config.
A module with alias = true has all of its functions promoted to top-level commands:
[modules.ci]
source = "modules/ci"
alias = trueWith this config, if the ci module has functions build, test, and lint, all of the following work:
dagger call ci build # explicit: module constructor + function
dagger call build # auto-alias: function promoted to top level
dagger call test # auto-alias
dagger call lint # auto-aliasAuto-aliases are the primary mechanism for backwards compatibility during migration. When a legacy "project module" is migrated to a workspace module, setting alias = true preserves existing dagger call <function> commands without requiring an [aliases] section or per-function configuration.
This strikes a balance between facilitating migration and avoiding the baggage of a "main module" concept. The module is still explicit in the config — it just has a shorthand for its functions. Users can always use the fully qualified dagger call ci build form, and can remove alias = true when they're ready to drop the shortcuts.
Module config and workspace settings can be overridden per environment (e.g., CI, staging, production). Environments are selected explicitly via --env:
dagger check --env=ciSee Part N: Environments (coming soon) for the full design.
Top-level ignore defines paths to skip during all workspace operations (glob, search, file access). This extends .gitignore for tracked-but-irrelevant parts of the repo:
ignore = ["docs/**", "marketing/**", "data-science/**"]The engine already respects .gitignore by default. The ignore key covers paths that are tracked in git but irrelevant to Dagger - useful in large monorepos to speed up module discovery.
For scoping which artifacts to operate on (rather than which files to see), use artifact path filtering. See Part 3: Artifacts:
dagger check --path='./myapp'Lock file: .dagger/lock (machine-managed)
[["version", "1"]]
["modules", "resolve", ["github.com/dagger/go-toolchain@v1.0"], "abc123..."]
["core", "git.ref", ["https://github.com/dagger/go-toolchain", "v1.0"], "abc123..."]
The lock file pins module versions to exact commits and caches runtime lookups (git refs, container digests, HTTP content). Modules can store their own lookups under their namespace. See PR #11156 for the lockfile design.
Most projects eventually need custom logic that doesn't fit existing modules - custom build steps, project-specific CI/CD, glue code combining multiple tools. This is when you create a module.
# From anywhere in the workspace
dagger module init --sdk=go ciThis:
- Creates
.dagger/modules/ci/with module source - Auto-installs in
.dagger/config.toml:[modules.ci] source = "modules/ci"
- Module is immediately callable:
dagger call ci <function>
The --sdk flag is required. Options: go, python, typescript, php, or a custom SDK module reference.
Directory structure:
repo/
├── .dagger/
│ ├── config.toml
│ └── modules/
│ └── ci/
│ ├── dagger.json
│ └── main.go
└── src/
Your module code lives in .dagger/modules/<name>/. Edit the generated source files to add functions:
// .dagger/modules/ci/main.go
func (m *Ci) Build(ctx context.Context) *dagger.Container {
// ...
}Changes take effect immediately - just run dagger call ci build.
To make a module installable by other projects:
Option 1: Promote an existing module
Move it from .dagger/modules/foo/ to a git-accessible location (dedicated repo, monorepo subdirectory, etc.).
Option 2: Start standalone
Run dagger module init outside any workspace:
mkdir my-module && cd my-module
dagger module init --sdk=go my-moduleWhen no workspace is found, the module is created in the current directory.
Either way, others can then install it:
dagger install github.com/you/my-moduleTo test a standalone module during development, create a workspace:
dagger install .
dagger call my-module <function>The --workdir / -C flag accepts remote git references in addition to local paths. This allows running Dagger against a remote project's workspace without cloning it locally.
# Run a function from a remote project's workspace
dagger -C github.com/dagger/dagger@main call ci build
# Same thing, long form
dagger --workdir github.com/dagger/dagger@main call ci build
# Subdirectory within a repo
dagger -C github.com/dagger/dagger/subproject@v1.0 call build
# Local paths still work (existing behavior)
dagger -C /path/to/project call build
dagger -C ../sibling-project call buildThe flag name -C follows the git -C convention for changing the working directory.
When the engine receives a RemoteWorkdir connect-time parameter:
- Parse the git ref: Extract the repo URL, version (tag/branch/commit), and optional subdirectory.
- Clone the repo: Use the engine's existing git pipeline to fetch the repo at the specified version.
- Detect workspace: Look for
.dagger/config.tomlwithin the cloned tree (starting from the subdirectory if specified). - Load modules: For each module entry in the config:
- Local sources (e.g.
source = "modules/ci") are resolved as paths within the cloned repo. The engine constructs a full git ref (e.g.github.com/dagger/dagger/.dagger/modules/ci@main) and loads the module through the standard git module pipeline. - Remote sources (e.g.
source = "github.com/other/module@v1.0") are loaded as-is.
- Local sources (e.g.
This reuses the same git cloning and module loading infrastructure that -m uses for remote modules. No new abstractions are needed — the engine already knows how to clone repos and load modules from git refs.
- Read-only: Mutating operations (
dagger install,dagger module init) are not supported with a remote workdir. These commands require writing to the host filesystem. - No local host access: When using a remote workdir, modules cannot access the local filesystem via
host.directory()etc. The workspace context is the cloned git tree, not the local machine. - Config values:
config.*entries that reference local environment variables (${VAR}) or secrets (env://KEY) resolve against the local environment where Dagger runs, not the remote repo's environment.
This section covers the transition from the current model. The design principle is: no runtime compat mode. The engine has exactly one loading path (workspace config). All backwards compatibility is handled by dagger migrate, which transforms legacy configurations into the new format.
When the engine encounters a dagger.json with legacy triggers and no .dagger/config.toml, it fails with a clear message:
Error: this project uses a legacy module format.
Run 'dagger migrate' to update your project.
The engine never interprets legacy dagger.json at runtime. This keeps the loading path clean — one format, one behavior, no branching.
Two independent signals in dagger.json indicate a legacy project needing migration. They are not mutually exclusive — a single file can match both:
| Trigger | What it signals | Example |
|---|---|---|
source != "." |
Legacy "project module" — a module that doubles as project config | "source": ".dagger" |
Has toolchains |
Toolchains that should become workspace modules | "toolchains": [...] |
Modules that match neither trigger (pure modules with source == "." or absent, no toolchains) are not legacy. They get a clean break: call them via dagger call -m . or install them in a workspace with dagger install ..
The dagger migrate command detects legacy triggers and transforms the project. It handles two cases: project module migration and toolchain migration.
dagger migrate is implemented as a standalone Dagger module. It receives the project source via +defaultPath, performs the migration logic using Dagger's own APIs (module introspection for enumerating constructor args and functions, user defaults discovery, etc.), and returns a Changeset. The CLI's built-in Changeset handling shows the user a diff preview and prompts to apply.
# Run migration (shows diff, prompts to apply)
dagger call -m github.com/dagger/migrate migrate
# Or install as a toolchain first
dagger toolchain install github.com/dagger/migrate
dagger call migrate migrateThis keeps migration logic out of the engine, makes it independently testable, and dogfoods the module and Changeset APIs.
Triggered by: source != "." in dagger.json.
A "project module" is one where dagger.json sits at the project root with source code in a subdirectory (e.g., source: ".dagger"). In the new model, modules are self-contained packages — dagger.json lives alongside the source, and source is always ..
Steps:
-
Move module source to
.dagger/modules/<name>/:.dagger/* → .dagger/modules/<name>/ dagger.json (project root) → .dagger/modules/<name>/dagger.json -
Update the moved
dagger.json:- Remove
sourcefield (now always.) - Remove
toolchainsfield (migrated separately, see below) - Rewrite
dependencies[].sourcepaths relative to new location - Rewrite
includepaths relative to new location
- Remove
-
Create
.dagger/config.toml:- Add module under
[modules]withalias = trueto preserve backwards compat withdagger call <function> - Enumerate the module's constructor arguments and write each as a commented-out
config.*entry (with type-appropriate example values)
- Add module under
Example — migrating dagger/dagger (name: dagger-dev, source: .dagger):
.dagger/config.toml (generated):
[modules.dagger-dev]
source = "modules/dagger-dev"
alias = true # preserves 'dagger call <function>' backwards compat
# Constructor arguments (uncomment to configure):
# config.someArg = "value".dagger/modules/dagger-dev/dagger.json (moved and updated):
{
"name": "dagger-dev",
"sdk": {"source": "go"},
"dependencies": [
{"name": "changelog", "source": "../../toolchains/changelog"},
{"name": "docs", "source": "../../toolchains/docs-dev"},
...
]
}Triggered by: toolchains field present in dagger.json.
Each toolchain becomes a workspace module. Toolchain source directories are left in place — only the configuration moves.
Steps:
For each toolchain entry:
-
Add to
[modules]in.dagger/config.toml, with path relative to.dagger/:[modules.go] source = "../toolchains/go" [modules.ci] source = "../toolchains/ci"
-
Migrate customizations (if any):
Customization type Action Default value for constructor arg Migrate to [modules.<name>.config]ignore,defaultPath, or other non-value customization for constructor argAdd warning comment with original value Customization targeting a non-constructor function Add warning comment with original value (cannot be migrated) Example —
gotoolchain with constructor-levelignorecustomization:[modules.go] source = "../toolchains/go" # WARNING: constructor arg 'source' had 'ignore' customization that cannot # be expressed as a config value. Original: # {"argument":"source","ignore":["bin",".git","**/node_modules",...]}
Example —
securitytoolchain with function-level customization:[modules.security] source = "../toolchains/security" # WARNING: customization for function 'scanSource' could not be migrated # (non-constructor). Original: # {"function":["scanSource"],"argument":"source","ignore":["bin",".git","docs",...]}
-
Remove
toolchainsfield from the migrateddagger.json.
For each module (project module and toolchains), dagger migrate uses the Dagger API's existing user defaults introspection to discover .env-based defaults.
For each discovered default:
| Default type | Action |
|---|---|
| Constructor arg with simple value | Migrate to config.* in config.toml |
| Constructor arg with variable expansion | Migrate as-is — engine evaluates ${VAR} the same way in config.toml |
Constructor arg with env:// reference |
Migrate as-is — engine handles env:// the same way in config.toml |
| Non-constructor function arg | Add warning comment (cannot be expressed in workspace config) |
Example — .env before migration:
GO_VERSION=1.22
GO_CGO=true
GO_CACHE_DIR=${HOME}/.cache/go
SECURITY_API_KEY=env://SECURITY_KEYAfter migration in .dagger/config.toml:
[modules.go]
source = "../toolchains/go"
config.version = "1.22"
config.cgo = true
config.cacheDir = "${HOME}/.cache/go"
[modules.security]
source = "../toolchains/security"
config.apiKey = "env://SECURITY_KEY"The migrated .env entries should be removed or commented out to avoid duplicate defaults. Note: the Dagger API's user defaults introspection may not track the source file path of each default — if so, dagger migrate should print which .env entries to remove manually.
| Command | Current | New |
|---|---|---|
dagger init |
Creates a module | Deprecated; use dagger module init |
dagger call in module dirs |
Loads module from dagger.json |
Only reads workspace config (.dagger/config.toml) |
dagger call -m |
Specifies current module | Loads explicit module instead of workspace modules. Workspace is still detected (not loaded). Functions appear as top-level commands. |
dagger -C <ref> |
(new) | Change workspace directory. Accepts local paths or remote git refs. Alias: --workdir. |
dagger install |
Adds code dependency to module | Adds module to workspace |
dagger module dependency add |
(new) | Adds code dependency to a module's dagger.json |
dagger migrate |
(new) | Migrates legacy project to workspace format |
Before — workspace ancestor pattern (no sdk, no source, toolchains only):
{"name": "dagger.io", "engineVersion": "v0.19.8",
"toolchains": [
{"name": "api", "source": "api"},
{"name": "dagger-cloud", "source": "cloud"}
]}After dagger migrate:
.dagger/config.toml:
[modules.api]
source = "../api"
[modules.dagger-cloud]
source = "../cloud"The dagger.json is removed (it had no sdk, no source — it was purely config).
Before — both triggers (source != ".", has toolchains):
{"name": "dagger-dev", "sdk": {"source": "go"}, "source": ".dagger",
"toolchains": [
{"name": "go", "source": "toolchains/go", "customizations": [...]},
{"name": "security", "source": "toolchains/security", "customizations": [...]},
... (17 more)
],
"dependencies": [...]}After dagger migrate:
.dagger/config.toml:
[modules.dagger-dev]
source = "modules/dagger-dev"
alias = true # preserves 'dagger call <function>' backwards compat
[modules.changelog]
source = "../toolchains/changelog"
[modules.ci]
source = "../toolchains/ci"
[modules.cli]
source = "../toolchains/cli-dev"
[modules.docs]
source = "../toolchains/docs-dev"
# ... (15 more toolchains)
[modules.go]
source = "../toolchains/go"
# WARNING: constructor arg 'source' had 'ignore' customization that cannot
# be expressed as a config value. Original:
# {"argument":"source","ignore":["bin",".git","**/node_modules",...]}
[modules.security]
source = "../toolchains/security"
# WARNING: customization for function 'scanSource' could not be migrated
# (non-constructor). Original:
# {"function":["scanSource"],"argument":"source","ignore":["bin",".git","docs",...]}.dagger/modules/dagger-dev/dagger.json:
{
"name": "dagger-dev",
"sdk": {"source": "go"},
"dependencies": [
{"name": "changelog", "source": "../../toolchains/changelog"},
{"name": "docs", "source": "../../toolchains/docs-dev"},
{"name": "helm", "source": "../../toolchains/helm-dev"},
{"name": "sdks", "source": "../../toolchains/all-sdks"},
{"name": "engine-dev", "source": "../../toolchains/engine-dev"},
{"name": "cli", "source": "../../toolchains/cli-dev"}
]
}Prototype in progress: PR #11812
Next: Part 2: Workspace API
Changelog
[workspace] ignorefor monorepo performance optimization