Java & JVM DevContainer Configuration

JVM projects pull large dependency trees and lean on a build daemon, so a reproducible Java devcontainer hinges on pinning the JDK, caching the Maven or Gradle store, and pointing the Java language server at the container's JDK. This guide sets that up for Maven and Gradle alike, so builds are fast and the editor analyzes against the same JDK the build uses. It sits under the language configurations overview.

The reproducibility contract for the JVM is the pinned JDK plus the build tool's wrapper (mvnw/gradlew), which pins the build tool version too. Cache the dependency store on a volume, route the language server, and a Java environment becomes as deterministic as any other in this section.

Prerequisites

You need a pinned JDK via the Java Feature, a build tool (Maven or Gradle, ideally via its wrapper), a named volume for the dependency cache, and the Java extension pack.

  • ghcr.io/devcontainers/features/java pinned to a JDK version.
  • The project's mvnw/gradlew wrapper committed.
  • A named volume for ~/.m2 (Maven) or the Gradle cache.
  • The Java extension pack in the container.

JVM prerequisitesYou need a pinned JDK, a build tool, a dependency-cache volume, and the Java extensions.Java Featurepinned JDKBuild toolMaven or GradleDep cache~/.m2 or gradle volumeExtensionsJava pack

The JVM's reproducibility story is worth pinning down precisely, because it differs from the lockfile-driven languages elsewhere in this section. Java has no single universal lockfile the way Rust has Cargo.lock or Go has go.sum; instead, reproducibility comes from two pins working together. The first is the JDK version, fixed by the Feature, so every rebuild compiles against the identical Java language level and standard library. The second is the build-tool version, fixed by the project's wrapper (mvnw or gradlew), which downloads and runs an exact Maven or Gradle version rather than whatever happens to be installed. Together these ensure both the compiler and the build orchestrator are identical everywhere. Dependency versions themselves are pinned in the pom.xml or Gradle build files (ideally with explicit versions rather than ranges), so the full reproducibility contract is JDK + wrapper + explicit dependency versions.

The wrapper prerequisite is the one JVM-specific idea that most repays understanding, because it solves a problem unique to build-tool-driven ecosystems. Without a wrapper, the build uses whatever Maven or Gradle version the developer or CI happened to install, and those tools change behavior across versions — a Gradle upgrade can alter how tasks resolve or how dependencies are ordered. The wrapper checks a small script and a version descriptor into the repository, and running ./gradlew or ./mvnw downloads and uses exactly that version, so the build tool becomes part of the reproducible, committed environment rather than an ambient dependency. In a devcontainer this matters doubly: it means the container does not even need the build tool pre-installed, because the wrapper fetches the pinned version on first use, keeping the image lean and the version authoritative.

The dependency-cache prerequisite addresses where JVM rebuild cost concentrates, which is dependency resolution more than compilation. JVM projects routinely pull large trees of transitive dependencies, and fetching them from Maven Central or a corporate repository is the slow part of a cold build. The cache — ~/.m2/repository for Maven, ~/.gradle for Gradle — holds these downloaded artifacts, and mounting it on a named volume is what lets a rebuild reuse them rather than re-download. This is the JVM analogue of Rust's registry cache and Go's GOMODCACHE: the store of fetched dependencies, cached so the expensive network fetch happens once. Because the dependency versions are pinned in the build files, a warm cache is safe — it holds exactly the artifacts the build declares — so caching is pure speed with no reproducibility cost.

Architecture & Configuration Deep Dive

Four layers. The JDK is Feature-pinned so every rebuild compiles against the same Java version. The build tool — Maven or Gradle, run through its wrapper — resolves dependencies into a store. That dependency store (~/.m2/repository or the Gradle cache) is cached on a named volume so it isn't refetched. And the Java language server (jdt.ls) must use the container's JDK so its analysis matches the build.

JVM environment layersA pinned JDK, a build tool with a cached dependency store, and the Java language server.JDKFeature-pinned versionBuild toolMaven (.m2) or Gradle cacheDependency cacheon a named volumeLanguage serverjdt.ls routes to the JDK

