Caching Maven and Gradle Dependencies in a DevContainer

JVM projects pull large dependency trees, and re-downloading them every rebuild is slow. This page caches the Maven ~/.m2 repository or the Gradle cache on a named volume, so a rebuild reuses downloaded artifacts and builds stay fast.

Dependency resolution, not compilation, is where JVM rebuild time usually concentrates. A typical Spring or enterprise Java project pulls dozens or hundreds of transitive dependencies from Maven Central or a corporate repository, and on a cold container that download is the slow part of getting to a working build — often far longer than compiling the code itself. Because a devcontainer's writable layer is discarded on rebuild, those downloaded artifacts vanish with it, so without a persistent cache every rebuild re-fetches the entire tree. Caching the dependency store on a named volume is the single change that fixes this, turning a multi-minute cold download into a near-instant reuse on every rebuild after the first.

The dependency store is simply another package cache, treated the same way as a language's other dependency caches — the Rust registry, Go's module cache, Python's wheels. You put the directory the build tool downloads into on a volume so it survives rebuilds, and because your build files and the wrapper pin the exact dependency and tool versions, a warm cache holds precisely the artifacts a clean build would fetch. That pinning is what makes the caching safe: the cache can only ever contain versions your build already declares, so reusing it never changes what the build resolves. The result is pure speed with no reproducibility cost.

Prerequisites

You need a Java devcontainer using Maven or Gradle — a pinned JDK via the Java Feature and the build tool available, ideally through its wrapper. This page assumes that foundation from the parent JVM guide and adds the caching volume on top. Using the wrapper (mvnw/gradlew) is not just about reproducibility here; it also means the build tool downloads its own pinned version into the same store you are caching, so the volume covers the tool distribution as well as the dependencies.

  • A Java Feature pinned to a JDK.
  • Maven or Gradle (ideally via the wrapper).
  • A named volume for the dependency store.

The detail to confirm before mounting is the exact store path for your remoteUser, because it depends on the home directory. Maven's local repository lives at $HOME/.m2/repository and Gradle's cache and wrapper distributions under $HOME/.gradle, which for a vscode user are /home/vscode/.m2 and /home/vscode/.gradle. Mounting the volume at the wrong path — a common copy-paste error when the config uses a different user — caches nothing while looking configured, so derive the target from whatever remoteUser you actually use.

Cache prerequisitesYou need the store path, a volume, a warm step, and a reuse check.Store path~/.m2 or ~/.gradleVolumemount itWarmdownload depsVerifyreuse

Step-by-Step Implementation

  1. Mount the dependency store on a volume (Gradle shown).
{
  "mounts": ["source=devcontainer-gradle-cache,target=/home/vscode/.gradle,type=volume"],
  "remoteUser": "vscode"
}

Mounting ~/.gradle caches both the resolved dependencies and the wrapper's downloaded Gradle distribution, so neither is re-fetched on a rebuild. A single named volume on this directory therefore covers the two persistent costs of a Gradle build — the dependency tree and the Gradle version the wrapper pins — while the build daemon, which is a live process rather than a cached artifact, simply starts fresh after a rebuild and stays warm within the session.

  1. Or for Maven, cache ~/.m2.
{ "mounts": ["source=devcontainer-m2,target=/home/vscode/.m2,type=volume"] }

Maven's local repository under ~/.m2/repository is the equivalent store, holding every downloaded artifact keyed by group, artifact, and version. Mounting it works identically to the Gradle case, and if your project uses both tools — or you maintain projects across both — you cache both directories with separate volumes. The principle is the same regardless of tool: the store is the reusable, version-pinned cache of downloaded dependencies, and it belongs on a volume that survives rebuilds.

  1. Warm the cache on create.
{ "postCreateCommand": "./gradlew --no-daemon dependencies" }

Warming the cache at create time front-loads the download so it overlaps with other container setup rather than making your first real build slow. The dependencies task resolves and downloads the full tree without compiling, and --no-daemon keeps the create step clean by not leaving a daemon holding state from before the code is even built. The Maven equivalent is mvn dependency:go-offline, which downloads everything needed to build offline. Because this runs on every create and lands on the mounted volume, the download happens once and every rebuild reuses it.

  1. Verify a rebuild reuses the store.
./gradlew --no-daemon build --offline   # succeeds from cache

The --offline flag is a strong proof of a warm cache: it forbids any network access, so a build that succeeds offline is provably using only cached artifacts. If the offline build fails complaining it cannot resolve a dependency, the cache is incomplete or not being reused — either the warm step did not run, the volume target is wrong, or an ownership problem prevents the build from reading the store. Running the offline build after a rebuild is the definitive confirmation that the volume is doing its job, far more conclusive than simply noticing a build "felt faster."

JVM cache anatomyA volume-backed dependency store with a wrapper-pinned build tool makes JVM builds fast.Dependency store~/.m2 or ~/.gradleNamed volumesurvives rebuildsWrapper-pinned toolreproducible resolutionResultfast, offline-capable

Common Pitfalls

Slow JVM builds come from an uncached store or missing wrapper — and the two failures are related, because both undermine the reuse the cache is meant to provide. An uncached store means every rebuild re-downloads; a missing wrapper means the build-tool version can vary between machines, which in the worst case can cause the cache to be organized or invalidated differently. The triage below sorts the symptoms, but the recurring diagnostic question is whether the build is re-downloading dependencies it should already have, which the offline build test answers definitively.

