Configuring Delve Debugger in a Go DevContainer

Delve is Go's debugger, and running it inside the devcontainer lets you set breakpoints against the real container runtime. This page configures Delve for both launching a program and attaching to a running Go service, with the source mapping right.

The reason this matters is that a Go binary is only debuggable against the exact toolchain, build tags, and filesystem layout it was compiled with. If Delve lives on your host and the binary was built inside the container, the two disagree about where source files live, which Go version emitted the DWARF symbols, and which GOOS/GOARCH the program targets. Those disagreements surface as breakpoints that never bind, variables that read as garbage, or a debugger that silently steps past the code you care about. Putting dlv inside the same container that runs go build collapses that gap: the debugger and the binary share one root, one toolchain, and one view of the module cache, so a breakpoint on line 42 of handler.go maps to the instruction the CPU actually executes.

Reach for this setup in two distinct situations. The first is ordinary iterative debugging, where you want to launch a main package under the debugger, step through it, and inspect state — that is the launch mode the Go extension drives for you. The second is when a service is already running inside the container, perhaps started by docker compose up or a background task, and you need to attach without restarting it; that is the headless mode where dlv opens a listener and the editor connects over a port. The mental model to hold onto is that Delve is always a container-resident process debugging a container-resident binary — the editor on your host is only a thin remote client that renders stack frames and forwards step commands.

Prerequisites

You need a Go devcontainer with the Go extension and Delve.

  • A Go Feature pinned in the container.
  • The Go extension (installs Delve) in the container.
  • A debug build so symbols are present.

The Go Feature (or a base image that bundles the toolchain) matters because Delve is version-sensitive: it reads DWARF and Go's runtime type information, and a dlv built against Go 1.22 can refuse to attach cleanly to a binary produced by a noticeably older or newer compiler. Pinning the Go Feature in devcontainer.json keeps the toolchain, the dlv the extension installs, and the compiled binary all moving together, so a rebuilt container never leaves you with a debugger that no longer understands its own program. Let the Go extension install Delve rather than pulling a separate copy onto the host — the copy that ships with the extension is placed on the container PATH and is the one every launch and attach config will call.

The single detail people most often get wrong is assuming the debug build is automatic. It usually is when you press the extension's debug button, but the moment you point Delve at a binary you produced yourself — through a Dockerfile stage, a Makefile target, or a compose service — you have to check that build did not strip symbols. A production-style build with -ldflags "-s -w" removes exactly the DWARF tables Delve needs, and the failure is silent: the session starts, the process runs, and no breakpoint ever fires. Treat "was this binary built for debugging?" as the first question, not the last.

Delve prerequisitesYou need the Go extension, a debug config, headless Delve for services, and binding breakpoints.Go extensioninstalls dlvLaunch/attachconfigHeadless dlvfor servicesBreakpointsbind

Step-by-Step Implementation

  1. Ensure the Go extension (and Delve) are in the container.
{ "customizations": { "vscode": { "extensions": ["golang.go"] } }, "remoteUser": "vscode" }

Listing golang.go under customizations.vscode.extensions forces the extension to install into the container rather than run from your host, and that is the whole point: the extension's post-install step drops dlv onto the container PATH next to the container's go binary. Setting remoteUser to vscode keeps the debugger, the compiled binary, and the module cache all owned by the same non-root user, which avoids a class of permission failures where dlv cannot read a cache directory or write its temporary build artifacts. Rebuild the container after adding this so the extension actually provisions Delve; a config change alone does not retroactively install anything into an already-running container.

  1. Launch a program under the debugger (the extension provides a launch config).

Launch mode is the simplest path and covers most day-to-day debugging. When you start a debug session with mode: "debug" on a main package, the Go extension shells out to dlv debug for you: it compiles the package with debug info, starts the resulting binary under Delve's control, and wires the editor to that Delve instance over the extension's own private channel. Because Delve is compiling the binary in that moment, you never have to reason about whether symbols were stripped — the extension always passes debug-friendly flags. The tradeoff is that launch mode owns the process lifecycle, so it is the wrong tool when the process you care about is already up and you cannot afford to restart it. That is precisely the case the next step handles.

  1. Or run a service headless with Delve and attach.
dlv debug --headless --listen=:2345 --api-version=2 ./cmd/app

Each flag here earns its place. --headless tells Delve to run as a server with no interactive terminal UI, which is exactly what you want when an editor rather than a human will drive it. --listen=:2345 binds the debug protocol to a TCP port so a remote client can connect; the leading colon binds all interfaces inside the container, which is what lets the port be forwarded out. --api-version=2 selects the newer RPC protocol that the VS Code Go extension speaks — omit it and older Delve builds default to version 1, producing a client that connects but cannot exchange breakpoints. The trailing ./cmd/app is the package to compile and run, so Delve builds a fresh debug binary from that entry point. Use dlv attach <pid> instead if the process is already running and you must not rebuild it; use this dlv debug form when starting the service under the debugger is acceptable.

  1. Attach the editor and set breakpoints.
{ "name": "attach", "type": "go", "request": "attach", "mode": "remote", "port": 2345 }

This launch configuration is the client half of the headless server from the previous step. request: "attach" with mode: "remote" tells the Go extension not to compile or start anything itself, but to connect to a Delve that is already listening — the port: 2345 must match the --listen value you gave dlv. Because both Delve and the binary live in the container, there is no remotePath/localPath juggling to do: the source paths the server reports are the same paths the editor opens, so breakpoints bind without a source-map translation layer. The failure this configuration prevents is the subtle one where a host-side Delve reports /root/app/handler.go while your editor has /workspaces/app/handler.go open, and every breakpoint lands as "unverified". Keeping the whole chain in the container makes that mismatch impossible by construction.

