Caching DevContainer Builds in CI

Rebuilding a devcontainer from scratch on every CI run is slow and wasteful. This page caches the build with a registry layer cache (cacheFrom/cacheTo) and persists package-manager stores, so a pipeline reuses prior work and the environment is ready in seconds.

This matters because a devcontainer image is usually the slowest thing a pipeline touches. A cold build pulls a base image, runs every apt-get and feature install, and then installs the whole application dependency tree before a single test runs. On a busy repository that cost is paid on every push, every pull request, and every re-run, and it compounds across the matrix of jobs a change fans out into. Caching converts that repeated, mostly-identical work into a one-time cost that later runs inherit, which is the difference between a pipeline that gates merges in under a minute and one that developers learn to ignore because the feedback arrives too late to act on.

Reach for build caching the moment a devcontainer build appears in CI and the build step dominates wall-clock time, not before. The mental model to hold is that there are two independent caches doing two different jobs. The registry layer cache, driven by cacheFrom and cacheTo, lets BuildKit skip Dockerfile instructions whose inputs have not changed by pulling the finished layer from a registry like GHCR. The dependency cache, driven by an action such as actions/cache, restores the package-manager store — ~/.npm, a pip wheelhouse, a Go module cache — so the install step reuses previously downloaded artifacts. Keep those two mechanisms distinct in your head: one caches the image, the other caches the packages, and they fail and recover for different reasons.

Prerequisites

You need a registry the CI runner can push/pull and a devcontainer build.

  • A registry (GHCR, ECR, etc.) reachable from CI.
  • The devcontainers/ci action or the CLI with BuildKit.
  • Lockfiles committed for keying dependency caches.

Cache prerequisitesYou need registry cache pull/push and a dependency cache keyed on the lockfile.cacheFrompull layerscacheTopush layersDep cachekeyed on lockfileVerifyfast reuse

The prerequisite people most often get wrong is the registry credential scope. A cacheFrom pull can succeed anonymously against a public image, which masks the problem until the first cacheTo push fails with a permissions error and the cache silently never populates. The runner needs write access to the same registry path it reads from, so on GHCR that means the workflow token carries packages: write, and on ECR it means the job assumed a role allowed to push to the repository. The second detail worth checking up front is that your lockfile is actually committed and deterministic. A dependency cache keyed on hashFiles('package-lock.json') is only trustworthy when that lockfile is the single source of truth for the resolved dependency set; a floating version range or an uncommitted lockfile makes the key meaningless because the same key can map to different installed packages on different runs.

Step-by-Step Implementation

  1. Enable the registry layer cache.
      - uses: devcontainers/ci@v0.3
        with:
          cacheFrom: ghcr.io/acme/dev-image
          push: always     # publish cacheTo for the next run

The cacheFrom value points the devcontainers/ci action at the registry tag where a previous run stored its finished layers, and BuildKit consults that manifest before executing each Dockerfile instruction. When the inputs to an instruction are unchanged, it imports the cached layer instead of rerunning the command. Setting push: always is what closes the loop: it publishes the newly built image and its cache metadata back to ghcr.io/acme/dev-image so the next run has something to pull. Omit the push and you get a cache that is read but never written, which produces the confusing symptom of a pipeline that never gets faster no matter how many times it runs. Pointing cacheFrom at a stable, long-lived tag rather than a per-commit tag is what keeps the cache warm across branches instead of starting cold on every feature branch.

  1. Order the Dockerfile so stable layers cache well.
# system packages (rarely change) BEFORE app deps (change often)
RUN apt-get update && apt-get install -y --no-install-recommends git curl