The ownership pitfall is the one most likely to catch you after the volume is added. A named volume can be created root-owned while the container runs as an unprivileged remoteUser, so the build cannot write the store and fails with permission errors that look like Maven or Gradle bugs rather than a mount problem. The fix is to chown the store directory to the remoteUser — typically in a postCreateCommand before the warm step, or in the Dockerfile — so the non-root build can populate and reuse the cache. This is the same ownership consideration that affects every cache volume under a non-root user, and anticipating it avoids a confusing "the cache is mounted but the build still can't write" state.

JVM cache triageA triage path from re-downloading dependencies to a warm, wrapper-pinned build.Do deps re-download each build?YESMount the store on a volumeSame tool version everywhere?YESUse the wrapperFast JVM builds

SymptomRoot CauseRemediation
Dependencies re-download each buildStore not on a volumeMount ~/.m2 or ~/.gradle on a volume
Tool version variesNot using the wrapperRun via mvnw/gradlew
Cache permission errorsVolume owned by rootchown the store to the remote user
Offline build failsCache not warmedWarm it in postCreateCommand

Conclusion

Cache the dependency store on a named volume — ~/.m2 for Maven or ~/.gradle for Gradle — and run through the wrapper so the build-tool version is pinned. A rebuild then reuses downloaded artifacts, and JVM dependency trees stop slowing every build.

The caching pairs naturally with the JVM's other speed lever, the build daemon. The volume avoids re-downloading dependencies, while keeping the Gradle daemon warm avoids re-starting the JVM on every task — two distinct costs addressed by two distinct mechanisms. Together they turn a JVM build in a container from something that feels sluggish into something that keeps pace with interactive development. The daemon's warmth is a within-session benefit, while the volume's persistence spans rebuilds, so the two complement rather than overlap. A build that both caches its dependency store and keeps its daemon warm avoids the two costs — re-downloading and re-starting — that make an uncached, cold-daemon JVM build feel slow, and it does so with two independent, low-effort changes.

Because the caching is safe whenever your build files pin dependency versions and you build through the wrapper, there is no trade-off to weigh: you get faster builds with identical, reproducible results. This is the same pin-and-cache pattern the whole language section applies — pin the versions so the build is deterministic, cache the expensive downloads so it is fast — and for the JVM, where dependency trees are large and downloads dominate cold builds, the payoff is especially pronounced.

Cache and pinCache the store and pin the tool via the wrapper for fast, reproducible builds.Cache~/.m2 or ~/.gradleOn a named volumeWarmed on createPinJDK versionWrapper tool versionCommitted build files

FAQ

Which directory should I cache, Maven or Gradle? Cache the one your build uses: ~/.m2/repository for Maven, or ~/.gradle for Gradle. Mount it on a named volume so downloaded artifacts persist across rebuilds. If you use both tools, cache both directories. For Maven, ~/.m2/repository is the artifact store specifically; some setups cache the whole ~/.m2 to also capture settings.xml, though that file is often better managed separately. For Gradle, ~/.gradle captures both the dependency cache and the wrapper distributions, so it is the natural single target. Whichever you cache, the goal is the same: the directory the build tool downloads into should survive rebuilds.

Does caching change build reproducibility? No, as long as you build through the wrapper (mvnw/gradlew), which pins the tool version, and your build files pin dependency versions. The cache only holds artifacts your build already resolved, so it speeds builds without altering results. The one thing that could undermine this is dynamic version ranges — 1.+ in Gradle or open version ranges in Maven — which let the resolved version drift and therefore let the cache and a fresh resolution disagree. Pinning to explicit versions closes that gap and makes the cache as reproducibly safe as any other language's lockfile-backed cache in this section. With explicit versions and the wrapper, the cache is pure speed.

How much time does caching actually save? For a project with a large dependency tree, the difference between a cold and warm build is often the difference between minutes and seconds — the entire download phase collapses to near-zero because every artifact is already local. The exact saving scales with the size of your dependency tree and the speed of your connection to the repository, but for typical enterprise Java projects it is one of the largest single speedups available in a devcontainer, which is why it is worth wiring up early rather than tolerating slow rebuilds and adding it later. The cost of wiring it up — one mount and one warm command — is trivial next to the considerable aggregate time it returns on every rebuild for the entire life of the project.

Why do I get permission errors on the cache volume? The volume is owned by root but your remoteUser isn't. chown the cache directory to the remote user (in a hook or the Dockerfile), and the non-root build can read and write it. This is the most common follow-on problem after adding a cache volume, and it is worth handling proactively: put the chown in postCreateCommand before the warm step so the store is writable the first time the build touches it, rather than discovering the failure on the first cached build.

Does the wrapper distribution get cached too? For Gradle, yes — the wrapper downloads its pinned Gradle version under ~/.gradle, so caching that directory covers the distribution as well as the dependencies, and the wrapper does not re-download Gradle on each rebuild. For Maven, the wrapper's Maven distribution similarly lands under a cached location. This means one volume per tool handles both the downloaded dependencies and the wrapper-managed build tool, which is exactly what you want: the entire reproducible, downloadable footprint of the build persists across rebuilds.

Can I share the dependency cache across projects? Yes, and it is often efficient because Maven and Gradle stores are keyed by artifact coordinates. Two projects depending on the same version of the same library share that artifact within one store, so a shared volume across projects with overlapping dependencies means the second project finds much already downloaded. The store grows to hold the union of all projects' dependencies, which is usually a fine trade for the reuse; if you prefer each project's cache cleanly scoped, a per-project volume keeps them separate. For a team standardizing on a common dependency set, sharing maximizes the benefit.