Skip to content

Development Containers: Consistent Environments for Every Contributor

Development Containers logo

Some engineering teams hit the same friction eventually: A new developer clones the repository, follows the setup instructions, and spends the next two hours debugging why the toolchain that works on everyone else's machine doesn't work on theirs. The versions are slightly different. A system dependency is missing. The environment variable was never set. By the time they make their first commit, they've already burned half a day on tasks that had nothing to do with the code they were hired to write.

Development containers help solve this by codifying the development environment as a container image. Every contributor uses the same tools at the same versions on every machine. Onboarding becomes more a matter of opening the repository rather than following a tedious setup guide.

A development container, commonly called a "devcontainer", is a Docker container configured as a development environment. It holds the runtime, tools, extensions, and settings required to work on the project, and that configuration lives in the repository right next to the codebase.

The Development Containers specification defines the format that editor and platform integrations follow. VS Code, GitHub Codespaces, JetBrains Gateway, and the Dev Containers CLI all consume the same .devcontainer/ definition. A valid configuration should work across those entry points without forked setup docs.

Instead of a README.md that says "install x v1.24, y 1.11, and z v7.1.2," the repository includes a container image specification.

What It Isn't

A development container isn't a production environment. It isn't even a deployment artifact or an image pushed for release pipelines to consume. Its job is to provide a consistent development experience. Paths, extensions, and tools in a devcontainer are chosen to make building and testing straightforward, not to minimize a production image size.

The .devcontainer Directory

The development container configuration lives in a .devcontainer directory at the root of the repository. Tools and platforms that implement the Development Containers specification look there for the files they need.

.devcontainer/
├── devcontainer.json
└── Dockerfile

The minimum viable devcontainer requires only devcontainer.json. Adding a Dockerfile gives you full control over what's installed in the container image.

devcontainer.json

devcontainer.json is the main configuration file. It tells the container runtime what image to use, which VS Code extensions to install, how to forward ports, what lifecycle hooks to run, and what settings to apply to the editor.

Here's a minimal example using a pre-built base image:

{
  "name": "My Project",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "customizations": {
    "vscode": {
      "extensions": [
        "EditorConfig.EditorConfig",
        "esbenp.prettier-vscode"
      ]
    }
  }
}

When you need a custom image, replace image with a build block that points to a Dockerfile:

{
  "name": "My Project",
  "build": {
    "dockerfile": "Dockerfile"
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "EditorConfig.EditorConfig",
        "esbenp.prettier-vscode",
        "hashicorp.terraform",
        "redhat.ansible",
        "ms-vscode.powershell",
        "streetsidesoftware.code-spell-checker"
      ]
    }
  }
}

The customizations.vscode.extensions array accepts any extension ID from the VS Code Marketplace. When the container starts, the editor installs these extensions inside the container rather than on the host. Contributors with a completely different set of extensions installed locally get the same editor experience when working inside the container.

Key Configuration Properties

Property Description
name Human-readable name displayed in the editor's status bar.
image A pre-built Docker image to use. Cannot be combined with build.
build.dockerfile Path to a Dockerfile relative to .devcontainer/. Used when a custom image is required.
build.context Docker build context directory. Defaults to the directory containing devcontainer.json.
features Devcontainer Features: pre-packaged tool installations from the Dev Container Features index.
customizations.vscode.extensions VS Code extension IDs to install in the container.
customizations.vscode.settings VS Code settings to apply inside the container.
forwardPorts Ports to forward from the container to the host machine.
remoteUser The user inside the container to use when connecting.
Lifecycle Hooks See Lifecycle Hooks for onCreateCommand, updateContentCommand, and others.

Devcontainer Features

The specification includes a feature system that lets you add commonly needed tools to a base image without writing RUN layers for each one in the Dockerfile. Features are pre-packaged, versioned tool installations published to the Dev Container Features.

{
  "name": "My Project",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/devcontainers/features/go:1": {
      "version": "1.26"
    },
    "ghcr.io/devcontainers/features/terraform:1": {
      "version": "1.15.7"
    },
    "ghcr.io/devcontainers/features/python:1": {
      "version": "3.14"
    }
  }
}

Features are a good fit when the index already ships the tool and you want declarative configuration without Dockerfile layers. Pin the version field explicitly. Using "latest" is the same mistake as releases/latest in a Dockerfile: Rebuilds drift without a reviewable diff.

For tighter control over what's installed, how it's installed, and how you verify it, use a Dockerfile.

The Dockerfile

When a devcontainer needs tools that aren't available as Dev Container Features, or when you need precise control over the installation process, a Dockerfile in .devcontainer/ provides the full power of Docker's build system.

