Skip to content
Fran Gonzalez
← Back to blog
(updated Jul 16, 2026)·Clanker·10 min read

Adding Dagger CI to an Astro Blog

One container, ten checks, local/CI parity, and three bugs that taught me how Dagger works.

Some matmuls wrote this slop, sorry. My goal with this content is to document some work I (a real human bean) do while poking the Clanker, and try to learn something along the way.

I ported a Dagger CI module to replace frangonf.com’s ten sequential GitHub Actions checks and two duplicate Astro builds with one container run.

Pipeline constraints

The original ci.yml ran pnpm install on the GHA runner, then ten sequential checks: audit-prod, audit-all, audit-signatures, obsidian-check, format-check, markdown-lint, astro-sync, eslint, typecheck, build. The deploy.yml ran a second pnpm install + astro build in a separate job to produce dist/ for GitHub Pages deployment. Two pnpm installs, two builds, and zero local/CI parity.

Dagger design

I adapted a ci/ module pattern: a Dagger TypeScript SDK module with a single Ci class. The nodeBase() method builds a node:26.1.0-slim container, installs pnpm globally, copies package.json and lockfiles, runs pnpm install, and copies the source directory. Individual @func() methods wrap each check. A check() method chains all ten on one container.

The CI module

// ci/src/index.ts
import { dag, Directory, object, func } from "@dagger.io/dagger";

@object()
export class Ci {
  private nodeBase(source: Directory) {
    return dag
      .container()
      .from("node:26.1.0-slim")
      .withExec(["npm", "install", "-g", "pnpm@11.1.2"])
      .withExec(["mkdir", "-p", "/app"])
      .withWorkdir("/app")
      .withFile("/app/package.json", source.file("package.json"))
      .withFile("/app/pnpm-lock.yaml", source.file("pnpm-lock.yaml"))
      .withFile("/app/pnpm-workspace.yaml", source.file("pnpm-workspace.yaml"))
      .withExec(["pnpm", "install", "--frozen-lockfile"])
      .withDirectory("/app", source, { exclude: [".git", "node_modules"] });
  }

  @func()
  async check(source: Directory): Promise<string> {
    const steps = [
      {
        name: "audit-prod",
        cmd: ["pnpm", "audit", "--prod", "--audit-level", "high"],
      },
      { name: "audit-all", cmd: ["pnpm", "audit", "--audit-level", "high"] },
      { name: "audit-signatures", cmd: ["pnpm", "audit", "signatures"] },
      {
        name: "obsidian-check",
        cmd: ["pnpm", "run", "normalize:obsidian:check"],
      },
      { name: "format-check", cmd: ["pnpm", "run", "format:check"] },
      { name: "markdown-lint", cmd: ["pnpm", "run", "lint:md"] },
      { name: "astro-sync", cmd: ["pnpm", "astro", "sync"] },
      { name: "eslint", cmd: ["pnpm", "run", "lint"] },
      { name: "typecheck", cmd: ["pnpm", "run", "typecheck"] },
      { name: "build", cmd: ["pnpm", "run", "build"] },
    ];

    let container = this.nodeBase(source);
    const results: string[] = [];

    for (const { name, cmd } of steps) {
      try {
        container = container.withExec(cmd);
        const output = await container.stdout();
        if (output.trim()) results.push(`[${name}] ${output}`);
      } catch (error) {
        throw new Error(`${name} failed: ${error}`);
      }
    }

    return results.join("\n");
  }
}

How the CI module works

nodeBase() is private. Dagger only exposes @func()-decorated members; private methods stay internal to the module.

The method builds a container by chaining six operations on an immutable Container. Every chained method returns a new snapshot. The original container is never mutated.

dag.container().from("node:26.1.0-slim") creates a container from a Node.js 26 slim base image. Dagger pulls the image once and caches it.

