Skip to content
Fran Gonzalez
← Back to blog
(updated Oct 2, 2026)·Clanker·7 min read

Dagger vs native GitHub Actions for a Tauri app: a same-commit CI comparison

I ran the same CI checks through Dagger containers and native mise-driven GitHub Actions on the same commit, and measured a 78% test-time cut once the Rust cache actually stayed warm.

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.

Two CI lanes, one commit, one race: the Dagger pipeline my repo already had, next to the same checks rewritten as native GitHub Actions jobs driven by mise. The results changed how I think about container CI on ephemeral runners.

Note

Tested with Dagger 0.21.9, Mr. Boxington 1.11.0, Rust 1.98.1, and Zig 0.15.2 on GitHub-hosted Ubuntu runners on 2026-10-01. Fact-checked against Dagger Cloud pricing documentation on 2026-10-01.

Cold starts on ephemeral runners

My Tauri app’s CI ran as a Dagger module: two containers (Node frontend, Rust backend), dagger call check --source=. from one workflow step. I liked the design: typed pipeline code, reproducible containers, runnable locally.

The runs were slow and never got faster. Four consecutive runs: 674s, 620s, 701s, 473s. Two of them back-to-back within minutes of each other, both cold. On ephemeral GitHub runners, the Dagger engine and its cache volumes die with the job, so every push reinstalled the WebKitGTK stack and recompiled the full ~500-crate Tauri dependency tree. Dagger Cloud’s free tier is observability only (no cache persistence), and cache syncing isn’t in any self-serve plan I could find documented.

I wanted direct measurements: same commit, same event, Dagger job next to native jobs.

Running native mise jobs with mbx cache persistence

In PR #15 of my Tauri app, I added two native jobs to the existing workflow and left the Dagger job untouched as a baseline, so all lanes execute on the same SHA:

  • Web checks (native mise): mise install --locked node pnpm via mise-action, then the six frontend checks as individually-timed steps (57s total, parallel to everything else).
  • Rust checks (native mise): Tauri sysdeps via apt, mise install --locked rust zig mr-boxington (using Zig to compile native sysdeps), then fmt/clippy/test/build with the same flags the Dagger module uses.

The interesting part is the Rust cache. I initially added Swatinem/rust-cache, and it never warmed. Restore 2–4s (miss) every run, and the post-save step took 0s: it saved nothing. The cause: Mr. Boxington (mbx), the Rust compilation cache wired into the repo’s mise config, wraps cargo and redirects target/ into its own content-addressed store. rust-cache restores a target directory; it cannot capture a store it doesn’t know about, and nothing persisted the store itself.

mbx ships the designed answer: mbx cache export / mbx cache import, with a CI group mode. I validated the round-trip locally first: build into a fresh store, export the closure, import into another fresh store, rebuild:

$ mbx cache export --group ci /tmp/mbx-closure.tar
exported 367 actions and 1127 objects (1.1 GiB) for export group "ci"

$ mbx cache import /tmp/mbx-closure.tar   # in a store that has never seen this build
imported 367 actions and 1127 objects from /tmp/mbx-closure.tar (1.1 GiB)

$ cargo check -p deepblock-cli --locked
mbx[cache]: 356 hits, 11 misses, 10 bypassed

In CI, the tar rides the GitHub Actions cache, which mbx’s own export --format directory docs name as an intended transport. Job-level env records the group on every wrapped cargo invocation, and the closure is saved per commit with lockfile-keyed fallbacks:

# .github/workflows/ci.yml
env:
  MBX_CACHE_EXPORT_GROUP: ci
steps:
  - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
    with:
      path: ~/.cache/mbx-closure.tar
      key: mbx-${{ runner.os }}-${{ hashFiles('mise.lock', 'src-tauri/Cargo.lock') }}-${{ github.sha }}
      restore-keys: |
        mbx-${{ runner.os }}-${{ hashFiles('mise.lock', 'src-tauri/Cargo.lock') }}-
  - run: |
      if [ -s "$HOME/.cache/mbx-closure.tar" ]; then
        mise exec -- mbx cache import "$HOME/.cache/mbx-closure.tar"
        rm -f "$HOME/.cache/mbx-closure.tar"
      fi
  # ... fmt, clippy, test, build ...
  - run: mise exec -- mbx cache export --group ci "$HOME/.cache/mbx-closure.tar"
  - uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
    with:
      path: ~/.cache/mbx-closure.tar
      key: mbx-${{ runner.os }}-${{ hashFiles('mise.lock', 'src-tauri/Cargo.lock') }}-${{ github.sha }}

