A Dagger module for Deno projects, written in Dang.
It does not just wrap the deno CLI. It models a Deno project as a typed
object graph and maps Deno's toolchain onto Dagger's first-class verbs, so you
get:
- CI by design — the very same checks run at your desk and in CI, in one
pinned, consistent environment.
dagger checklocally is byte-for-byte what CI runs, so there is no separate pipeline to maintain and no "works on my machine". - Reproducible toolchains — a pinned Deno version and base image, the same everywhere.
- Warm dependency cache —
DENO_DIRmounted as a Dagger cache volume, shared across every run. - Reviewable formatting that can't go stale —
deno fmtcomes back as a changeset you preview and confirm before anything is written (great for agents and PR bots). And because Dagger surfaces every@generateas a check as well, a formatting generator doubles as an "is formatting up to date?" check — CI fails when the output drifts, with no extra wiring. - Monorepo-aware — a Deno workspace
(a root
deno.jsonwith aworkspacearray) is a first-class object: checks fan out across every member, and a single member's checks still resolve the shared lockfile, import map, and sibling packages. - Selectable — projects and workspaces are Dagger collections, so you can
list them and run checks on just the ones you name
(
--deno-project=apps/api). - Composability — install Deno into any container, produce standalone binaries, and extend it from your own module.
See designs/deno-module.md for the full design.
Requires Dagger v1.0.0-beta.15 or later.
The examples below use this layout: two standalone projects and a Deno workspace with two members.
apps/api/deno.json standalone project
apps/web/deno.json standalone project
packages/deno.json Deno workspace: { "workspace": ["./core", "./ui"] }
packages/core/deno.json member
packages/ui/deno.json member
1. Install the module into your workspace:
dagger install github.com/dagger/denoThis adds a [modules.deno] entry to your dagger.toml. Its settings:
| Setting | Default | Meaning |
|---|---|---|
version |
"2.9.3" |
Deno version for the default base image (denoland/deno:alpine-<version>). |
base |
the image above | Base container for Deno commands; it must have deno on PATH. |
permissions |
"config" |
Permissions for test and compile: "config" (the deno.json permission sets, -P), "all" (-A) or "none". See Test permissions. |
testArgs |
[] |
Extra deno test flags, e.g. ["--doc", "--parallel", "--ignore=_tools/"]. |
typeCheckArgs |
[] |
Extra deno check flags, e.g. ["--allow-import"]. |
typeCheckTargets |
["."] |
What type-check checks, relative to each project or workspace root, e.g. ["mod.ts"]. See Type-check. |
dagger settings deno lists them (its VALUE column shows only what you've
set). Set one with dagger settings deno <key> <value>; list values are JSON:
dagger settings deno version 2.9.3
dagger settings deno permissions all
dagger settings deno testArgs '["--doc"]'
dagger settings -u deno testArgs # unset: back to the defaultThe first three write
[modules.deno.settings]
version = "2.9.3"
permissions = "all"
testArgs = ["--doc"]The same settings apply to every project and workspace the module checks.
2. Run checks and generators. Projects and workspaces are discovered from where you stand (see Discovery):
dagger check # lint, test, type-check and format-check every project and workspace
dagger generate -y # format: runs `deno fmt` and writes the resultThese are Dagger's first-class verbs, so the same commands run identically in CI
— there is no separate pipeline to maintain. And because Dagger surfaces every
generator as a check too, dagger check also fails when your formatting is out
of date (the stale check).
3. Select what runs by project, workspace, or check name:
dagger list deno-projects -a # standalone projects, by path
dagger list deno-workspaces -a # Deno workspaces, by root path
dagger check -l --all # one line per project/workspace and check
dagger check --deno-project=apps/api # every check on one project
dagger check --deno --check test --deno-project=apps/api --deno-project=apps/web
dagger check deno/projects/lint # one check on every project
dagger check deno/workspaces/test --deno-workspace=packages
dagger generate -y --deno-project=apps/api # format just that project
dagger -W ./apps/api check # or scope by directoryOr just cd into the project: from inside it, it is the only one selected, with
no flag needed:
cd apps/api/src && dagger check # checks apps/api4. Or call functions directly. dagger call reaches the rest of the API:
looking a project up by path, its workspace root, compile, container.
--path may point inside a project; it snaps up to the nearest deno.json /
deno.jsonc (pass --find-up=false when it is already a root):
dagger call deno project --path apps/api/src path # -> apps/api
dagger call deno project --path apps/api testUse
dagger checkin CI.dagger callon a check function (lint,test,type-check,format-check) prints the result, but the command exits 0 even when the check fails.dagger checkfails the command.
Don't want to install? Run a function one-off with
-m, dropping the module name:dagger -m github.com/dagger/deno call project --path apps/api path.
dagger check, dagger generate and dagger list find Deno configs with
Workspace.findRoots, starting from the directory you run them in:
- every directory with a
deno.json/deno.jsoncat or below it, and - when that directory is not itself a project root, the nearest project enclosing it.
So:
| You run from | Selected |
|---|---|
a project root (apps/api) |
that project, and any project below it |
inside a project (apps/api/src) |
that project (the enclosing one), and any project below |
a directory in no project (the repo root, apps) |
every project below it |
A project above a project root you stand in is not selected; project /
workspace still reach it by path.
Each config directory then lands in exactly one collection:
- A directory whose config declares a non-empty
workspacearray is a Deno workspace (workspaces, keyed by its root). The config is the one deno reads:deno.jsonwhen a directory has both, anddeno.jsoncis parsed as JSONC, so comments and trailing commas are fine. A config that doesn't parse counts as a standalone project, whose checks then fail with deno's error. - A directory below a discovered workspace root is one of its members and is checked through the workspace, not on its own.
- Every other directory is a standalone project (
projects, keyed by its root).
Inside a workspace member (packages/ui/src), the member is the nearest
enclosing project, so the member is selected — not the whole workspace. Its
commands run from the workspace root with the member as the target
(deno test ui), as a workspace's own CI does, so the shared deno.lock,
import map and sibling packages resolve and tests see the root as their cwd;
dagger check there checks just that member.
Inside a workspace directory that belongs to no member (packages/docs), the
workspace itself is selected.
Keys are workspace-root-relative paths. Listing runs no container and no deno:
one findRoots walk, one ripgrep search for configs mentioning "workspace"
(only those few are read and parsed), and a findUp hop per enclosing project
to find the workspace a project belongs to. node_modules is skipped.
Discovery is static, so it doesn't evaluate deno.json beyond the workspace
array: a member listed in a workspace but missing its own config is covered by
the workspace but never selected on its own, and a directory below a workspace
root counts as a member even if the workspace array doesn't list it.
A Deno workspace — a
root deno.json with a workspace array of member packages sharing one
deno.lock and import map — is modeled as a workspace object. Its checks run a
single deno command at the root, so the toolchain fans out across every member:
# run all members' checks at once (deno fans out from the root)
dagger check --deno-workspace=packages
dagger check deno/workspaces/test --deno-workspace=packages
# list the members
dagger call deno workspace --path packages members pathTo work on one member, run from inside it — the member is then the only
project selected — or use project with the member's path. Its checks run from
the workspace root (so the shared lockfile and sibling @scope/pkg imports
resolve) and pass the member's directory as the target — deno lint ui,
deno test ui, deno check ui, deno fmt --check ui — so they cover that
member only. Its container has the whole workspace mounted with the member as
the workdir, for your own commands:
cd packages/ui && dagger check # just the ui member
dagger call deno project --path packages/ui workspace-root # -> packagesdagger check / dagger generate handle the mix automatically: each discovered
workspace is checked (with deno fanning out) and each standalone project is
checked on its own — members are never run twice. A workspace's members is a
plain list rather than a collection for that reason: its members are checked
through the workspace, not one by one.
| Function | Runs |
|---|---|
lint |
deno lint |
test |
deno test |
type-check |
deno check |
format-check |
deno fmt --check |
Each exists on a project (project …) and on a workspace (workspace …, running
across every member at once).
projects and workspaces on the root are collections, which is how
dagger check finds them:
| Collection | Keys | Dimension flag | Check addresses |
|---|---|---|---|
projects |
standalone project roots | --deno-project=PATH |
deno/projects/lint, …/test, …/type-check, …/format-check |
workspaces |
Deno workspace roots | --deno-workspace=PATH |
deno/workspaces/lint, …/test, …/type-check, …/format-check |
The generators are deno/projects/format and deno/workspaces/format, and
their staleness checks deno/projects/format/stale and
deno/workspaces/format/stale. dagger check --help lists the flags in effect:
--deno (--by-deno) selects this module, --deno-projects /
--deno-workspaces a whole dimension.
Each collection's batch runs its check on every selected item in parallel, then
fails naming each item that failed. Its batch format returns one changeset
over the selected items.
test runs deno test --permit-no-files, plus a permission flag chosen by the
permissions setting:
"config"(default) runsdeno test -P, which applies the permission set in the project'sdeno.json(test.permissions, or a top-level set). The same policy then applies locally (deno test -P), in CI and here. Deno printsPermissions in the config file is an experimental feature and may change in the future.with every run. A project without a permission set gets no permissions this way, so its tests that need any fail withNotCapable."all"runsdeno test -A. Use it when your CI grants permissions on the command line (deno test -A,--allow-read …), as denoland/std and oak do. Tests run in a container, so-Areaches the container, not your machine."none"passes no flag, so tests get no permissions at all.
dagger settings deno permissions all # for a repo whose CI runs `deno test -A`Config permission sets landed in Deno 2.5.0. If you set version to an
older release, "config" falls back to -A for tests, since -P doesn't
exist there.
Other deno test flags go in testArgs. Doc tests (the code blocks in JSDoc
comments and markdown) only run with --doc:
dagger settings deno testArgs '["--doc", "--parallel", "--ignore=_tools/"]'Large suites can need a lot of memory in the Dagger engine: a test killed for
running out of memory only reports exit code: 137. denoland/std's cbor
tests needed about 12 GiB. Give the engine more memory, or leave the heavy
tests out with --ignore=… in testArgs.
type-check runs deno check . from the project root; the config's exclude
applies. Add flags with typeCheckArgs — for example when tooling scripts
import remote modules:
dagger settings deno typeCheckArgs '["--allow-import"]'typeCheckTargets replaces the ., and like every setting it applies to every
project and workspace the module checks. Each target is resolved against each
root: a workspace member runs deno check <member>/mod.ts, a Deno workspace
deno check mod.ts at its root, a standalone project deno check mod.ts in
its own directory. So ["mod.ts"] only works where every project and
workspace has a mod.ts — a workspace root without one fails with TS2307.
dagger settings deno typeCheckTargets '["mod.ts", "src/"]'When projects differ, leave typeCheckTargets at . and shape what gets
checked per project instead: list what to skip in each deno.json's exclude,
or pass flags with typeCheckArgs.
format returns a changeset. dagger generate runs it on every selected
project and workspace and summarizes the changed files (apps/api/main.ts +3 -1), not the diff itself; it asks before writing. Without a terminal to ask
on (CI, a coding agent), pass -y to write the changes or --no-apply to only
list them:
dagger generate -y --deno-project=apps/api # write them
dagger generate --no-apply --deno-project=apps/api # just list them
dagger -y call deno project --path apps/api format # one project, via callA changeset applies from the directory you run in, so from inside a project
(apps/api/src) format returns only the changes under that directory; the
stale check there covers the same part. format-check still checks the whole
project.
compile returns the compiled executable as a File — export it or feed it into
an image. The build runs in a Linux container, so cross-compile with --target
if you need a different platform.
dagger call deno project --path apps/api \
compile --entrypoint main.ts export --path ./bin/app
# cross-compile
dagger call deno project --path apps/api \
compile --entrypoint main.ts --target x86_64-unknown-linux-gnu export --path ./bin/appLike test, compile takes its permissions from the permissions setting: with
"all" the binary is built with -A, with "none" with no permissions. With
the default "config", the baked-in permissions come from deno.json: declare
the app's default runtime permissions in a top-level permissions.default set
(this is the deno compile -P set, distinct from test.permissions). Below
Deno 2.5.0, "config" builds with no permissions rather than -A:
// deno.json
{
"permissions": {
"default": { "net": true }
}
}base is a ready-to-use container (Deno + cache). install adds the Deno CLI to
a container you provide (a glibc base such as debian/ubuntu/distroless-cc; for
musl/alpine use base).
# drop into a shell with deno available
dagger call deno base terminal
# print the configured version
dagger call deno versionThe settings (see Quick start) are also the module's
constructor arguments, so dagger call takes them before the function. For
dagger check, set them with dagger settings instead.
# pin a specific Deno version
dagger call deno --version 2.9.3 version
# bring your own base image
dagger call deno --base docker.io/denoland/deno:debian project --path apps/api \
compile --entrypoint main.ts export --path ./bin/app
# grant all permissions and run doc tests (a check: exits 0 even if it fails)
dagger call deno --permissions all --test-args=--doc project --path apps/api testInstall the module as a dependency and call it by name. This is where the real
value is: reuse the toolchain, add your own checks, ship images, and expose the
@up service the base module intentionally leaves to you.
dagger-module.toml:
[[dependencies]]
name = "deno"
source = "github.com/dagger/deno"main.dang:
type MyApp {
"""
CI for this app: reuse Deno's checks, add our own.
"""
ci(ws: Workspace!): Void @check {
let project = deno(version: "2.9.3", permissions: "all", testArgs: ["--doc"])
.project(ws, "apps/api")
run(project.lint(ws))
run(project.typeCheck(ws))
run(project.test(ws))
# Or through the collections: every standalone project, one of them, or
# some of them. Keys are workspace-root-relative project paths.
let projects = deno.projects(ws)
run(projects.batch.test(ws))
run(projects.get(key: "apps/web").lint(ws))
run(projects.subset(keys: ["apps/api", "apps/web"]).batch.formatCheck(ws))
run(deno.workspaces(ws).batch.typeCheck(ws))
null
}
"""
A check called through a dependency comes back as a Check that has not run:
ask whether it passed.
"""
let run(check: Check!): Void {
if (check.pass == false) {
raise check.error.message ?? "check failed"
}
null
}
"""
Ship a minimal image from the compiled binary.
"""
image(ws: Workspace!): Container! {
let bin = deno.project(ws, "apps/api").compile(ws, entrypoint: "main.ts")
container
.from("debian:stable-slim")
.withFile("/app", bin, permissions: 493)
.withEntrypoint(["/app"])
}
"""
Run this app's dev server with `dagger up`.
"""
serve(ws: Workspace!): Service! @up {
deno
.project(ws, "apps/api")
.container(ws)
.withExposedPort(8000)
.asService(args: ["deno", "serve", "--allow-net", "--port", "8000", "main.ts"])
}
}
The collections' batches (projects(ws).batch.lint(ws), .test, .typeCheck,
.formatCheck, and .format returning a Changeset) take the same ws; so
does each item's function.
The module is split into deno.dang (root Deno type and the DenoProjects /
DenoWorkspaces collections), deno-project.dang (DenoProject), and
deno-workspace.dang (DenoWorkspace).
End-to-end tests live in .dagger/modules/e2e: a Dang
module that installs this module and drives it against the sample projects under
.dagger/modules/e2e/modules (a clean project, a badly formatted one, one with a
jsr dependency, and a Deno workspace with two members that import each other).
# run the e2e checks
dagger check -m .dagger/modules/e2e
# or from the workspace root (also runs them)
dagger check