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

_EXPERIMENTAL_DAGGER_CACHE_CONFIG is inert in Dagger 0.21

A source trace explains why Dagger's BuildKit cache bridge works on 0.20.1, disappears in 0.21, and leaves type=gha with no effect.

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 tried to send Dagger’s layer cache to GitHub Actions. Four successful CI runs produced zero cache entries because Dagger 0.21 removed the BuildKit solver that consumed the configuration while leaving the client-side env var behind.

Note

Tested with Dagger 0.21.7 on GitHub-hosted Actions runners on 2026-07-16. The source comparison covers Dagger 0.20.1 through 0.21.7.

Observed failure

On an ephemeral GitHub-hosted runner, the Dagger engine dies with the job. The next run starts with an empty local engine cache. My install repeatedly reported reused 0, downloaded 731.

Dagger’s client still recognizes _EXPERIMENTAL_DAGGER_CACHE_CONFIG. A March 2026 community report showed type=gha producing CACHED labels with Dagger 0.20.1. I applied the same idea to Dagger 0.21.7, exposed the GitHub runtime URL and token, and moved the install from cache volumes into ordinary container layers.

The workflow passed, yet GitHub’s cache API kept returning this:

{ "total_count": 0, "actions_caches": [] }

Dagger’s debug output contained the env var; cache import and export activity was absent. That combination pointed past the workflow and into the engine version.

The version boundary in the source

Dagger 0.20.1 contains the complete bridge. The pinned client source declares three env vars:

// engine/client/client.go
const (
  cacheConfigEnvName        = "_EXPERIMENTAL_DAGGER_CACHE_CONFIG"
  cacheImportsConfigEnvName = "_EXPERIMENTAL_DAGGER_CACHE_IMPORT_CONFIG"
  cacheExportsConfigEnvName = "_EXPERIMENTAL_DAGGER_CACHE_EXPORT_CONFIG"
)

The combined var configures both directions. The other two configure imports and exports independently. The 0.20.1 parser accepts k1=v1,k2=v2;k3=v3: commas separate attributes, semicolons separate backend configurations, and \; escapes a literal semicolon. Each backend requires type=. The parser creates BuildKit’s controlapi.CacheOptionsEntry type, and the client metadata assignment sends it to the engine.

In Dagger 0.20.1’s session initialization, the engine reads those metadata fields, validates the requested importer and exporter, and attaches them to the session:

// engine/server/session.go in v0.20.1
for _, cacheImportCfg := range clientMetadata.UpstreamCacheImportConfig {
  _, ok := srv.cacheImporters[cacheImportCfg.Type]
  if !ok {
    return fmt.Errorf("unknown cache importer type %q", cacheImportCfg.Type)
  }
  sess.cacheImporterCfgs = append(sess.cacheImporterCfgs, bkgw.CacheOptionsEntry{
    Type:  cacheImportCfg.Type,
    Attrs: cacheImportCfg.Attrs,
  })
}

Dagger 0.21 changed the cache architecture. The 0.21.0 release notes describe the migration to DagQL and removal of the BuildKit solver backend. Pull request #11856 contains the implementation.

The server-side consumer above disappears in 0.21.0. In 0.21.7, the env-var declarations and read site, parser, metadata fields, and client assignment remain. A source search finds four references to each metadata field: one declaration and one assignment for imports, then the same pair for exports. The engine has zero read sites.

That leftover plumbing explains the silent result. The client accepts a valid config and transmits it. The 0.21 engine discards it. gha, registry, s3, and the other BuildKit backends all lose their path into Dagger at this version boundary.

The old bridge covered BuildKit solver results. Ordinary filesystem layers created by operations such as withExec, withFile, and withDirectory could participate. withMountedCache contents stayed in engine-local cache volumes because BuildKit remote exporters treated cache directories as a separate feature. The 2022 CI caching epic #2140 tracked cache-directory import and export separately for this reason.

Dagger issue #13405 independently reported the same source-level result on 0.21.4. A maintainer confirmed the removal, explained that the replacement backend lacks remote export, and said an improved exporter is under development.

The issue closed after that response. The comment confirms intent, the current limitation, and planned replacement work. It gives no release number or delivery date.

The decision trail

The 0.21 behavior follows several years of cache-design changes. Reading only the surviving env var hides that history.