Reaching parity took six fix commits, each a lesson in what the Dagger containers had been quietly providing: the tooling tests spawn zsh; libghostty-vt-sys builds via zig; the pinned mbx 1.11 predates --format directory (tar works); an immutable cache key collides on the second save (suffix with the commit SHA); and the 14 GB runner disk filled to 2 MB free with the expanded store plus the export tar, fixed by reclaiming the preinstalled dotnet/ghc/codeql trees and deleting the tar after import.

Same-commit benchmark results

Looking at job wall time alone in a single run flatters Dagger: its 7m59s pass was an all-time fastest outlier, while the native job spent roughly 3.5 minutes on one-time runner setup (APT dependencies, disk space reclamation) and cache serialization overhead (mbx export and save).

The real comparison is visible when separating the actual checks execution (where caching operates) from runner overhead, and comparing cold vs warm runs on the exact same commit:

LaneModeTotal Job TimeChecks ExecutionNotes
Project checks (Dagger)Always cold7m59s (best) / ~10.5m (median)~7m40sOpaque single container step; every run cold on ephemeral runners (ranged up to 15m47s)
Rust checks (native mise)Cold (miss)10m19s (619s)9m06s (546s)First run or cache miss; comparable to Dagger cold
Rust checks (native mise)Warm (hit)7m36s–8m13s4m22s–4m23s (262s)−52% checks time; includes APT sysdeps, disk cleanup, and mbx tar export/import
Web checks (native mise)Independent55s–57s35sParallel job; fully hidden in wall time

The warm cache effect is concentrated in the checks themselves:

StepCold (Run 36860004602)Warm (Run 36872562300)Warm (Run 36943266991)Change
Clippy412s (6m52s)225s (3m45s)225s (3m45s)−45% (−3m7s)
Test123s (2m03s)28s27s−78% (−1m36s)
Build11s9s10s−18%
Checks execution546s (9m06s)263s (4m23s)262s (4m22s)−52% (−4m43s)
Setup & sysdeps55s97s72sAPT desktop deps + runner disk reclaim
Cache import / export12s101s99sRestore, import, export, and save mbx closure
Total job wall time10m19s (619s)8m13s (493s)7m36s (456s)−26% total job time

When the cache hits, mbx skips recompilations cleanly: rustc arrived to find 187 compilations already done. awkward. (3m 16s saved).

Every failure along the way was diagnosed from per-step timings in seconds. Under Dagger the same failures surface as “Run Dagger checks: failed, 575s.”

Note

Follow-up optimization (Run 37006028611): Replacing the un-cached manual apt-get install step with awalsh128/cache-apt-pkgs-action cut the Linux desktop dependency installation step from 56s down to 8s. That dropped total job wall time to 6m 34s (394s), reducing runner setup overhead without touching the compiled closure.

Lessons and tradeoffs

The single-run comparison is a trap: Dagger’s best-ever run landed in the same run as the native lane’s first fully-green warm pass, making a ~20–25% steady-state advantage look like a tie. I’d collect medians from the start.

I’d also check cache-scope rules before debugging them: caches saved during pull_request events live under the PR merge ref and are invisible to workflow_dispatch runs on the branch. I burned a run on that.

The unresolved trade: Dagger still owns forge portability and one-command local reproduction (dagger call check works unchanged). My resolution is to keep the native jobs as what CI runs and keep the ci/ module as the local runner: the container definitions encode the environment once, which the YAML had to rediscover one red run at a time.

References

  • Dagger: container pipelines; engine runs as Linux containers via host runtimes
  • Dagger Cloud pricing: free tier is observability; no documented cache persistence in self-serve plans
  • Mr. Boxington: shared Cargo compilation cache; cache export/import with CI groups
  • mise and mise-action: locked tool installs in CI
  • Swatinem/rust-cache: ineffective when Mr. Boxington redirects Cargo targets into its own store
  • actions/cache: transport for the mbx closure
  • cache-apt-pkgs-action: caches APT packages and deb archives across GitHub-hosted runner runs

This post was written with AI assistance.