Layer caching in Docker is positional: an instruction is only reused if every instruction before it also hit the cache. That single rule dictates the whole ordering strategy. System packages installed with apt-get change rarely, so putting that RUN line near the top means it stays cached across almost every build. Application dependencies, which change whenever a developer adds a library, belong lower down, after the system layer and ideally split so the manifest copy and install happen before the source copy. The --no-install-recommends flag is not just a size optimization here; by refusing to pull in loosely-related recommended packages it keeps the layer's input set small and stable, so an unrelated upstream change to a recommended package cannot invalidate a layer you expected to reuse. Get the order wrong and you pay for the mistake on every build, because a churny layer near the top forces everything beneath it to rebuild.

  1. Cache dependencies keyed on the lockfile.
      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ hashFiles('package-lock.json') }}

This step caches the package-manager store rather than the image, which is why it uses actions/cache@v4 and not the registry cache. The path is the download cache — ~/.npm for npm, and the equivalent for whatever manager the project uses — not node_modules, because caching the resolved store lets the install step verify and relink packages while still skipping the network fetch. The key binds the cache entry to a hash of package-lock.json, so a run only restores the cache when the resolved dependency set is byte-for-byte the same. When someone changes a dependency the hash changes, the key misses, and the install repopulates a fresh entry; when nothing changes the key hits and the install is nearly instant. This exact-match keying is what prevents the classic failure mode where a cache quietly serves yesterday's packages after the lockfile has moved on.

  1. Verify a second run reuses layers (much faster).

Verification is a real step, not a formality, because a cache that appears to be configured can still be missing on every run. Trigger the pipeline twice with no source changes and compare the two build logs. On the second run the BuildKit output should show CACHED next to the apt-get and dependency-install lines, and the actions/cache step should report a cache hit rather than a save. Watch the total build time as the headline signal: the second run should collapse from minutes to seconds, and if it does not, the log will tell you which half is at fault — a rebuilt image layer points at cacheFrom/cacheTo or layer order, while a slow install with an image hit points at the dependency key. Making this comparison part of how you land the change means you catch a cache that never populates before it quietly costs the whole team weeks of slow builds.

CI cache anatomyA registry layer cache plus a lockfile-keyed dependency cache makes CI builds fast.cacheFromreuse prior layersLayer orderstable-firstDependency cachekeyed on lockfileResultseconds, not minutes

Common Pitfalls

Cache misses come from unordered layers, no registry cache, or a coarse key.

A pitfall that only surfaces once a cache is actually being reused is file ownership. The package-manager store you persist with actions/cache is written by whatever user ran the install, and when a later job restores those files under a container that runs as a different UID, the install can refuse to touch them or silently rebuild everything to sidestep the permission mismatch. If a devcontainer runs as a non-root vscode user but the cache was populated as root, the restore lands files the running user cannot write, and you get the worst of both worlds: the cache download cost with none of the reuse benefit. The fix is to keep the user consistent between the run that saves the cache and the run that restores it, and to point the cache path at a directory that user owns rather than a root-owned system location.

The other trap is treating cacheTo as something every job should publish. Only builds on a trusted, stable ref — typically the default branch — should write the shared cache, because a cache pushed from an arbitrary pull request can poison the layers every later build pulls from. Pull-request jobs should read the cache with cacheFrom but not push, while push on the main build keeps the shared tag authoritative. Skipping this distinction is how a cache slowly fills with layers built from unmerged, sometimes untrusted, changes, and it undermines the reproducibility the cache was supposed to protect.

Cache triageA triage path from full rebuilds to reused layers and dependency caches.Does a second run reuse layers?NOAdd cacheFrom/cacheToIs the dep cache keyed on the lockfile?YESOrder layers stable-firstFast, correct CI cache

SymptomRoot CauseRemediation
Full rebuild every runNo registry layer cacheSet cacheFrom and cacheTo
Small change busts the whole cacheApp deps layered too earlyPut stable layers first
Cache serves stale depsKey not tied to the lockfileKey on the lockfile hash
Cache never populatespush disabledPublish cacheTo on main builds

Conclusion

CI caching is two layers: a registry image cache so Docker reuses build layers, and a lockfile-keyed dependency cache so package installs reuse downloads. Order the Dockerfile stable-first, and a CI build goes from minutes to seconds while staying reproducible.