The language-server JDK is the routing detail teams miss: if jdt.ls picks a different JDK than the build, code that compiles can show phantom errors, or vice versa. Set java.jdt.ls.java.home (and the runtime configuration) to the container's JDK so both agree — the same interpreter-routing principle applied to Python and Go elsewhere in this section.

The language-server routing detail deserves emphasis because Java's language server, jdt.ls, is unusually sensitive to which JDK it uses. jdt.ls compiles your code to provide diagnostics, completions, and refactorings, so it must run on and target the same JDK the build uses — otherwise it can flag code as erroneous that compiles fine (because it is checking against a different Java language level) or accept code the build rejects. The setting that controls this is java.jdt.ls.java.home (the JDK jdt.ls itself runs on) together with the runtime configuration (the JDKs it compiles against), and both should point at the container's pinned JDK. This is the same interpreter-routing discipline the Python and Go guides describe, and the failure mode is identical: an editor whose analysis disagrees with the compiler because the two are looking at different toolchains.

A subtlety specific to the JVM is that a project may declare a source/target compatibility level distinct from the JDK it builds with — for instance building with JDK 21 but targeting Java 17 language features. jdt.ls needs to understand both the JDK it runs on and the compatibility level the project targets, so that its diagnostics reflect the language level the build actually enforces. Configuring the runtime list so jdt.ls knows about the container's JDK, and letting it read the project's compatibility settings from the build files, keeps the editor's notion of "valid Java" aligned with the compiler's. Getting this right is what lets a developer trust that code the editor accepts will also compile, even when the build targets an older language level than the JDK provides.

The daemon dimension is a JVM-specific performance consideration that interacts with reproducibility. Gradle (and Maven with its daemon) keeps a warm JVM process alive between tasks to avoid paying JVM startup on every invocation, which dramatically speeds interactive work. But a warm daemon can also hold state between runs, so for clean, reproducible builds — especially in CI — you run with --no-daemon to guarantee a fresh process with no carried-over state. The right split is to let the daemon stay warm during interactive development for speed, and to use --no-daemon in CI and for any build where reproducibility must be airtight. This is why the example uses --no-daemon for the postCreateCommand dependency warm-up and CI, while everyday gradlew build during development can use the daemon.

Step-by-Step Implementation

Pin the JDK, warm the dependencies, cache the store on a volume, and verify a build and test run.

Setup flowPin the JDK, warm dependencies, cache the store on a volume, then verify a build.Pin JDKJava FeatureWarm depsmvn/gradle downloadCache volume~/.m2 or gradleVerifybuild + test

{
  "name": "Java + Gradle",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
  "features": { "ghcr.io/devcontainers/features/java:1": { "version": "21", "installGradle": true } },
  "customizations": { "vscode": { "extensions": ["vscjava.vscode-java-pack"] } },
  "mounts": [
    "source=devcontainer-gradle-cache,target=/home/vscode/.gradle,type=volume"
  ],
  "postCreateCommand": "./gradlew --no-daemon dependencies",
  "remoteUser": "vscode"
}

Dependency caching for both tools is in caching Maven and Gradle dependencies in a devcontainer, JDK selection in configuring the JDK version in a devcontainer, and debugging in debugging Java in a devcontainer.

The postCreateCommand: ./gradlew --no-daemon dependencies is a deliberate warming step whose two choices are both intentional. Running the dependencies task resolves and downloads the project's full dependency tree into the Gradle cache without building the code, so the artifacts are present before the first real build — front-loading the network cost into container creation. Using --no-daemon here keeps the create step clean and avoids leaving a daemon holding state from a context where the code has not even been built yet. Paired with the ~/.gradle volume, this means the dependency download happens once on first create and is reused on every rebuild, since the cache persists on the volume. The equivalent for Maven would warm ~/.m2 with a dependency:go-offline invocation.

