Debugging Java in a DevContainer

You need to set breakpoints in a Java service that runs inside a devcontainer. This page attaches the VS Code Java debugger to the in-container JVM — launching directly, or attaching over JDWP for a running service — so debugging works against the real container runtime.

This matters because a Java debugger is only useful when it and the JVM agree on exactly one thing: the class files being executed. The moment a debugger runs on your host machine but the process it is inspecting lives inside a container, that agreement breaks. Line numbers drift, breakpoints show as hollow "unbound" markers, and stepping either does nothing or lands in the wrong method. The container has its own JDK, its own classpath, its own target/classes or build/classes directory, and its own view of the filesystem. Debugging Java in a devcontainer is fundamentally about collapsing that gap so the debugger, the compiler that produced the bytecode, and the running JVM are all on the same side of the container boundary.

Reach for this whenever the code you want to step through does not run on your laptop's JDK: a Spring Boot service started by docker compose, a Gradle or Maven application run from a terminal inside the container, or a background worker that a postStartCommand launches. The mental model has two shapes. In launch mode the debugger starts the JVM for you, so it controls the process from the first instruction and no extra flags are required. In attach mode the JVM is already running — often as a long-lived service you do not want to restart — and you connect to it after the fact through the Java Debug Wire Protocol (JDWP). Knowing which of these you need is the single decision that determines the rest of the setup.

Prerequisites

You need a Java devcontainer with the debugger extension.

  • A Java devcontainer with a pinned JDK.
  • The Java extension pack (includes the debugger) in the container.
  • A JDWP port if attaching to a running service.

The detail people get wrong is where the Java extension pack is installed. VS Code will happily show you a fully working vscjava.vscode-java-pack in its Extensions view while that pack is actually installed on the host, not in the container. In a devcontainer the language tooling has to live on the "server" side — inside the container — because that is where the JDK, the project's compiled classes, and the JVM process all are. The devcontainer.json shown below pins the pack under customizations.vscode.extensions so a rebuild always provisions it in the container. It is also worth confirming that the pinned JDK is the same major version the code was compiled against; a container running a JDK 21 runtime while the debugger resolves sources against JDK 17 class files is a subtle source of mismatched line tables.

Java debug prerequisitesYou need the debugger extension, a launch/attach config, and JDWP for services.Java packdebugger in containerLaunch/attachconfigJDWPfor servicesBreakpointsbind

Step-by-Step Implementation

  1. Ensure the Java pack is installed in the container.
{ "customizations": { "vscode": { "extensions": ["vscjava.vscode-java-pack"] } }, "remoteUser": "vscode" }

The customizations.vscode.extensions array is what forces the Java pack to be installed on the container's server side rather than merely on the host. When VS Code reopens the folder in the container, it reads this list and provisions each extension inside the container image, so the debugger, the language server, and the JDK all share one filesystem. The remoteUser field matters here too: the debugger process, the compiled class files, and any workspace scratch directories are all owned by that user, and running the JVM as one user while the editor connects as another is a frequent cause of "permission denied" when the debugger tries to read a class or write a temporary file. Pinning both settings in devcontainer.json means a Rebuild Container reliably reproduces a working debug setup instead of leaving it to whatever happens to be installed locally.

  1. Launch the app under the debugger (simple case) — the extension provides a launch config.

For a plain application with a main method, launch mode is the least error-prone path. The Java extension detects the entry point, generates a launch.json config of "request": "launch", and starts the JVM itself with debugging enabled from the first instruction. Because the debugger owns the process, you never touch JDWP flags, port forwarding, or timing: there is no window in which the JVM is running but unreachable. It stops being enough the moment the process you care about is started by something other than the debugger — a compose service, a shell script, or an application server — which is exactly where attach mode and JDWP come in.

  1. Or start a service with JDWP and attach.
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar

