How to Set Up a Go Development Container with Docker

Create a devcontainer.json file to launch a pre-configured Go development environment inside Docker.

The "works on my machine" trap

You clone a Go repository on a fresh laptop. You run go run main.go and the terminal floods with missing dependency errors. You install the linter and it complains about a different Go version than the one in CI. You spend two hours chasing path variables, proxy settings, and mismatched toolchains before you write a single line of application code. The problem is never the code. The problem is the environment.

What a dev container actually does

A development container solves this by packaging the entire toolchain into a reproducible image. Instead of installing Go, the debugger, the formatter, and the linter on your host machine, you run them inside a Docker container that mounts your project folder. The container acts as a portable workshop. It comes with the exact Go version, the correct module proxy configuration, and the precompiled binaries your team relies on. You open the project, the container starts, and the language server connects. The host machine only handles file I/O and the terminal session.

Development containers follow an open specification that editors like VS Code and JetBrains IDEs support natively. The spec separates the environment definition from the editor configuration. Docker handles the runtime isolation. The editor handles the user interface. This split keeps your workflow consistent whether you switch machines or collaborate with a team that uses different operating systems.

Containers are cheap. Environment drift is expensive.

The minimal configuration

The Dev Container specification uses a single JSON file to describe this environment. Create a .devcontainer directory at your project root and place devcontainer.json inside it. This file tells Docker and your editor how to build and run the workspace.

Here is the simplest valid configuration for a Go project:

