_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.
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.
| Date | Issue, comment, or pull request | Decision or result |
|---|---|---|
| April 2022 | Issue #2140, Improve CI Caching | Tracked 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 2022 | PR #2519, CUE-based cache config | Prototyped reusable GitHub Actions and registry cache configuration. The draft closed without merging. |
| February 2023 | Engine-operator design comment | Shifted cache policy toward engine configuration and automatic decisions rather than pipeline-level choices. |
| February 2023 | PR #4543, initial remote cache config | Added _EXPERIMENTAL_DAGGER_CACHE_CONFIG and support for BuildKit registry, gha, s3, and azblob backends. The first implementation was server-configured. |
| April 2023 | PR #4923, cache service | Moved 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 2024 | Issue #6911 and its transferred discussion #12338 | Recorded the missing CI-persistence documentation, engine-volume workarounds, persistent-engine option, and eventual 0.20.1 type=gha report. |
| July 2024 | Issue #8004, Storage Drivers | Proposed 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 2024 | PR #7767, local engine cache API | Added APIs to inspect and prune local engine cache. This merged as foundational cache-control work. |
| July to October 2024 | PR #7646, host cache volumes and dagger/buildkit PR #3 | Explored a host-backed volume snapshotter and concurrent sharing. The Dagger PR closed as an unmerged draft; the BuildKit-fork PR remains open. |
| January 2026 | PR #11729 | Separated content and recipe digests and explicitly prepared cache keys for BuildKit solver removal. |
| February 2026 | PR #11767 | Removed more LLB coupling and described the project as approaching deletion of the BuildKit solver. |
| February 2026 | PR #11838 | Made the DagQL cache aware of IDs and results. Its design notes say this enables persistent results, pruning, observability, and a future remote cache. |
| March 2026 | The working type=gha comment | Demonstrated the old bridge on Dagger 0.20.1 with a GitHub runtime helper. |
| April 2026 | PR #11856 | Migrated 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 2026 | Dagger 0.21.0 | Shipped the DagQL cache rewrite. The client-side experimental parser survived while its engine consumer disappeared. |
| June 2026 | Issue #13405 and maintainer response | Confirmed 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:
type=registryexport issue #8717 reached the exporter and then failed while computing a blob.- S3 issue #9059 reported missing descriptor handlers for lazy blobs.
- Issue #11328 reported successful exports followed by session-shutdown timeouts on 0.19.3.
- Issue #11885 reported an S3 exporter stack overflow on the second run with 0.19.11.
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=2url_v2=$ACTIONS_RESULTS_URLtoken=$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 0.20.1 client declarations, parser, and metadata assignment. The complete client half of the old bridge
- Dagger 0.20.1 session cache bridge. The server-side consumer for BuildKit cache import and export config
- Dagger 0.21.7 client declarations, parser, metadata fields, and assignment. Surviving client plumbing without an engine consumer
- PR #11729, PR #11767, and PR #11838. Preparatory steps toward a DagQL-native cache and BuildKit solver removal
- PR #11856. Migrates all caching to DagQL, removes the BuildKit solver, and fixes issue #8955
- Dagger 0.21.0 release notes. Public release record for the cache architecture change
- Issue #13405 maintainer response. Explicit confirmation that 0.21 dropped remote cache export and that replacement work is underway
Dagger cache design history
- Issue #2140 and PR #2519. Early CI-cache goals and the unmerged CUE configuration prototype
- Engine-operator cache comment. Rationale for automatic engine-level cache policy
- PR #4543. Introduces the experimental env var and BuildKit backend selection
- PR #4923. Moves upstream cache configuration to the client and distinguishes it from Dagger’s cache service
- Issue #6911 and discussion #12338. CI persistence question and community investigation
- Working 0.20.1
type=ghacomment. The configuration that prompted this experiment - Issue #8004. Proposal for pluggable layer storage, host cache volumes, observability, and a possible DagQL-native design
- PR #7767. Merged local cache inspection and pruning API
- PR #7646 and dagger/buildkit PR #3. Unmerged host-backed cache-volume work
- Scaling discussion #6486. Maintainer analysis of ephemeral engines, shared cloud cache, and persistent engines
Old BuildKit bridge limitations
- Registry export issue #8717. Blob computation failed during experimental registry export
- S3 lazy-blob issue #9059. Imported S3 cache lacked descriptor handlers
- Session-shutdown issue #11328. A completed export still caused a client timeout
- S3 stack-overflow issue #11885. The second import-export cycle crashed the old engine
GitHub Actions and alternatives
- Docker BuildKit GitHub Actions cache. Defines
version=2,url_v2, authentication, and scope - GitHub cache service shutdown. Official legacy-service deadline
- crazy-max/ghaction-github-runtime v4.0.0. Node 24 runtime-variable helper
- actions/cache and v6.1.0 release. Node 24 host-side cache action
- Dagger Built-In Caching and Dagger Cloud. Current cache model and hosted distributed-cache path
- Depot with Dagger. Managed runner option with a persistent Dagger cache
- Nexus Repository and Verdaccio. Pull-through package-registry alternatives
- GitHub cache restrictions and workflow artifacts. Visibility and artifact-handoff rules
- GitLab distributed cache and MinIO. CI-native and object-store archive alternatives
- Git LFS. Large-file option for explicitly vendored artifacts
- Gradle build cache, Bazel remote caching, sccache, ccache, Turborepo, and Nx. Tool-native remote cache examples
- Dependency caching belongs at the registry layer, not the package store. Companion post on package-registry caching
Footnotes
-
GitHub’s March 2025 breaking-change notice gives April 15, 2025 as the legacy cache shutdown date. ↩
This post was written with AI assistance.