The remoteUser: vscode and the cache-volume ownership interact in the way common to every cached language environment, and it is worth anticipating. The Gradle (or Maven) cache volume must be writable by the vscode user; if it is created root-owned while the container runs as vscode, the build cannot populate it and fails with permission errors that masquerade as build-tool bugs. Ensuring the cache directory is owned by the remoteUser — via a postCreateCommand chown or appropriate mount setup — is what lets the cache work under the non-root discipline the security guides recommend. This is the same ownership consideration that affects the Rust target volume and Go's GOMODCACHE, and handling it alongside adding the volume avoids a confusing "the cache is mounted but the build still can't write" failure.

Performance & Resource Optimization

JVM build time is dominated by dependency resolution and compilation. Cache the dependency store (~/.m2 or ~/.gradle) on a named volume so a rebuild reuses downloaded artifacts, and reuse the Gradle daemon within a session to avoid JVM startup on every task.

Build time by cacheCaching the dependency store and reusing the build daemon cut JVM build time sharply.Cold Gradle build110sWarm dep cache30sWarm cache + daemon12sillustrative build time

Mount the dependency cache on a volume, and let Gradle's daemon stay warm during interactive work (use --no-daemon only in CI for clean, reproducible runs). The wrapper (gradlew/mvnw) pins the build-tool version so caching never changes behaviour — the same pin-and-cache pattern used across this section's language guides.

The daemon distinction is the JVM's most idiosyncratic performance lever, and understanding what it caches clarifies when to use it. The Gradle daemon keeps a warm JVM alive between task invocations, so it avoids the substantial JVM startup cost (class loading, JIT warm-up) on every build — which is why a warm-daemon rebuild can be several times faster than a cold one even with dependencies already cached. This is distinct from the dependency cache: the volume avoids re-downloading artifacts, while the daemon avoids re-starting the JVM. Both matter, and they address different parts of the build time. During interactive development, keeping the daemon warm is a large, free speedup; the only reason to disable it is when you need the guaranteed-clean-state that --no-daemon provides for reproducible CI builds.

Because the JVM's reproducibility rests on the wrapper and explicit dependency versions rather than a single lockfile, the caching is safe for the same reason it is elsewhere: the cache can only hold artifacts the build declares. A warm ~/.m2 or ~/.gradle contains exactly the dependency versions your pom.xml or Gradle build files specify, so reusing it never changes what the build resolves — provided you use explicit versions rather than dynamic ranges. The one caveat is that dynamic version ranges (1.+ in Gradle, version ranges in Maven) undermine this, because they let the resolved version drift; pinning to explicit versions is what makes the JVM cache as reproducibly safe as Rust's or Go's. With explicit versions, the wrapper, and the pinned JDK, the whole environment is deterministic and the caches are pure speed.

Validation & Testing

Confirm the Java language server uses the container JDK (its errors match the build) and that builds reuse the cache rather than re-downloading. A build plus test run against the pinned JDK is the definitive check.

JVM validationConfirm the Java language server uses the container JDK and builds reuse the cache.Does the Java LS use the container JDK?NOSet java.jdt.ls.java.homeDo builds reuse the dep cache?YESJDK version pinnedEditor and build agree

# The JDK the build uses; the LS should match this java.home
java -version
./gradlew --no-daemon build test   # reuses the cached dependencies

The definitive validation is a build plus test run against the pinned JDK, because it exercises the whole environment end to end. Confirming java -version reports the JDK you pinned, then running ./gradlew --no-daemon build test (or the Maven equivalent), proves that the toolchain, the wrapper-pinned build tool, and the cached dependencies all cooperate to produce a clean build and passing tests. Running this in CI — where --no-daemon guarantees a fresh JVM and the cache is populated from the volume or a fresh download — confirms that the same environment developers use produces a reproducible, verified build. Because it uses the wrapper, CI runs the identical build-tool version developers do, closing the loop on build-tool reproducibility.