Every part of this -agentlib:jdwp string carries weight. transport=dt_socket selects TCP sockets, which is what a network-attached debugger needs; the shared-memory transport dt_shmem cannot cross the container boundary. server=y makes the JVM listen for the debugger instead of dialling out to it, which is the correct polarity when the editor initiates the attach. suspend=n lets the application start and serve traffic immediately rather than freezing at startup until a debugger connects — switch it to suspend=y only when you need to catch something that happens during initialization, before you could realistically attach. The most important token for containers is address=*:5005: the leading * binds the agent to all interfaces rather than just loopback. A JVM that binds JDWP to 127.0.0.1 inside a container is unreachable from the host or from another compose service, which produces the classic "connection refused" even though the agent is clearly running. Binding to * and then controlling exposure through port forwarding is the reliable pattern.

  1. Attach the editor to the JDWP port and set breakpoints.
{ "type": "java", "request": "attach", "hostName": "localhost", "port": 5005 }

This attach configuration is deliberately minimal: "request": "attach" tells the Java debugger to connect to an existing JVM rather than start a new one, and hostName/port name where the JDWP agent is listening. The value of hostName depends on where the editor is running. When VS Code is attached to the devcontainer and the JVM listens inside that same container, localhost is correct because both endpoints share the container's loopback. When the JVM is a separate compose service, hostName should be that service's name on the compose network instead, and no host forwarding is involved at all. Getting port to match the address value from the JDWP flags is what actually binds breakpoints — a mismatch here surfaces as an attach that succeeds against nothing, or an immediate refusal. Once attached, the debugger downloads the class metadata the running JVM already loaded, which is why breakpoints in code that has genuinely been compiled and deployed bind, while breakpoints in stale or never-built sources do not.

Java debug modelThe debugger runs in the container and attaches to the in-container JVM, directly or via JDWP.Debugger in containerruns on the serverLaunch configdirect debugJDWP attachfor running servicesBreakpointsbind to real JVM

Common Pitfalls

Java debug issues are a host-run debugger or a missing JDWP port.

A less obvious pitfall is ownership of the build output and the debug artifacts when a dependency cache or a mounted volume is in play. If Maven's ~/.m2 or Gradle's ~/.gradle is a named volume shared across rebuilds, and the container's remoteUser differs from the user that first populated that volume, the JVM may fail to read cached classes or the debugger may be unable to write its temporary agent files. The symptom looks like a debugging problem — breakpoints that never bind, or an attach that hangs — but the root cause is a permission mismatch between the volume's on-disk ownership and the process trying to use it. Aligning the remoteUser in devcontainer.json with the UID that owns the cache volume, or fixing ownership once with a chown in a postCreateCommand, removes a whole class of intermittent debug failures.

The other pitfall worth expanding is stale source mapping, listed in the table as "Build output stale." Java breakpoints bind by matching the source file and line number against the line-number table baked into the loaded .class files. If you edit a source file but the JVM is still running bytecode compiled before that edit, the debugger will place the breakpoint at the wrong line or refuse to bind it — the source and the running class have diverged. This is especially common with attach mode, where the service was started once and keeps serving old bytecode while you keep editing. The fix is discipline about rebuilding: recompile so target/classes (Maven) or build/classes (Gradle) reflects the current sources, and restart or hot-reload the service before expecting new breakpoints to land.

Java debug triageA triage path from a non-binding debugger to a working in-container attach.Is the debugger in the container?NOInstall the Java pack in the containerDebugging a running service?YESStart it with JDWP and attachBreakpoints bind

SymptomRoot CauseRemediation
Breakpoints don't bindDebugger not in the containerInstall the Java pack in the container
Can't attach to a serviceNo JDWP agentStart the JVM with the JDWP agent
Attach refusedJDWP port not forwardedForward the JDWP port to the host
Wrong source mappingBuild output staleRebuild so class files match sources

Conclusion