The strategic payoff is that fast, cached builds change developer behavior. When the pipeline returns in under a minute, contributors run it before pushing, keep pull requests small, and treat CI as a live check rather than a nightly chore they route around. That feedback loop is only durable if the cache stays correct, which is why the lockfile key and the stable-first layer order matter as much as the speed: a cache that is fast but occasionally serves the wrong dependencies erodes trust faster than a slow build ever could, and once people stop believing the pipeline they stop reading it.

This is the same pin-and-cache discipline that runs through reproducible devcontainers generally. Pinning fixes what a build resolves to — a specific base image, a specific lockfile — and caching reuses the artifacts that pinning made deterministic. cacheFrom/cacheTo and a hashFiles('package-lock.json') key are just the CI expression of that idea: the key only matches when the inputs are identical, so a cache hit is also a proof that nothing meaningful changed. Treat the two caches as an accelerator layered on top of reproducibility, never as a shortcut around it, and the pipeline stays both quick and honest as the project grows.

Two cache typesA registry layer cache and a lockfile-keyed dependency cache work together.Layer cachecacheFrom/cacheToStable-first orderRegistry imageDep cacheLockfile keyPackage store pathRestore + save

FAQ

What's the difference between the layer cache and the dependency cache? The layer cache reuses Docker build layers (the image build itself) via cacheFrom/cacheTo. The dependency cache reuses downloaded packages (npm, pip, etc.) keyed on your lockfile. Both help; the layer cache saves image build time, the dependency cache saves install time. They also live in different places — the layer cache in a registry like GHCR, the dependency cache in the CI provider's own cache store — and they fail independently, so it is normal to see one hit while the other misses. Configure and verify them separately rather than assuming that fixing one addresses the other.

Why does a tiny change rebuild everything? Because a frequently-changing layer sits early in the Dockerfile, invalidating everything after it. Put rarely-changing layers (system packages) first and application dependencies last, so a small change invalidates as little as possible. A common offender is a broad COPY . . placed before the dependency install: because it copies the whole working tree, any source edit changes that layer's inputs and busts the install layer beneath it. Copy only the manifest and lockfile first, install, then copy the rest, so editing application code no longer reaches back and invalidates the expensive install.

How do I keep a cache from serving stale dependencies? Key the dependency cache on the lockfile hash (hashFiles('package-lock.json')). Then the cache is only reused when the resolved dependency set is unchanged, so it can never smuggle in a stale package. Avoid the temptation to add a restore-keys fallback that matches a partial prefix for this store unless you are certain a partial restore is safe, because a loose fallback can hand back an older cache that no longer matches the current lockfile. When correctness matters more than a marginal hit rate, an exact key with no fuzzy fallback is the safer default.

Should pull requests publish to the cache? No. Let pull-request jobs read the shared cache with cacheFrom but leave push off, and reserve cacheTo publishing for builds on the default branch. A shared cache that anyone can write becomes a supply-chain surface: a layer built from an unmerged branch could be pulled into every later build. Reading everywhere and writing only from trusted refs keeps the cache fast for contributors without letting untrusted changes seed it.

Why is my cache slow to restore even when it hits? A very large dependency cache can spend more time downloading and unpacking than a fresh install would take, especially when the store accumulates versions that are no longer referenced. Scope the path to the download cache rather than a fat node_modules tree, and let the lockfile key roll the entry over when dependencies change so stale layers age out instead of piling up. If restores stay slow, measure the cache size — the goal is to cache the expensive network fetch, not to ship gigabytes of rarely-used artifacts on every run.

Do I still need the layer cache if I have a dependency cache? Usually yes, because they cover different costs. The dependency cache skips package downloads, but it does nothing for the apt-get system layer, feature installs, or any build step baked into the image itself. On a devcontainer with meaningful setup beyond npm install, the registry layer cache is what removes the bulk of the cold-build time, and the dependency cache trims what remains.