Configuring the JDK Version in a DevContainer

A JVM project must compile against a known JDK, and the editor must analyze against the same one. This page pins the JDK via the Java Feature and points the Java language server at it, so the build and the editor never disagree about the Java version.

The problem this solves is a split brain between two Java toolchains that both live inside the same container. The Gradle or Maven build resolves its JDK from whatever java is on the PATH or from the toolchain the wrapper selects, while the Java extension pack runs an independent language server — the Eclipse JDT.LS — that reads its own java.home. Nothing forces those two to agree, so a container can compile cleanly on the command line while the editor underlines valid code in red, or accept a record in the editor that the build then rejects. Pinning one JDK and routing the language server at it removes the second, unmanaged toolchain from the equation.

Reach for this whenever the editor and the build disagree about what Java is, whenever a project depends on a language level that a floating latest JDK might overshoot or undershoot, or whenever a rebuilt container silently upgrades the JDK because the Feature was left unversioned. The mental model is a single source of truth: the Java Feature installs one known JDK at a known path, and every other consumer — the JDT language server, the java.configuration.runtimes list, the terminal java — is pointed back at that path rather than allowed to discover a JDK on its own.

Prerequisites

You need a Java devcontainer and the target JDK version.

  • The Java Feature available.
  • The JDK version your project targets.
  • The Java extension pack in the container.

JDK prerequisitesYou need the pinned Feature, the LS java.home, runtime config, and a check.Java Featureversion pinnedjava.homeLS points at itRuntimesconfiguredVerifyjava -version

The Java Feature (ghcr.io/devcontainers/features/java) is what actually places a JDK on disk during the build; without it you are relying on whatever the base image shipped, which is exactly the floating version this page is trying to eliminate. The Java extension pack matters because it is what starts the JDT language server inside the container — the server that owns the java.jdt.ls.java.home setting. Installing the pack on the host instead of via customizations.vscode.extensions is the usual miss: the language server then runs against the host's Java, not the container's, and no amount of java.home tuning inside the container will reach it.

The one detail people get wrong is the path. java.jdt.ls.java.home wants a filesystem path to a JDK home, not a version string and not a JAVA_HOME-style alias that may not be resolved yet. On the devcontainers Java Feature that path is typically the SDKMAN candidate directory shown below (/usr/local/sdkman/candidates/java/current); confirm it with readlink -f $(which java) inside the running container before you hard-code it, because a wrong path leaves the server falling back to a bundled JRE that cannot analyze your source level.

Step-by-Step Implementation

  1. Pin the JDK via the Feature.
{
  "features": { "ghcr.io/devcontainers/features/java:1": { "version": "21" } },
  "remoteUser": "vscode"
}

Setting "version": "21" on the Feature is the load-bearing line: it tells the installer to fetch exactly JDK 21 on every rebuild rather than resolving a moving tag. Without that key the Feature installs its own default, which drifts as the Feature is updated, so a colleague who rebuilds next month can end up on a different major version than the one your pom.xml or build.gradle targets. The remoteUser of vscode keeps the installed JDK owned and readable by the account the editor and terminal actually run as, so the language server and the wrapper both reach the same files without permission surprises.

  1. Point the language server at that JDK.
{ "customizations": { "vscode": { "settings": {
  "java.jdt.ls.java.home": "/usr/local/sdkman/candidates/java/current"
} } } }

java.jdt.ls.java.home is the JDK the language server itself runs on and uses to parse and index your project; pointing it at /usr/local/sdkman/candidates/java/current — the same tree the Feature populated — is what makes the editor's diagnostics reflect the pinned Java rather than a bundled fallback JRE. Because the setting lives under customizations.vscode.settings, it is baked into the container definition and travels with the repository, so every clone gets the same routing instead of each developer configuring java.home by hand. The failure this prevents is the classic one where the build accepts a sealed class or a switch pattern that the editor, still on an older embedded runtime, flags as a syntax error.

  1. Configure runtimes if you target multiple JDKs.
{ "customizations": { "vscode": { "settings": {
  "java.configuration.runtimes": [ { "name": "JavaSE-21", "path": "/usr/local/sdkman/candidates/java/current", "default": true } ]
} } } }

java.configuration.runtimes is a different axis from java.jdt.ls.java.home: the latter is the JDK the server runs on, while the runtimes list is the set of JDKs the server is allowed to compile against, keyed by execution-environment name such as JavaSE-21. Marking one entry "default": true sets the level used for projects that do not otherwise declare one, and the name must match a real execution environment or the server ignores the entry. You need this list the moment a workspace holds modules on different Java levels — one on 17, one on 21 — so each is analyzed at its own source level instead of all being forced to the default.

  1. Verify the versions match.
java -version   # matches the pinned Feature and the LS java.home

java -version is the cheap confirmation that closes the loop: it prints the JDK the terminal and, by extension, the wrapper-less build commands resolve, and its major version must read back as the same 21 you pinned on the Feature and pointed the server at. If this reports a different number, the Feature version key and the language-server path have diverged, and you fix that before trusting any editor diagnostic. Running it after a fresh rebuild — not just after the first build — is what catches a Feature that quietly reinstalled a newer default.

JDK routingA pinned JDK plus a matching language-server java.home keeps the editor and build aligned.Feature-pinned JDKthe build's JavaLS java.homethe editor's JavaRuntimes configmulti-JDK supportResulteditor == build JDK

Common Pitfalls

JDK mismatches come from an unpinned Feature or a wrong java.home.

When a JDK is installed through SDKMAN under /usr/local/sdkman, ownership is the quiet failure mode. If the Feature lays the candidate directory down as root but the container runs as vscode, the language server can still read the JDK but the build tools may be unable to write their caches next to it, and a java.home that points into a root-owned tree can leave the server unable to index the standard library. Aligning remoteUser with the account that owns the SDKMAN candidates — or chown-ing the candidate path in a post-create step — keeps both the editor and the build reaching the same readable JDK.