Debug Java where it runs: install the Java debugger into the container and either launch under it or attach over JDWP to a running service. Breakpoints then bind against the real in-container JVM, so what you debug is what actually executes.

The strategic payoff is that debugging becomes a property of the project rather than of the individual machine. Once the Java pack is pinned in customizations.vscode.extensions and the JDWP conventions — dt_socket, address=*:5005, an attach config on port 5005 — are captured in checked-in configuration, any teammate who rebuilds the container gets an identical, working debug setup with no local installation and no "works on my laptop" divergence. That is the same reproducibility discipline that pins the JDK version and caches the dependency directories: the debugger is just one more piece of the environment that you refuse to leave to chance. When the JDK the code compiles against, the runtime the JVM executes, and the tooling the debugger uses are all pinned together, the line-number tables always agree and breakpoints stop being flaky.

Framed against the broader pin-and-cache theme, in-container debugging closes the loop between building and running. You already pin the JDK so builds are deterministic and cache ~/.m2 or ~/.gradle so they are fast; putting the debugger inside the same container means the artifact you reproduced is also the artifact you can step through, against the exact classpath it ships with. Keeping the whole debug session on the container's side of the line is what makes what you observe faithful to what will actually run.

Launch vs attachLaunch directly for simple runs; attach over JDWP for running services.Launch modeDirect debugFast for a main()No extra flagsAttach modeJDWP agentRunning servicesForwarded port

FAQ

Why won't my Java breakpoints bind? Usually because the debugger is running on the host rather than in the container. Install the Java extension pack into the container (via customizations.vscode.extensions) so the debugger runs on the server against the in-container JVM, and breakpoints bind to the real runtime. The second most common cause is stale bytecode: the debugger matches breakpoints against the line-number tables in the loaded .class files, so if you edited a source but never recompiled, the running JVM is still executing older bytecode and the breakpoint has nowhere valid to land. Rebuild so target/classes or build/classes reflects your current sources, then confirm the JDK major version in the container matches the one the code was compiled against.

How do I debug a long-running service in the container? Start the JVM with the JDWP agent (-agentlib:jdwp=...,address=*:5005), forward that port, and use an attach launch configuration pointing at it. The editor then attaches to the live service and you can set breakpoints without restarting it under the debugger. Keep suspend=n so the service comes up and serves traffic normally rather than freezing at startup waiting for a debugger; reserve suspend=y for the narrow case where you must break inside initialization before the first request. Because the service keeps running as you attach and detach, this is the pattern for reproducing a bug in something like a Spring Boot app under docker compose without disturbing its lifecycle.

Do I need to forward the JDWP port? If you attach from the host editor to a container JVM, yes — forward the JDWP port (e.g. 5005) so the debugger can reach it. When the whole debug session runs in-container via the server, the port stays internal and no forwarding is needed. The deciding factor is which side of the container boundary the debugger lives on: an editor attached to the devcontainer reaches the JVM over the container's own loopback, whereas a host-side editor has to cross the boundary and therefore needs the port published. This is also why binding the agent to address=*:5005 rather than 127.0.0.1 matters — a loopback-only bind cannot be forwarded to anything outside the container.

Launch or attach — which should I use? Use launch mode when the code you want to debug has a main method you are happy for the debugger to start, such as a CLI tool, a batch job, or a focused reproduction; the extension handles the JVM and no JDWP flags are involved. Use attach mode when the process is started by something else — a compose service, a shell script, or an application server — and you need to connect to it as it already runs. The rule of thumb is that if you cannot let the debugger own the process lifecycle, you attach.

Why does the debugger connect but immediately disconnect? The usual causes are a port mismatch between the JDWP address and the attach config's port, or a JVM that bound JDWP to loopback so the connection lands on nothing. Double-check that both sides name 5005 (or whatever you chose), that the agent used address=*:5005, and that the port is actually forwarded if the editor is on the host. A compose network that does not route between services can produce the same symptom, so verify reachability before assuming the debugger is at fault.