The language-server-agreement check is the other invariant worth validating deliberately, because a JDK mismatch produces confusing phantom errors. Confirm that the JDK the build uses (java -version) matches what jdt.ls reports it is running on and compiling against, then verify the editor and the build agree on a file with a deliberate mistake — both should flag it, and both should accept correct code. If the editor shows errors the build does not (or vice versa), jdt.ls is on a different JDK or targeting a different language level, and the fix is to align java.jdt.ls.java.home and the runtime configuration with the container's JDK. Validating this agreement once saves repeatedly second-guessing editor diagnostics that do not reflect what will actually compile.

Common Pitfalls

The failures below are an uncached dependency store or a JDK mismatch. The triage below sorts them.

JVM pitfall triageA triage path from re-downloading dependencies or JDK mismatch to a deterministic JVM env.Do dependencies re-download each build?YESCache ~/.m2 or gradle on a volumeDoes the LS use a different JDK?YESSet the LS java.homeDeterministic JVM env

SymptomRoot CauseRemediation
Dependencies re-download each buildStore not on a volumeCache ~/.m2 or ~/.gradle on a volume
Editor shows errors the build doesn'tLanguage server on a different JDKSet java.jdt.ls.java.home to the container JDK
Build-tool version differs per machineNot using the wrapperCommit and use mvnw/gradlew
Slow task startupDaemon cold each runReuse the Gradle daemon during interactive work
Cache volume permission errorsVolume owned by rootchown the cache dir to the remote user

The JDK-mismatch pitfall is the one that most confuses JVM developers new to devcontainers, and it is worth expanding because the symptom is so misleading. When jdt.ls runs on or targets a different JDK than the build, the editor shows red squiggles under code that compiles perfectly, or fails to flag code the build rejects — so developers either chase phantom errors or trust an editor that is quietly wrong. The cause is almost always that java.jdt.ls.java.home or the runtime configuration points at a JDK other than the container's pinned one, perhaps a host JDK or a bundled one. The fix is to set both to the container's JDK, after which the editor and build agree. The tell is the editor and ./gradlew build disagreeing on the same file — an impossibility when both use the same JDK and language level, so its existence points straight at a routing mismatch.

The missing-wrapper pitfall is the quieter reproducibility hazard specific to the JVM. Without mvnw/gradlew, the build uses whatever Maven or Gradle version each developer or CI runner has installed, and because those tools change behavior across versions, two machines can build the same code differently — a task resolves differently, a plugin behaves differently, or the build simply fails on one version and not another. The symptom is "works on my machine" traced to a build-tool version, which is maddening to diagnose because the code and dependencies are identical. Committing and using the wrapper pins the build-tool version into the repository, so every machine runs the identical Maven or Gradle. Both pitfalls reflect this section's recurring lesson: an environment is reproducible only when every tool that shapes the build — the JDK, the build tool, and the language server — is pinned to the same version and pointed at the same inputs.

Conclusion

A reproducible JVM devcontainer is a pinned JDK, a wrapper-pinned build tool, a cached dependency store, and a language server pointed at the container's JDK. Cache ~/.m2 or the Gradle cache on a named volume, run through the wrapper so the build-tool version is fixed, and route jdt.ls at the same JDK the build uses — and Java's heavy dependency trees stop slowing every rebuild.

Pin and cache JVMPin the JDK and wrapper; cache the dependency store and daemon for fast rebuilds.PinJDK versionBuild-tool wrapperLS java.homeCache~/.m2 / gradleBuild daemonWrapper dists

Standing back, the JVM environment follows the same pin-and-cache logic as the rest of this section, adapted to a build-tool-driven ecosystem that lacks a single lockfile. Reproducibility comes from three pins — the JDK (Feature), the build tool (wrapper), and explicit dependency versions (build files) — rather than one lockfile, but the effect is the same: every developer and CI run compile identical code with identical tools. Speed comes from caching the dependency store on a volume and keeping the build daemon warm, and editor fidelity comes from routing jdt.ls at the container JDK. Hold those and Java's heavy dependency trees and JVM startup — the two things that make JVM builds feel slow — stop being a per-rebuild tax, while the environment stays byte-for-byte reproducible.