The subtler pitfall is confusing the three Java settings that all look interchangeable. java.jdt.ls.java.home is the runtime the server runs on; java.configuration.runtimes is the set it compiles against; and the terminal java is what unwrapped build commands pick up. Setting only one of the three and assuming the others follow is the most common reason an editor keeps flagging valid syntax after a version bump — the server was still executing on the old JDK even though the runtimes list named the new one. Treat the three as one unit and update them together whenever you move the pinned version.

JDK triageA triage path from a JDK mismatch to aligned editor and build.Is the JDK pinned on the Feature?NOSet the Feature versionDoes the LS use the same JDK?YESRuntimes configuredEditor and build agree

SymptomRoot CauseRemediation
Editor errors differ from buildLS on a different JDKSet java.jdt.ls.java.home to the JDK
Wrong Java version at buildFeature version unpinnedPin the Java Feature version
Multi-module targets differNo runtimes configConfigure java.configuration.runtimes
Preview features failJDK too oldPin a newer JDK version

Conclusion

Pin the JDK on the Java Feature so the build compiles against a known version, and point the language server's java.home at the same JDK so the editor analyzes it too. When both use one Java, the editor's verdict matches the build's.

The strategic payoff is that the JDK stops being an ambient property of whoever's machine built the container and becomes a committed, reviewable fact in devcontainer.json. A version bump from 21 to 25 is then a one-line diff that changes the Feature version and the runtimes entry together, reviewable in a pull request and identical for every contributor on their next rebuild, rather than an invisible drift that surfaces as mysterious editor errors weeks later.

This is the same pin-and-cache discipline that governs the rest of a reproducible JVM container: the Feature pins the JDK exactly as a lockfile pins dependencies, and the language-server routing plays the role of caching by ensuring the expensive, already-installed JDK is the one every tool reuses instead of each discovering its own. Pair this page with dependency caching for Maven and Gradle and the whole toolchain — compiler, editor, and build cache — resolves to one deterministic set of versions that a rebuild reproduces exactly.

Pin and route JDKPin the JDK for the build and route the language server at the same JDK.Pin (build)Feature versionWrapper toolCommitted configRoute (editor)jdt.ls java.homeruntimes listDefault runtime

FAQ

How do I pin the JDK version? Set the version on the Java Feature (ghcr.io/devcontainers/features/java with "version": "21"). Every rebuild then installs that exact JDK, so the build compiles against a known Java version rather than whatever happens to be installed. Because the version lives in the committed devcontainer.json, a rebuild months later still lands on JDK 21, and bumping to a newer major is a single reviewed edit rather than a surprise. Avoid the bare java tag or an unversioned Feature reference, since both resolve to a moving default that changes underneath you.

Why does the editor use a different Java than my build? The Java language server has its own java.home setting. If it isn't pointed at the container's JDK, it analyzes against a different Java. Set java.jdt.ls.java.home (and java.configuration.runtimes) to the Feature-installed JDK so both agree. The reason the drift happens at all is that the JDT language server is a separate process with its own configuration and does not inherit the terminal's PATH; it reads java.jdt.ls.java.home and nothing else to decide which JDK to run on. Point that setting at the Feature-installed path and the editor's red underlines start matching the compiler's actual errors.

Can I target multiple JDKs in one container? Yes — install the JDKs you need and list them in java.configuration.runtimes, marking one default. Modules can then target different Java versions, and the language server resolves each against the correct runtime. Keep the execution-environment names accurate — JavaSE-17, JavaSE-21 — because the server matches a module's declared level against those names, and a mismatch sends it back to the default. One JDK should still carry "default": true so modules that declare no level have a defined target.

Where do I find the JDK path to put in java.home? Run readlink -f $(which java) inside the running container; it resolves the symlink chain to the real JDK home. On the devcontainers Java Feature that is usually /usr/local/sdkman/candidates/java/current, but confirm rather than assume, because a different base image or a manually added JDK can sit elsewhere and a wrong path silently drops the server onto a bundled JRE that cannot parse your source level.

Do I still need to set JAVA_HOME if I pin the Feature? The Feature generally exports JAVA_HOME and puts java on the PATH for the terminal and for wrapper-less build commands, so the shell side is covered. The language server, however, ignores JAVA_HOME and reads only java.jdt.ls.java.home, which is why this page sets both the environment and the editor setting rather than relying on one to imply the other.

Why does the build pass but the editor still shows errors? That is the exact split this page closes: the Gradle or Maven build compiled with the pinned JDK on the PATH, while the language server was still running on a stale or bundled runtime that predates your source level. Re-point java.jdt.ls.java.home at the pinned JDK, reload the window so the server restarts, and the diagnostics realign with what the build already accepts.

Does changing the Feature version take effect without a rebuild? No — the Feature runs only during the container build, so editing "version" in devcontainer.json changes nothing until you rebuild the container. Reopening the folder or reloading the window reuses the existing image and its old JDK, which is why a version bump that seems ignored is almost always a skipped rebuild rather than a wrong path. Trigger a full rebuild, then re-run java -version to confirm the new major landed.

Should I pin the same JDK in the build tool as well as the Feature? It is worth it for defence in depth. The Feature controls which java the container ships, but a Gradle toolchain block or a Maven maven.compiler.release still declares the source level the build enforces, and keeping that number in step with the pinned Feature version means the build fails loudly if the two ever drift apart instead of compiling against an unexpected level. Treat the Feature version as the installed JDK and the build tool's release setting as the contract it must satisfy.