.withExec(["npm", "install", "-g", "pnpm@11.1.2"]) runs npm install -g pnpm inside the container. The withExec API accepts a string array as the command and arguments, runs it, and returns a new container snapshot carrying the resulting filesystem changes. The version pin pnpm@11.1.2 matches the version in mise.toml, keeping local and CI tooling aligned.

.withExec(["mkdir", "-p", "/app"]) creates the application directory.

.withWorkdir("/app") sets the working directory. Every subsequent file operation and command resolves paths relative to /app.

.withFile("/app/package.json", source.file("package.json")) copies a single file from the host Directory into the container. source.file() reads a file from the host filesystem; withFile writes it at the specified container path. I copy package.json, pnpm-lock.yaml, and pnpm-workspace.yaml before pnpm install. Dagger caches each intermediate layer; placing lockfiles before the install means the container skips pnpm install entirely when no lockfile changed.

.withExec(["pnpm", "install", "--frozen-lockfile"]) installs dependencies. --frozen-lockfile prevents pnpm from mutating the lockfile, keeping installs deterministic.

.withDirectory("/app", source, { exclude: [".git", "node_modules"] }) copies the full source tree into the container, skipping .git and node_modules. The withDirectory API accepts exclude and include patterns to filter what gets copied. Excluding these directories avoids copying hundreds of megabytes the container does not use.

The check() method carries @func(). Dagger exposes decorated members as callable functions. The async modifier and Promise<string> return type let me await async operations inside the method body.

The steps array defines ten checks as {name, cmd} tuples. Each tuple maps a human-readable name to the shell command array Dagger passes to withExec.

The loop chains every check on one container: container = container.withExec(cmd) overwrites the variable with each new snapshot. All ten steps share the same pnpm install, node_modules, and generated files from earlier steps (see Bug 1 for why this matters).

After each command, await container.stdout() captures the stdout from the last withExec. I push non-empty output into a results array tagged with the step name.

If a step exits with a non-zero code, catch wraps the error in a new Error that includes the step name. The failure message tells me which of the ten checks broke without reading the full stack trace.

results.join("\n") joins all captured output into one string. Dagger prints the return value in the call output.

The CI workflow

The ci.yml dropped from 77 lines to 63. Two Dagger calls, a lychee link check, and the artifact upload.

jobs:
  ci:
    name: CI
    runs-on: ubuntu-latest
    environment: dagger
    steps:
      - uses: actions/checkout@...
        with:
          persist-credentials: false

      - uses: dagger/dagger-for-github@v8.4.1
        with:
          version: "v0.21.5"
          module: "ci"
          cloud-token: ${{ secrets.DAGGER_CLOUD_TOKEN }}
          call: check --source=.

      - uses: dagger/dagger-for-github@v8.4.1
        with:
          version: "v0.21.5"
          module: "ci"
          cloud-token: ${{ secrets.DAGGER_CLOUD_TOKEN }}
          call: export-dist --source=. export --path dist

      - uses: lycheeverse/lychee-action@v2.9.0
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          lycheeVersion: v0.24.2
          args: --verbose --no-progress ./dist ./README.md

      - uses: actions/upload-pages-artifact@v5.0.0
        with:
          path: dist

How the CI workflow runs

The first dagger/dagger-for-github step calls check. The dagger-for-github action starts a Dagger engine inside the runner and invokes the local ci/ module. The call parameter is a shorthand that combines function name and arguments: check --source=. calls the check function with the repository root as the source Directory. cloud-token: ${{ secrets.DAGGER_CLOUD_TOKEN }} connects to Dagger Cloud for caching and observability. version: "v0.21.5" pins the Dagger CLI to match mise.toml.

The second dagger/dagger-for-github step calls export-dist. It reuses the same engine process the first call started, so the pnpm install layer hits the cache immediately. call: export-dist --source=. export --path dist chains two operations: export-dist runs astro build inside the container and returns a Directory, then export --path dist writes it to the host runner filesystem. export at the CLI level writes to the host; SDK-level .export() writes to the module sandbox (see Bug 3 for the distinction).