{
  // Name appears in the editor status bar and container list
  "name": "Go Development",
  // Base image pins the Go version and includes common developer tools
  "image": "mcr.microsoft.com/devcontainers/go:1",
  // Features add capabilities without writing a custom Dockerfile
  "features": {
    // Enables running Docker commands from inside the container
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  // Editor-specific settings apply once the container is running
  "customizations": {
    "vscode": {
      // Installs the official Go extension for syntax and debugging
      "extensions": ["golang.go"]
    }
  }
}

Open the project in your editor and trigger the container rebuild. The editor downloads the base image, applies the feature layers, and starts a background process. Run go version in the integrated terminal to verify the toolchain matches the image tag. The environment is now isolated from your host system.

The Go community expects gofmt to run automatically. The official extension enforces this on save, so you never argue about indentation or brace placement. Trust the toolchain. Let the container handle the formatting.

How the environment boots up

When you trigger the container launch, the Dev Container CLI reads the JSON configuration and generates a temporary Dockerfile behind the scenes. It pulls the specified base image, which already contains the Go binary, the standard library, and a configured module cache. The CLI then mounts your local project directory into the container at a predefined workspace path. This bind mount keeps your source code on the host filesystem while the container provides the execution environment.

The Go language server, gopls, starts inside the container. It reads the go.mod file, resolves dependencies, and indexes your codebase for autocomplete and type checking. Because the container runs the exact same Go version that your CI pipeline uses, compiler warnings and formatting rules stay consistent. The gofmt tool runs automatically on save, enforcing the community standard without manual configuration. You never fight indentation debates or missing semicolons. The container handles the plumbing.

File system synchronization happens over a virtualized network stack on macOS and Windows. Linux hosts use native bind mounts, which are faster. The editor communicates with gopls over the Language Server Protocol. Diagnostics, hover information, and refactor suggestions travel through this channel. If the container loses network access, gopls falls back to cached module metadata. The editor stays responsive even when the proxy is unreachable.

Language servers expect a stable workspace. Mount the project root, not a parent directory.

Debugging and toolchain parity

Writing code is only half the workflow. Debugging requires a separate binary that attaches to the running process. Go uses dlv (Delve) for this. The Dev Container image includes Delve by default, but you must configure the editor to launch it correctly.

Create a launch.json file inside .vscode to define the debug profile. This file tells the editor how to compile the binary and attach the debugger.

{
  "version": "0.2.0",
  "configurations": [
    {
      // Name appears in the debug dropdown menu
      "name": "Launch Go Program",
      // Uses the Delve debugger backend
      "type": "go",
      // Requests the editor to start a new process
      "request": "launch",
      // Points to the main package entry point
      "program": "${workspaceFolder}",
      // Builds the binary with debug symbols enabled
      "buildFlags": "-gcflags=all=-l -N",
      // Sets environment variables for the debug session
      "env": {
        "APP_ENV": "development"
      }
    }
  ]
}

The -gcflags=all=-l -N flags disable inlining and optimize for debugging. Without them, the debugger cannot map stack frames to source lines. The program field uses a workspace variable that resolves to your project root. Delve compiles the binary, injects breakpoints, and hands control to the editor. You step through functions, inspect variables, and evaluate expressions without leaving the terminal.

Go functions follow a strict naming convention. Public names start with a capital letter. Private names start lowercase. The debugger respects this boundary. You can inspect exported fields and methods, but unexported variables require the --check-go-version flag to bypass safety checks. Keep your debug configuration simple. Let Delve handle the heavy lifting.

A production-ready setup

The default Microsoft image works for most projects, but real codebases often need specific tool versions, custom environment variables, or additional binaries. You can extend the base image with a custom Dockerfile while keeping the Dev Container JSON for editor configuration.

Create a Dockerfile inside the .devcontainer folder. This file defines the exact toolchain and installs supplementary utilities.

# Start from the official Go image with the desired version
FROM golang:1.22-bookworm

# Install system dependencies required by the application
RUN apt-get update && apt-get install -y \
    git \
    curl \
    jq \
    && rm -rf /var/lib/apt/lists/*

# Install golangci-lint for faster, multi-linter analysis
RUN curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v1.55.2

# Set environment variables for module caching and proxy
ENV GOMODCACHE=/go/pkg/mod \
    GOPROXY=https://proxy.golang.org,direct \
    GOFLAGS=-buildvcs=false

# Switch to the non-root user for safer file permissions
USER vscode

Update devcontainer.json to reference this Dockerfile instead of the raw image tag. The build object points to the Dockerfile location and passes build arguments if needed.

{
  "name": "Go Custom",
  "build": {
    // Points to the Dockerfile in the same directory
    "dockerfile": "Dockerfile",
    // Context ensures Docker can access the file during build
    "context": "."
  },
  "remoteUser": "vscode",
  "customizations": {
    "vscode": {
      "extensions": ["golang.go", "golang.go-team"]
    }
  }
}

The GOMODCACHE environment variable tells Go where to store downloaded modules. Keeping this path inside the container prevents permission conflicts when the host machine tries to read cached binaries. The GOFLAGS=-buildvcs=false flag disables version control metadata embedding, which speeds up builds during rapid iteration. You can re-enable it for release builds.

Go developers run go mod tidy before committing. The container executes this command against the exact same standard library and dependency versions that production uses. Mismatched module graphs disappear. The compiler rejects programs with undefined: package errors if a dependency is missing, and the container catches this before you push to Git.

Keep the Dockerfile lean. Every layer adds build time.

Common pitfalls and how to avoid them

Development containers introduce a few friction points if you ignore how Docker handles file systems and networking. The most common issue is slow file synchronization on macOS and Windows. Docker Desktop uses a virtual machine to run Linux containers, and bind mounts cross that boundary. Large compiled Go binaries or vendor directories can cause laggy editor performance. Exclude heavy directories in your .gitignore and add them to the mounts configuration if you need faster access.

Another trap is losing the module cache after a container restart. Docker containers are ephemeral. When you stop and remove the container, the /go/pkg/mod directory disappears unless you mount it as a persistent volume. Add a mounts array to devcontainer.json to map the cache to a named volume. This keeps dependencies across sessions and avoids repeated network downloads.

Permission mismatches also cause silent failures. If the container runs as root but your host files belong to a different user, go mod tidy might create files your editor cannot read. Always set remoteUser to match the Dockerfile user, or use runArgs to pass user ID mappings. The compiler will reject builds with permission denied errors if the Go binary cannot write to the build cache.

Network proxies and firewall rules inside the container differ from your host machine. If your company requires a custom proxy for module downloads, you must set GOPROXY and HTTPS_PROXY inside the container environment. The Go toolchain does not inherit host proxy settings automatically. Verify connectivity by running go env GOPROXY inside the terminal.

Context cancellation and deadline handling apply to long-running container tasks too. If you run a background process that waits on a channel, ensure it respects a cancellation signal. The worst goroutine bug is the one that never logs. Apply the same discipline to container lifecycle management.

When to use a dev container

Use a local Go installation when you need maximum editor performance and your team standardizes on a single host operating system. Use a dev container when you want reproducible environments across macOS, Windows, and Linux without manual setup. Use a cloud-based IDE when you work on low-spec hardware or need instant access from public networks. Use a full virtual machine when your application requires kernel-level debugging or custom device drivers.

Containers isolate the toolchain. They do not replace good architecture.

Where to go next