The robustness of the setup comes from each defining input being explicit rather than ambient. The JDK is not "whatever Java is on the machine" but a Feature-pinned version; the build tool is not "whatever Gradle is installed" but the wrapper's exact version; the dependencies are not "whatever resolves today" but explicit versions in the build files. Nothing that shapes the build is left to the ambient environment, which is exactly why the container reproduces the same build everywhere. This is the JVM expression of the section's theme — pin what must stay constant, cache what is expensive to reproduce — applied to a language where "what must stay constant" happens to include the build tool itself, not just the compiler and dependencies.

FAQ

How do I speed up Gradle/Maven builds in a devcontainer? Cache the dependency store on a named volume — ~/.gradle for Gradle or ~/.m2/repository for Maven — so rebuilds reuse downloaded artifacts instead of refetching them. Keep the Gradle daemon warm during interactive work to skip JVM startup, reserving --no-daemon for clean CI runs.

Why does the Java editor show errors my build doesn't? The Java language server (jdt.ls) is analyzing against a different JDK than the build uses. Set java.jdt.ls.java.home and the runtime configuration to the container's JDK so the language server and the build compile against the same Java version, and the phantom errors disappear. The reason this happens is that jdt.ls actually compiles your code to produce diagnostics, so it is only accurate if it uses the same JDK and language level as the build. A disagreement on the same file between the editor and ./gradlew build should be impossible when both use identical inputs, so its existence points straight at a JDK or language-level mismatch rather than a real code problem — aligning the two makes the editor a faithful preview of the compiler again.

Should I pin the build tool as well as the JDK? Yes — use the project's mvnw/gradlew wrapper, which pins the exact Maven or Gradle version. Combined with a Feature-pinned JDK, that makes both the compiler and the build tool reproducible, so a cached dependency store can never change how the project builds. This is the JVM-specific reproducibility requirement that languages with a single lockfile do not have: because Maven and Gradle change behavior across versions, pinning the build tool is as important as pinning the compiler. The wrapper also means the container does not need the build tool pre-installed — running ./gradlew fetches the pinned version on first use — keeping the image lean and the version authoritative.

Maven or Gradle — does the devcontainer setup differ? The principles are identical; only the paths and commands change. For Maven, cache ~/.m2/repository and warm it with mvn dependency:go-offline; for Gradle, cache ~/.gradle and warm it with ./gradlew dependencies. Both use a wrapper (mvnw or gradlew) to pin the build-tool version, both resolve dependencies from build files with explicit versions, and both need jdt.ls routed at the container JDK. Whichever your project uses, the four layers — pinned JDK, wrapper-pinned build tool, cached dependency store, routed language server — are the same; you just point the cache volume at the right directory and run the right warm-up command.

How do I handle a project that targets an older Java version than the JDK? Build with a recent JDK but set the source/target compatibility (or release) to the older language level in your build files, and make sure jdt.ls knows about both. The JDK provides the compiler and runtime, while the compatibility setting constrains which language features are allowed, so you can build with JDK 21 while targeting Java 17. Configure jdt.ls's runtime list so it understands the container's JDK, and let it read the project's compatibility level from the build files, so its diagnostics reflect the language level the build actually enforces rather than the full capabilities of the JDK it runs on.

Do I need to cache the Gradle daemon or wrapper distribution too? The wrapper distribution — the downloaded Gradle version — lives under ~/.gradle, so caching that directory on a volume covers it, meaning the wrapper does not re-download Gradle on every rebuild. The daemon itself is a live process, not a cached artifact, so you do not "cache" it so much as keep it warm within a session; it does not persist across container rebuilds. In practice, mounting ~/.gradle handles both the dependency cache and the wrapper distribution, and the daemon simply starts fresh after a rebuild and stays warm for subsequent tasks in that session. So a single named volume on ~/.gradle covers the two persistent costs — downloaded dependencies and the wrapper's Gradle distribution — while the daemon's warmth is a within-session benefit that costs nothing to leave enabled during interactive work. The same holds for Maven's ~/.m2, where one volume covers both the downloaded artifacts and any wrapper-managed Maven distribution.