DateIssue, comment, or pull requestDecision or result
April 2022Issue #2140, Improve CI CachingTracked seamless cache configuration, cache-directory import and export, observability, and support beyond GitHub Actions. It also warned that transferring package caches can cost more than downloading from a CDN.
June 2022PR #2519, CUE-based cache configPrototyped reusable GitHub Actions and registry cache configuration. The draft closed without merging.
February 2023Engine-operator design commentShifted cache policy toward engine configuration and automatic decisions rather than pipeline-level choices.
February 2023PR #4543, initial remote cache configAdded _EXPERIMENTAL_DAGGER_CACHE_CONFIG and support for BuildKit registry, gha, s3, and azblob backends. The first implementation was server-configured.
April 2023PR #4923, cache serviceMoved upstream BuildKit backend configuration to the client-side env var. The PR described synchronous upstream exporters as usable but missing cache-service benefits such as pooling and asynchronous export.
March 2024Issue #6911 and its transferred discussion #12338Recorded the missing CI-persistence documentation, engine-volume workarounds, persistent-engine option, and eventual 0.20.1 type=gha report.
July 2024Issue #8004, Storage DriversProposed pluggable layer storage, host-backed cache volumes, cache observability, and user-owned S3, GCS, Azure, R2, or registry storage. It also raised a DagQL-native cache as a possible long-term redesign.
July 2024PR #7767, local engine cache APIAdded APIs to inspect and prune local engine cache. This merged as foundational cache-control work.
July to October 2024PR #7646, host cache volumes and dagger/buildkit PR #3Explored a host-backed volume snapshotter and concurrent sharing. The Dagger PR closed as an unmerged draft; the BuildKit-fork PR remains open.
January 2026PR #11729Separated content and recipe digests and explicitly prepared cache keys for BuildKit solver removal.
February 2026PR #11767Removed more LLB coupling and described the project as approaching deletion of the BuildKit solver.
February 2026PR #11838Made the DagQL cache aware of IDs and results. Its design notes say this enables persistent results, pruning, observability, and a future remote cache.
March 2026The working type=gha commentDemonstrated the old bridge on Dagger 0.20.1 with a GitHub runtime helper.
April 2026PR #11856Migrated all caching to DagQL, added generalized persistence and pruning, and removed the BuildKit solver. The PR lists deletion of remaining solver-related code as unfinished cleanup.
May 2026Dagger 0.21.0Shipped the DagQL cache rewrite. The client-side experimental parser survived while its engine consumer disappeared.
June 2026Issue #13405 and maintainer responseConfirmed that remote cache export was intentionally dropped and that a replacement is under development.

Two details matter for interpreting this trail. Issue #8004 closed as completed on March 24, 2026, and its public timeline ends at that closure. PR #11856 later delivered DagQL persistence inside the engine. Issue #13405 then confirmed that remote export remained future work.

The rewrite also fixed cache behavior. PR #11856 closes issue #8955, a cache-invalidation bug, and notes that stripping BuildKit fixed it along the way. This helps explain the architectural move: Dagger gained a cache keyed directly on its DagQL operation graph while losing BuildKit’s ready-made remote exporters.

Why staying on 0.20.1 carries risk

The old bridge had backend-specific failures before its removal:

Those reports concern the BuildKit-backed architecture before 0.21. They establish that the experimental path had backend-specific failure modes. Pinning 0.20.1 is a compatibility choice with an already-replaced subsystem rather than a durable upgrade path.

The earlier Dagger design already separated this synchronous bridge from its cache service. PR #4923 called out pooling and asynchronous exports as cache-service benefits. The 0.21 rewrite completes the separation by making DagQL the local cache authority and leaving a new remote-export implementation pending.

GitHub cache v2 is a separate requirement

A working 0.20.1 setup also needs GitHub’s cache API v2. GitHub shut down the legacy cache service on April 15, 2025.1 BuildKit’s current gha backend documentation defines the explicit v2 parameters:

  • version=2
  • url_v2=$ACTIONS_RESULTS_URL
  • token=$ACTIONS_RUNTIME_TOKEN

url=$ACTIONS_CACHE_URL selects the retired v1 path when the version is unspecified. BuildKit can infer v2 from ACTIONS_CACHE_SERVICE_V2 or from a results-receiver URL, but an explicit version avoids that dependency.

The current runtime helper is crazy-max/ghaction-github-runtime v4.0.0. It runs on Node 24. I verified the tag and signed commit, then pinned the full SHA for zizmor:

# Compatibility example for Dagger 0.20.1 only
- name: Expose GitHub runtime
  uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0

- name: Build cache config
  id: cache-config
  run: |
    echo "::add-mask::${ACTIONS_RUNTIME_TOKEN}"
    echo "config=type=gha,version=2,mode=max,scope=dagger-ci,url_v2=${ACTIONS_RESULTS_URL},token=${ACTIONS_RUNTIME_TOKEN}" >> "$GITHUB_OUTPUT"

- name: Run Dagger
  uses: dagger/dagger-for-github@27b130bf0f79a7f6fbbbe0fbca6760dc9bb40a77 # v8.4.1
  env:
    _EXPERIMENTAL_DAGGER_CACHE_CONFIG: ${{ steps.cache-config.outputs.config }}
  with:
    version: "v0.20.1"
    module: "ci"
    call: check --source=.

The full-SHA pins satisfy zizmor’s immutable-revision check. Both commits have valid GitHub verification. This example documents the last compatible Dagger release; it is outside the upgrade path I want for this project.

Conclusion

The GitHub Actions workflow was reaching Dagger correctly. The cache API URL and token were visible, the experimental env var reached the client, and every run passed. The server-side consumer is absent from Dagger 0.21.7.

The practical alternatives now live in Practical cache strategies for ephemeral Dagger CI. The inert type=gha setup should leave the workflow whichever persistence boundary replaces it.

Investigation retrospective

I initially traced the parser on the current tag and assumed the metadata reached an active BuildKit solver. Comparing the same symbols across 0.20.1 and 0.21.0 exposed the missing consumer. Future experiments with undocumented flags should start with an end-to-end symbol trace on the exact pinned release, followed by a deliberately invalid backend to prove the engine reads the setting.

References

Dagger source and the 0.21 cutover

Dagger cache design history

Old BuildKit bridge limitations

GitHub Actions and alternatives

Footnotes

  1. GitHub’s March 2025 breaking-change notice gives April 15, 2025 as the legacy cache shutdown date.

This post was written with AI assistance.