Delve modelDelve runs in the container, debugging the container binary via launch or headless attach.Delve in containerdebugs container binaryLaunch modedirect debugHeadless attachfor running servicesDebug buildbreakpoints bind

Common Pitfalls

Delve issues are an optimized build or missing headless server for services.

The permission angle bites hardest when a module cache volume is involved. If you mount a named volume for GOPATH/pkg/mod and it was first populated by a root process, dlv running as vscode may be unable to read the cached sources it wants to resolve when stepping into a dependency, and you will see it stall or refuse to show library frames. The fix is the same discipline used everywhere else in this Go setup: make the container user consistent and let that user own the cache. When Delve, the compiler, and the cache all run as vscode, the debugger can freely read the same module sources the build wrote, and stepping into a third-party function shows real code rather than "source not available".

The most common topic-specific trap after symbol stripping is a stale binary. Delve maps line numbers using the DWARF tables baked into the binary at compile time, so if you edit handler.go but attach to a service that is still running last build's binary, every breakpoint lands on the wrong line — or in code that no longer exists. This is not a Delve bug; the debugger is faithfully mapping the binary it was given. The habit that prevents it is to treat a rebuild as mandatory before every attach: rebuild the service, restart it under dlv debug, and only then connect the editor, so the running instructions and the source on screen are guaranteed to be the same generation.

Delve triageA triage path from non-binding breakpoints to a working Delve attach.Do breakpoints bind?NOBuild with debug info (no -ldflags -s-w)Debugging a running service?YESRun dlv headless and attachGo debugging works

SymptomRoot CauseRemediation
Breakpoints don't bindOptimized build, symbols strippedBuild without -s -w; keep debug info
Can't attach to a serviceNo headless Delve serverRun dlv --headless --listen
Attach refusedPort not forwardedForward the Delve port
Wrong line mappingStale build vs sourceRebuild so the binary matches sources

Conclusion

Debug Go where it runs: put Delve in the container via the Go extension, and either launch under it or run it headless and attach to a service. Build with debug info so breakpoints bind, and you're debugging the exact binary the container executes.

The strategic payoff is that debugging stops being a source of "works on my machine" ambiguity. When the debugger, the toolchain, and the binary all originate from the same pinned container image, a breakpoint that binds for you binds for a teammate who opens the same devcontainer, and a session that reproduces a bug reproduces it identically on every rebuild. That is the same reproducibility contract the rest of a well-built Go devcontainer already provides through pinned Feature versions and cached modules — this page simply extends it to the debug session, which is often the last place people leave to chance.

Seen that way, the Delve configuration is not a separate concern bolted onto the environment but the natural endpoint of the pin-and-cache discipline. Pinning the Go Feature keeps the toolchain stable; caching the module volume keeps builds fast and dependency sources readable when you step into them; and running Delve in-container keeps the debug view honest. Get those three aligned and debugging becomes boring in the best sense: you set a breakpoint, it binds, execution stops on the right line, and the variables read true — every time, on every machine that opens the container.

Launch vs attachLaunch directly for programs; attach headless Delve for running services.Launch modeDirect debugSimple programsNo extra serverAttach modeHeadless dlvRunning servicesForwarded port

FAQ

Why won't my Go breakpoints bind? Usually the binary was built optimized with symbols stripped (-ldflags "-s -w"), so Delve has nothing to map breakpoints to. Build with debug information (the default debug build) so symbols are present, and ensure Delve runs in the container against that binary. If you built the binary yourself in a Dockerfile stage, check that stage did not add the stripping flags for a smaller image. A second common cause is a stale binary: if the running process predates your latest edit, breakpoints map against the old line numbers and appear unverified even though symbols are present.

How do I debug a running Go service in the container? Start it under Delve in headless mode (dlv debug --headless --listen=:2345 --api-version=2), forward the port, and use a remote attach configuration. The editor connects to the live service and you set breakpoints without restarting it under the debugger. If you cannot restart the process at all — say it is holding state you need to inspect — use dlv attach <pid> against the already-running binary instead of dlv debug. Either way the --api-version=2 flag matters, because the VS Code Go extension speaks the version 2 protocol and a version 1 server will connect but fail to exchange breakpoints.

Does Delve run on the host or in the container? In the container — the Go extension installs Delve into the container, so it debugs the container's binary against the container's Go toolchain. That keeps the debug session faithful to what actually runs, including any container-specific build tags or environment. It also sidesteps the source-mapping headache entirely: because Delve reports the same paths your editor has open, there is no remotePath/localPath translation to configure and no way for the two to drift out of sync.

What port should I forward for a headless Delve session? Whatever you passed to --listen; the examples here use 2345, which is the Delve convention, but any free port works as long as the port in your attach config matches. In a devcontainer the port is on the container network, so add it to forwardPorts (or let the editor auto-forward it) if you want to attach from a client outside the container. When both the editor client and the headless server sit inside the same container, no forwarding is needed at all.

Can I debug a test with Delve in the container? Yes — run dlv test ./pkg/... (optionally with --headless --listen) instead of dlv debug, and Delve compiles the test binary with debug info and stops at breakpoints in both the test and the code under test. This is the fastest way to isolate a failing case, since you get the debugger stopped exactly where the assertion fires rather than reading it back from a log. The same symbol and rebuild rules apply: the test binary must be freshly built and unstripped for breakpoints to bind.

Delve attaches but immediately says "could not launch process" — what now? On Linux this is almost always a ptrace restriction: the container needs the SYS_PTRACE capability (or a seccomp profile that permits ptrace) for Delve to control another process. Add "capAdd": ["SYS_PTRACE"] and, if needed, "securityOpt": ["seccomp=unconfined"] to the devcontainer configuration, then rebuild. Once Delve can call ptrace, both launch and attach modes work normally.