The lycheeverse/lychee-action step recursively scans the exported dist/ directory and README.md. Shared options in lychee.toml set dist as the content root, enable the request cache, and exclude a known external site that rate-limits. Passing the directory directly avoids shell-dependent ** expansion. The GITHUB_TOKEN secret authenticates GitHub API calls during link checking, avoiding IP-based rate limiting on GitHub-hosted pages.

The actions/upload-pages-artifact step publishes dist/ as a GitHub Pages artifact. The corresponding deploy.yml job picks up this artifact instead of running a second pnpm install and astro build.

The deploy.yml Build job (35s of pnpm setup + install + build) was removed entirely. CI now handles the artifact upload.

Local parity

mise run ci:dagger
mise run lychee

The first command runs the Dagger checks in the same container used by CI. The second builds the production site and runs the same recursive Lychee policy as GitHub Actions. Lychee stays outside Dagger because it validates the host-exported dist/ artifact. Both paths read lychee.toml.

Three Bugs I Walked Into

1. Dagger functions create isolated containers

Each @func() creates its own container. I originally had separate astroSync() and eslint() methods. astroSync() generated .astro/types.d.ts inside its container, but eslint() started fresh without it. Type-aware ESLint failed with 44 @typescript-eslint/no-unsafe-* errors.

The fix was simple once I understood the model: chain pnpm astro sync before pnpm run lint inside the eslint method, and also run it in the check() chain at step 7 so all downstream steps have the generated types.

2. Copying files that don’t exist

The original nodeBase() pattern I adapted copies .npmrc into the container. frangonf.com doesn’t have one. Dagger’s source.file(".npmrc") throws if the file is missing. Removing the line fixed it, but the lesson is that copying the bootstrap code verbatim from another project will break on filesystem differences.

3. Inline .export() doesn’t write to the GHA runner

I tried embedding the dist export inside check():

await container.directory("/app/dist").export("dist");

This exported dist/ to the Dagger module’s sandbox filesystem, not the GHA runner. Only CLI-level chaining writes to the host runner:

call: export-dist --source=. export --path dist

The error message (tar: dist: Cannot open: No such file or directory) was clear once I looked at the logs: .export() at the SDK level writes to the module sandbox, while CLI-level chaining writes to the host runner filesystem.

Verification

The second export-dist call runs with cached layers: the pnpm install hits the Docker layer cache, the astro build runs in about 3s inside the container. The total cost is 33s of CI time for a second Dagger invocation with a warm cache hit.

BeforeAfter
ci.yml: 10 sequential GHA steps5 steps (checkout, 2x Dagger, lychee, upload)
2 pnpm installs per deploy (bare runner)2 (second is layer-cached in Dagger)
2 astro build per deploy2 (check builds, export-dist rebuilds from cache)
Deploy pipeline2 jobs
No local paritymise run ci:dagger
Individual checks: 12 separate containers, ~50s local1 container chain in check(), ~30s local

The links check runs after export-dist via lycheeverse/lychee-action on the exported dist/ directory. The GHA token avoids rate limiting on GitHub-hosted links. A later coverage review found that the original unquoted ./dist/**/*.html argument reached only shallow indexes in CI. The current directory input scans the full generated tree.

Lessons from the first implementation

The export-dist call is still a second Dagger invocation inside ci.yml. The same engine keeps layers cached, so it’s fast, but a single call that handles both checks and artifact export would be cleaner. Dagger’s model of functions returning Directory types works for local use but the dagger/dagger-for-github action’s behavior around where directories land on the runner filesystem took some trial and error.

I also didn’t add a content-only fast path (skip format/lint/typecheck when only blog posts changed). For this project’s size, the full CI is fast enough that the extra logic isn’t worth the complexity.

References

I later ported this module to a polyglot monorepo, where the lockfile-first caching broke because uv builds the project at install time. See Adding Dagger CI to a Polyglot Monorepo.

This post was written with AI assistance.