Choosing a Base Image

Microsoft publishes a set of base images specifically designed for development containers under mcr.microsoft.com/devcontainers/. These images are pre-configured with a non-root user, common development utilities, and a shell environment suitable for interactive use. For most projects, mcr.microsoft.com/devcontainers/base:ubuntu is a good starting point.

FROM mcr.microsoft.com/devcontainers/base:ubuntu

Installing System Packages

Start by installing the system packages that your toolchain and tools depend on:

RUN apt-get update && \
    apt-get install -y \
        apt-transport-https \
        ca-certificates \
        curl \
        git \
        jq \
        software-properties-common \
        unzip \
        wget && \
    update-ca-certificates && \
    rm -rf /var/lib/apt/lists/*

Use one RUN per logical install step, then drop apt lists in that same layer. That keeps layer count reasonable and avoids bloating the image with cached package indexes.

Installing Tools from GitHub Releases

Many tools publish releases on GitHub. The pattern that matches a devcontainer's job is to pin the version at the top of the Dockerfile, download a known artifact, and verify the checksum before you unzip or install it:

ARG TOOL_VERSION=1.2.3
ARG TOOL_SHA256=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
RUN wget -q -O tool.zip \
        "https://releases.example.com/tool/${TOOL_VERSION}/tool_${TOOL_VERSION}_linux_amd64.zip" && \
    echo "${TOOL_SHA256}  tool.zip" | sha256sum -c - && \
    unzip -o tool.zip && \
    mv tool /usr/local/bin/ && \
    rm tool.zip

Bump ARG values in a pull request when you want everyone on a new tool version. That keeps rebuilds reviewable.

If the vendor publishes a checksum manifest, use that instead of hard-coding a hash in the Dockerfile. HashiCorp, for example, publishes SHA256SUMS files for each release:

ARG TERRAFORM_VERSION=1.15.7
RUN wget -q -O terraform.zip \
        "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \
    wget -q -O terraform_SHA256SUMS \
        "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS" && \
    grep " terraform_${TERRAFORM_VERSION}_linux_amd64.zip\$" terraform_SHA256SUMS | sha256sum -c - && \
    unzip -o terraform.zip && \
    mv terraform /usr/local/bin/ && \
    rm terraform.zip terraform_SHA256SUMS

Avoid releases/latest in Devcontainer Images

Querying the GitHub API for releases/latest on every image build looks convenient, but it breaks the point of a devcontainer: two contributors who rebuild a week apart can get different toolchains without merging a config change. It can also make CI flaky because GitHub keeps a much lower rate limit on unauthenticated API requests than on direct asset downloads. If you need a floating install for a personal scratch image, use an authenticated API request or a vendor release endpoint when one exists. For the shared baseline, pin the version.

For tools distributed as Debian packages (.deb), pin the release URL or version the same way and finish with apt-get dependency resolution:

ARG PWSH_VERSION=7.6.3
RUN wget -q "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell_${PWSH_VERSION}-1.deb_amd64.deb" && \
    apt-get update && \
    apt-get install -y "./powershell_${PWSH_VERSION}-1.deb_amd64.deb" && \
    rm "powershell_${PWSH_VERSION}-1.deb_amd64.deb" && \
    rm -rf /var/lib/apt/lists/*

Installing Python and System-Packaged Tools

For tools distributed through the system package manager or pip, integrate them into the same RUN chain to keep the layer count low. This example assumes an Ubuntu base image and that you already installed software-properties-common in an earlier step:

RUN add-apt-repository --yes --update ppa:ansible/ansible && \
    apt-get update && \
    apt-get install -y python3 python3-pip ansible && \
    rm -rf /var/lib/apt/lists/*

Cleaning Up

Every Dockerfile should end with a cleanup step to remove package manager caches and temporary files. These files serve no purpose in the final image and only increase its size:

RUN apt-get autoremove -y && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

An Example

The following Dockerfile assembles a development environment for an infrastructure-as-code project with Packer, Terraform, gomplate, PowerShell, and Ansible. Versions are pinned with ARG so rebuilds stay predictable:

# Use the base Ubuntu devcontainer image.
FROM mcr.microsoft.com/devcontainers/base:ubuntu

ARG PACKER_VERSION=1.15.4
ARG TERRAFORM_VERSION=1.15.7
ARG GOMPLATE_VERSION=5.1.0
ARG PWSH_VERSION=7.6.3

# Install system packages.
RUN apt-get update && \
    apt-get install -y \
        apt-transport-https \
        ca-certificates \
        curl \
        git \
        jq \
        software-properties-common \
        unzip \
        wget && \
    update-ca-certificates && \
    rm -rf /var/lib/apt/lists/*

# Packer
RUN wget -q -O packer.zip \
        "https://releases.hashicorp.com/packer/${PACKER_VERSION}/packer_${PACKER_VERSION}_linux_amd64.zip" && \
    wget -q -O packer_SHA256SUMS \
        "https://releases.hashicorp.com/packer/${PACKER_VERSION}/packer_${PACKER_VERSION}_SHA256SUMS" && \
    grep " packer_${PACKER_VERSION}_linux_amd64.zip\$" packer_SHA256SUMS | sha256sum -c - && \
    unzip -o packer.zip && \
    mv packer /usr/local/bin/ && \
    rm packer.zip packer_SHA256SUMS

# Terraform
RUN wget -q -O terraform.zip \
        "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \
    wget -q -O terraform_SHA256SUMS \
        "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_SHA256SUMS" && \
    grep " terraform_${TERRAFORM_VERSION}_linux_amd64.zip\$" terraform_SHA256SUMS | sha256sum -c - && \
    unzip -o terraform.zip && \
    mv terraform /usr/local/bin/ && \
    rm terraform.zip terraform_SHA256SUMS

# gomplate
RUN wget -q -O /usr/local/bin/gomplate \
        "https://github.com/hairyhenderson/gomplate/releases/download/v${GOMPLATE_VERSION}/gomplate_linux-amd64" && \
    chmod +x /usr/local/bin/gomplate

# PowerShell
RUN wget -q "https://github.com/PowerShell/PowerShell/releases/download/v${PWSH_VERSION}/powershell_${PWSH_VERSION}-1.deb_amd64.deb" && \
    apt-get update && \
    apt-get install -y "./powershell_${PWSH_VERSION}-1.deb_amd64.deb" && \
    rm "powershell_${PWSH_VERSION}-1.deb_amd64.deb" && \
    rm -rf /var/lib/apt/lists/*

# Python and Ansible
RUN add-apt-repository --yes --update ppa:ansible/ansible && \
    apt-get update && \
    apt-get install -y python3 python3-pip ansible && \
    apt-get autoremove -y && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

Development images can afford extra layer size. The point is a readable, pinned toolchain, not a minimal production base.

The corresponding devcontainer.json pairs the Dockerfile with editor extensions for that stack:

{
  "name": "Infrastructure-as-Code",
  "build": {
    "dockerfile": "Dockerfile"
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "EditorConfig.EditorConfig",
        "esbenp.prettier-vscode",
        "GitHub.remotehub",
        "GitHub.vscode-pull-request-github",
        "hashicorp.hcl",
        "hashicorp.terraform",
        "ms-vscode.powershell",
        "redhat.ansible",
        "streetsidesoftware.code-spell-checker"
      ]
    }
  }
}

Using the Devcontainer

In VS Code

The Dev Containers extension (ms-vscode-remote.remote-containers) adds devcontainer support to VS Code. If you live in VS Code daily, see Inside My VS Code Setup: Theme, Extensions, and Settings for how I think about host editor defaults versus project-level extension lists in devcontainer.json.

With the extension installed, opening a repository that contains a .devcontainer directory presents a notification to reopen the project in a container.

flowchart TD
    A([Open Folder in VS Code]) --> B{devcontainer detected?}
    B -- Yes --> C[Notification: Reopen in Container]
    B -- No --> D([Standard Local Environment])
    C --> E[Build or Pull Container Image]
    E --> F[Start Container]
    F --> G[Attach VS Code to Container]
    G --> H[Install Extensions Inside Container]
    H --> I([Environment Ready!])

You can also trigger the open manually via the Command Palette:

  1. Open the Command Palette (Cmd+Shift+P on macOS, Ctrl+Shift+P on Linux and Windows).
  2. Type Dev Containers: Reopen in Container and select it.
  3. VS Code builds the image (if using a Dockerfile), starts the container, and attaches to it.

After attaching, the VS Code terminal runs inside the container. All installed tools, the configured extensions, and the project files are available exactly as configured.

Rebuilding the Container

When you change the Dockerfile or devcontainer.json, the running container doesn't update automatically. Open the Command Palette and run Dev Containers: Rebuild Container to apply the changes. Use Dev Containers: Rebuild and Reopen in Container to rebuild and reattach in one step.

In JetBrains IDEs

JetBrains IDEs connect to the same .devcontainer/ definition through Gateway and remote development support. The editor UI differs from VS Code, but the container image, Features, and lifecycle hooks are the same file in the repository.

In GitHub Codespaces

GitHub Codespaces uses the same Development Containers specification. A repository with a .devcontainer configuration automatically provisions a Codespace with that environment when a contributor creates a new Codespace from GitHub.

If no .devcontainer is present, Codespaces falls back to a default image. Adding a devcontainer configuration to the repository gives every Codespace the same environment as every local container: the same tools, the same versions, the same extensions.

Codespaces also supports multiple devcontainer configurations in a .devcontainer/ directory by placing each configuration in a subdirectory. When a contributor creates a Codespace, GitHub presents a configuration picker. That's useful for repositories that need different environments for different workflows.

With the Dev Containers CLI

The Dev Containers CLI (@devcontainers/cli) provides a command-line interface for building and running devcontainers outside of an editor. It's useful for scripting and for CI jobs that sanity-check the devcontainer itself. If you already use the VS Code extension, it can install its own CLI variant for local use. In automation, pin the npm package version the same way you pin the rest of the toolchain:

npm install -g @devcontainers/[email protected]

Build the devcontainer:

devcontainer build --workspace-folder .

Start the devcontainer and run a command inside it:

devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . -- packer version

Running tests inside the container through the CLI can validate that the devcontainer still builds and boots. Budget for Docker on the runner and the extra minutes; it's worth it on large or long-lived repos, not on every small library.

Lifecycle Hooks

devcontainer.json supports lifecycle hooks that run commands at different points in the container lifecycle. Use them for work that changes with the repository and shouldn't be baked into the image, such as installing application dependencies after clone.

Hook Runs Notes
initializeCommand On the host, before the container is built. Rare; host-side prep only.
onCreateCommand Inside the container, on first create. First of three setup commands during create.
updateContentCommand Inside the container, after onCreateCommand. Runs when content is available; may repeat during create or prebuild.
postCreateCommand Inside the container, after updateContentCommand, on first create. Common choice for npm install, uv sync, and similar.
postStartCommand Every time the container starts. Refresh services or state on restart.
postAttachCommand Every time an editor attaches. Editor-specific setup.

If any hook fails, later hooks in the chain are skipped.

A common use of postCreateCommand is to install project dependencies after the workspace is available:

{
  "name": "My Project",
  "build": {
    "dockerfile": "Dockerfile"
  },
  "postCreateCommand": "uv sync"
}

For a pip workflow, pip install -r requirements.txt still works; prefer a repo script when setup is more than one command.

For more complex initialization, point the hook at a script in the repository:

{
  "postCreateCommand": ".devcontainer/post-create.sh"
}

Port Forwarding

When the project runs a local server for development, such as a documentation preview or a local API, forwardPorts makes those ports accessible from the host machine:

{
  "name": "My Project",
  "build": {
    "dockerfile": "Dockerfile"
  },
  "forwardPorts": [8000, 5432]
}

VS Code and Codespaces forward the listed ports automatically when the container starts. In Codespaces, forwarded ports can optionally be made public (accessible outside the Codespace) for sharing previews with collaborators.

Environment Variables

Environment variables can be passed into the container through devcontainer.json using containerEnv for variables that are the same for all contributors, and remoteEnv for variables sourced from the host environment:

{
  "containerEnv": {
    "MY_TOOL_LOG_LEVEL": "info"
  },
  "remoteEnv": {
    "PATH": "${containerEnv:PATH}:/custom/path",
    "HOST_HOME": "${localEnv:HOME}"
  }
}

Secrets and Sensitive Values

Don't put secrets, API keys, or credentials in devcontainer.json or the Dockerfile. These files are committed to the repository and visible to everyone with repository access. Use host injection, GitHub Codespaces secrets, or a secrets manager at runtime.

What to Include and What to Leave Out

The goal of a devcontainer is a reproducible baseline environment, not a complete mirror of every contributor's local machine. A few guidelines help keep the configuration focused:

Include:

  • The runtime, compilers, and interpreters required to build and run the project.
  • CLI tools required by the project's development workflow (linters, formatters, deployment tools, template engines).
  • Editor extensions that are directly relevant to the languages and tools in use.
  • Editor settings that enforce project-level standards (file endings, ruler at 80 or 120 characters, default formatter).

Exclude:

  • Personal tools and preferences that have nothing to do with the project (a specific shell theme, a preferred multiplexer, a custom prompt).
  • Tools that contributors are expected to bring themselves and that aren't project dependencies.
  • Credentials and secrets.

The .devcontainer configuration is a contribution to the project that affects all contributors. Treat changes to it with the same review discipline you would apply to any other configuration change.

References