Dev Containers: your entire dev environment in a single JSON file

No more "works on my machine" and no more two-hour onboarding. One configuration file, one container, and every developer on the team in the same environment.

You know the scene. A new teammate joins and spends the first day fighting the environment: the wrong Python version, a missing system library, two projects that want different Node versions. By the evening they have written no code.

Or this one. You fix a bug locally, push it, and CI fails because the build server has a slightly different version of some dependency. An hour later you find a gcc version mismatch. Nothing you wrote was wrong.

Both problems have the same cause: the development environment lives in your head and on your laptop, not in the repository.

Dev Containers fix that. They are a specification, an ecosystem of tools and a workflow that describe the whole development environment (tools, runtimes, editor extensions, settings) in a devcontainer.json file that sits next to your code. Anyone with Docker and VS Code, or GitHub Codespaces, or another supporting tool, gets the same environment without manual setup.

This post goes through each layer of how that works.

What a dev container is

The project's own site puts it in one line: "A development container (or dev container for short) allows you to use a container as a full-featured development environment." The specification adds that a dev container "defines an environment in which you develop your application before you are ready to deploy."

The important word is full-featured. A dev container is more than a Docker image with a language runtime. It also carries your editor extensions, linter settings, a database next to the app, environment variables and the scripts that finish the setup. It is a reproducible description of the whole development setup.

How the pieces fit together

Before any configuration, it helps to have the picture in mind. Everything later in this post fits into it.

+-------------------------------------------------------------+
| Your machine (host)                                         |
|                                                             |
|  +-------------------------------------------------------+  |
|  | Docker or another container runtime                   |  |
|  |                                                       |  |
|  |  +-------------------------------------------------+  |  |
|  |  | Dev container                                   |  |  |
|  |  |   Base image (Ubuntu, Debian, ...)              |  |  |
|  |  |   Features (Python, Node, Docker, git, ...)     |  |  |
|  |  |   Your source code (bind mounted)               |  |  |
|  |  |   Lifecycle scripts (postCreate, postStart ...) |  |  |
|  |  +-------------------------------------------------+  |  |
|  |                                                       |  |
|  |  +----------------------+  +-----------------------+  |  |
|  |  | Postgres container   |  | Redis container       |  |  |
|  |  | (with Docker Compose)|  | (with Docker Compose) |  |  |
|  |  +----------------------+  +-----------------------+  |  |
|  +-------------------------------------------------------+  |
|                                                             |
|  +-------------------------------------------------------+  |
|  | Supporting tool (VS Code, Codespaces, ...)            |  |
|  |   reads .devcontainer/devcontainer.json               |  |
|  |   installs extensions inside the container            |  |
|  +-------------------------------------------------------+  |
+-------------------------------------------------------------+

Three things follow from this picture:

The .devcontainer folder

The whole configuration lives in the repository:

your-project/
|-- .devcontainer/
|   |-- devcontainer.json    # the main configuration file
|   |-- Dockerfile           # optional, for a custom image
|   `-- docker-compose.yml   # optional, for several containers
|-- src/
|   `-- main.py
`-- README.md

Tools that implement the specification look for devcontainer.json in these places, in order of precedence:

  1. .devcontainer/devcontainer.json
  2. .devcontainer.json in the project root
  3. .devcontainer/<folder>/devcontainer.json, where <folder> is one level deep. This is how one repository offers several configurations.

The file is JSON with comments, so the // comments in the examples below are valid.

Three ways to define the container

The specification supports three ways to describe the container, from the simplest to the most flexible.

1. From an image

Point at a prebuilt image. No Dockerfile needed.

// .devcontainer/devcontainer.json
{
  "name": "Python 3.12",
  "image": "mcr.microsoft.com/devcontainers/python:3.12"
}

Microsoft publishes a set of dev container images under mcr.microsoft.com/devcontainers/: python, javascript-node, go, java, rust, dotnet, base and more. Each comes with common development tools and a non-root user already set up.

2. From a Dockerfile

Use a Dockerfile when you need system packages or tools that the base image does not have.

// .devcontainer/devcontainer.json
{
  "name": "My Python App",
  "build": {
    "dockerfile": "Dockerfile",
    "args": {
      "PYTHON_VERSION": "3.12"
    }
  },
  "forwardPorts": [8000]
}
# .devcontainer/Dockerfile
ARG PYTHON_VERSION=3.12
FROM mcr.microsoft.com/devcontainers/python:${PYTHON_VERSION}

# System-level dependencies
RUN apt-get update && apt-get install -y \
    postgresql-client \
    redis-tools \
    && rm -rf /var/lib/apt/lists/*

The Dockerfile runs while the image is built, before the container starts. Use it for system-level dependencies. For project-level setup such as installing your Python requirements, use the lifecycle scripts described later.

3. With Docker Compose

Use Docker Compose when the project needs a database, a cache or another service next to the main container.

// .devcontainer/devcontainer.json
{
  "name": "Django + Postgres",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace"
}
# .devcontainer/docker-compose.yml
services:
  app:
    build:
      context: ..
      dockerfile: .devcontainer/Dockerfile
    volumes:
      - ..:/workspace:cached
    command: sleep infinity   # keep the container running
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: myapp
    volumes:
      - postgres-data:/var/lib/postgresql/data
  redis:
    image: redis:7-alpine

volumes:
  postgres-data:

dockerComposeFile and service are both required in this mode: the first names the Compose file, the second names the container your editor connects to. The app, the database and the cache start together. From inside the app container you reach the others by service name, for example db:5432, because Compose puts them on one network. command: sleep infinity is there because the tool does not override the container's command in Compose mode, and without a long-running command the container would exit. Most backend projects end up with this pattern.

I dropped the top-level version: "3.8" line that older Compose files carry. Current Docker Compose treats it as obsolete and prints a warning when it sees it.

Features: modular tooling

Features are one of the most useful parts of dev containers. A Feature is a self-contained, shareable unit of installation code that adds a tool to any base image, a bit like a package for your development environment.

{
  "name": "Full-Stack Dev",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu",
  "features": {
    "ghcr.io/devcontainers/features/python:1": {
      "version": "3.12"
    },
    "ghcr.io/devcontainers/features/node:2": {
      "version": "lts"
    },
    "ghcr.io/devcontainers/features/docker-in-docker:4": {
      "version": "latest",
      "moby": true
    },
    "ghcr.io/devcontainers/features/git:1": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  }
}

Each Feature is:

Major versions move. At the time of this revision the official Node Feature is at :2 and Docker-in-Docker is at :4, so check each Feature's page before you copy an old snippet.

How Features work inside

Every Feature has this shape:

my-feature/
|-- devcontainer-feature.json    # metadata: id, version, options
`-- install.sh                   # the installation script
// devcontainer-feature.json
{
  "id": "python",
  "version": "1.2.3",
  "name": "Python",
  "options": {
    "version": {
      "type": "string",
      "default": "os-provided",
      "description": "Python version to install"
    }
  }
}

When you add a Feature to devcontainer.json, the tool downloads it from the registry and runs its install.sh as root while it builds the image. The tool is then part of the image and available for the life of the container.

The lifecycle: what runs when

This is the part most people find confusing, so I will be thorough. The specification defines six lifecycle commands. The image, including its Features, is built first. Then they run in this order:

CommandRuns onWhen
initializeCommandHostDuring initialization: when the container is created and again on later starts. It may run more than once in a session. For a cloud service, the "host" is in the cloud.
onCreateCommandContainerFirst of three setup commands, right after the container starts for the first time. Cloud services may run it while prebuilding, so it usually has no access to user secrets.
updateContentCommandContainerSecond setup command, after onCreateCommand. It runs at least once, and cloud services also rerun it to refresh prebuilt containers when the source changes. Only repository and organization secrets are available.
postCreateCommandContainerLast setup command, once the container has been assigned to a user for the first time. This is where user-specific secrets and permissions are available.
postStartCommandContainerEvery time the container starts.
postAttachCommandContainerEvery time a tool attaches to the container.

Two more rules matter in practice. If one lifecycle command fails, the ones after it do not run: a failing postCreateCommand means postStartCommand is skipped too. And the waitFor property says which command a tool waits for before it connects. It defaults to updateContentCommand, so slow steps in postCreateCommand can finish in the background while you already work.

What goes where

TaskBest placeWhy
Check a host prerequisiteinitializeCommandRuns on the host, before the container is created
Install system packagesDockerfile or FeaturesCached in an image layer
pip install -r requirements.txtupdateContentCommand or postCreateCommandNeeds the workspace files
npm installupdateContentCommand or postCreateCommandNeeds the workspace files
Database migrationspostCreateCommandOne-time setup
Start a dev serverpostStartCommandHas to start again with the container
Start background workerspostStartCommandEvery container start
Print a welcome messagepostAttachCommandEvery session

Running commands in parallel

Every lifecycle command accepts three forms. A string goes through a shell. An array is passed to the operating system without a shell. An object runs each of its entries in parallel:

{
  "postCreateCommand": {
    "python-deps": "pip install --user -r requirements.txt",
    "node-deps": "npm install"
  }
}

The two installs above run at the same time, and each must exit successfully for the step to count as successful. Parallel means no ordering, so only put independent commands in one object. A migration that needs the Python packages must not sit next to the install that provides them. For steps in sequence, use && in a string, a shell script, or an earlier lifecycle command.

devcontainer.json, property by property

Here are the properties you will use most, with examples.

What to build

Pick exactly one of the three sources from earlier: an image, a build, or a Compose file.

{
  // Human-readable name, shown by the editor
  "name": "My Python API",

  // Option 1: a prebuilt image
  "image": "mcr.microsoft.com/devcontainers/python:3.12"

  // Option 2: a Dockerfile, instead of "image"
  // "build": {
  //   "dockerfile": "Dockerfile",
  //   "context": "..",
  //   "args": { "VARIANT": "3.12-bookworm" }
  // }

  // Option 3: Docker Compose, instead of "image"
  // "dockerComposeFile": "docker-compose.yml",
  // "service": "app"
}

build.context is relative to devcontainer.json, so ".." lets the Dockerfile reach files in the project root, such as requirements.txt.

Ports

{
  // Forward these ports to your machine
  "forwardPorts": [8000, "db:5432", "redis:6379"],

  // Options per port
  "portsAttributes": {
    "8000": {
      "label": "Application",
      // notify, openBrowser, openBrowserOnce, openPreview,
      // silent or ignore
      "onAutoForward": "notify"
    },
    "db:5432": {
      "label": "PostgreSQL",
      "onAutoForward": "silent"
    }
  }
}

A plain number forwards a port of the main container. The "host:port" form forwards a port of another Compose service, which is what you want for a database that runs in its own container. onAutoForward defaults to notify.

Environment variables

{
  // Set on the container itself, for every process.
  // Fixed for the life of the container: rebuild to change.
  "containerEnv": {
    "PYTHONPATH": "/workspace/src",
    "DATABASE_URL": "postgresql://db:5432/devdb"
  },

  // Set for the tool and its sub-processes, such as
  // terminals. Can change without a rebuild.
  "remoteEnv": {
    "LOCAL_WORKSPACE_FOLDER": "${localWorkspaceFolder}",
    "API_KEY": "${localEnv:MY_API_KEY}"
  }
}

Mounts

{
  "mounts": [
    // A named volume that survives rebuilds
    {
      "source": "devcontainer-cache",
      "target": "/home/vscode/.cache",
      "type": "volume"
    },
    // A folder from the host
    {
      "source": "${localWorkspaceFolder}/data",
      "target": "/workspace/data",
      "type": "bind"
    }
  ]
}

Each entry is either an object like the ones above or a string in the format of Docker's --mount flag.

Users and the workspace

{
  // User for the tool, terminals, tasks and debugging
  "remoteUser": "vscode",
  // User for everything in the container
  "containerUser": "vscode",

  // Where the source is mounted, and where the editor opens.
  // Each of these two needs the other.
  "workspaceMount":
    "source=${localWorkspaceFolder},target=/workspace,type=bind",
  "workspaceFolder": "/workspace"
}

remoteUser changes only the user that the tool and its terminals run as. containerUser changes the user for everything in the container. Most of the time remoteUser is enough.

Lifecycle scripts

{
  "initializeCommand": "echo 'Checking prerequisites on the host'",

  "onCreateCommand": "echo 'Container created'",

  "updateContentCommand": "pip install --user -r requirements.txt",

  "postCreateCommand": {
    "pre-commit": "python -m pre_commit install",
    "migrate": "python manage.py migrate"
  },

  "postStartCommand":
    "nohup python manage.py runserver 0.0.0.0:8000 > /tmp/dev.log 2>&1 &",

  "postAttachCommand": "echo 'Dev container ready.'"
}

The requirements are installed in updateContentCommand, which finishes before postCreateCommand starts. That is what lets the two postCreateCommand entries run in parallel: both need the packages, and neither needs the other. The --user flag is there because the Python image installs Python as root, so the non-root vscode user needs either sudo or a user install. The dev server is started with nohup and its output goes to a log file, so it keeps running after the hook returns.

Editor customizations

{
  "customizations": {
    "vscode": {
      // Extensions installed inside the container
      "extensions": [
        "ms-python.python",
        "ms-python.vscode-pylance",
        "charliermarsh.ruff",
        "ms-python.mypy-type-checker",
        "mtxr.sqltools",
        "mtxr.sqltools-driver-pg"
      ],
      // Settings applied inside the container
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python",
        "editor.formatOnSave": true,
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff",
          "editor.codeActionsOnSave": {
            "source.fixAll.ruff": "explicit",
            "source.organizeImports.ruff": "explicit"
          }
        },
        "mypy-type-checker.importStrategy": "fromEnvironment"
      }
    }
  }
}

customizations holds settings for specific tools. Each tool reads its own key, here vscode, and ignores the rest.

A complete example: FastAPI, PostgreSQL and Redis

Here is everything together for a realistic Python API project:

my-fastapi-project/
|-- .devcontainer/
|   |-- devcontainer.json
|   `-- docker-compose.yml
|-- src/
|   `-- main.py
|-- requirements.txt
`-- pyproject.toml
// .devcontainer/devcontainer.json
{
  "name": "FastAPI Dev",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "features": {
    "ghcr.io/devcontainers/features/git:1": {},
    "ghcr.io/devcontainers/features/github-cli:1": {}
  },
  "forwardPorts": [8000, "db:5432", "redis:6379"],

  "portsAttributes": {
    "8000": { "label": "FastAPI", "onAutoForward": "notify" },
    "db:5432": { "label": "PostgreSQL", "onAutoForward": "silent" },
    "redis:6379": { "label": "Redis", "onAutoForward": "silent" }
  },

  "updateContentCommand": "pip install --user -r requirements.txt",

  "postCreateCommand": {
    "pre-commit": "python -m pre_commit install",
    "migrate": "python -m alembic upgrade head"
  },

  "remoteUser": "vscode",

  "customizations": {
    "vscode": {
      "extensions": [
        "ms-python.python",
        "ms-python.vscode-pylance",
        "charliermarsh.ruff",
        "ms-python.mypy-type-checker",
        "mtxr.sqltools",
        "mtxr.sqltools-driver-pg",
        "ms-azuretools.vscode-docker"
      ],
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python",
        "editor.formatOnSave": true,
        "[python]": {
          "editor.defaultFormatter": "charliermarsh.ruff"
        }
      }
    }
  }
}
# .devcontainer/docker-compose.yml
services:
  app:
    image: mcr.microsoft.com/devcontainers/python:3.12
    volumes:
      - ..:/workspace:cached
    command: sleep infinity
    environment:
      DATABASE_URL: postgresql://postgres:devpassword@db:5432/myapp
      REDIS_URL: redis://redis:6379
    depends_on:
      - db
      - redis
  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: devpassword
      POSTGRES_DB: myapp
    volumes:
      - postgres-data:/var/lib/postgresql/data
  redis:
    image: redis:7-alpine
    restart: unless-stopped

volumes:
  postgres-data:

The tools run as python -m pre_commit and python -m alembic so that they are found even when the user-install bin folder is not on the PATH. Both packages come from requirements.txt.

When a new teammate clones this repository and opens it in VS Code:

  1. VS Code finds .devcontainer/devcontainer.json and offers to reopen the folder in a container.
  2. Docker pulls the Python image and starts Postgres and Redis.
  3. updateContentCommand installs the dependencies, then postCreateCommand installs the Git hooks and runs the migrations.
  4. VS Code installs the listed extensions inside the container.
  5. They have a working development environment without installing anything but Docker and VS Code.

The Dev Container CLI

The Dev Container CLI is the reference implementation of the specification. It creates and configures a dev container from a devcontainer.json, and it is meant both for direct use and for tools that want to support the spec. The VS Code Dev Containers extension ships a variation of it, and CodeSandbox uses it to manage dev containers.

npm install -g @devcontainers/cli

# Build the dev container image
devcontainer build --workspace-folder .

# Create and start the dev container
devcontainer up --workspace-folder .

# Run a command inside the container
devcontainer exec --workspace-folder . python --version

# Prebuild and push an image, for a faster first open
devcontainer build \
  --workspace-folder . \
  --push \
  --image-name ghcr.io/your-org/your-repo-devcontainer:latest

The npm package needs Python and a C/C++ compiler to build one of its dependencies. The project also offers a standalone install script that brings its own Node.js runtime, if you prefer that.

The CLI is especially useful in CI. The devcontainers/ci GitHub Action is built on it, so you can run your tests in GitHub Actions in the same container you develop in:

# .github/workflows/test.yml
name: Test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run tests in the dev container
        uses: devcontainers/ci@v0.3
        with:
          imageName: ghcr.io/your-org/your-repo-devcontainer
          push: never
          runCmd: pytest tests/ -v --cov

The same project ships an Azure DevOps task that does the same job.

Variables in devcontainer.json

devcontainer.json supports a set of predefined variables, so one file works on every machine:

VariableValue
${localWorkspaceFolder}Path of the project folder on the host
${containerWorkspaceFolder}Path of the project folder in the container
${localWorkspaceFolderBasename}Name of the project folder on the host
${containerWorkspaceFolderBasename}Name of the project folder in the container
${localEnv:VARIABLE_NAME}Value of an environment variable on the host. Unset variables are left blank.
${containerEnv:VARIABLE_NAME}Value of an environment variable inside the running container. Only in remoteEnv.
${devcontainerId}An ID unique to this dev container and stable across rebuilds
{
  "mounts": [
    {
      "source": "${localWorkspaceFolder}/.cache",
      "target": "/home/vscode/.cache",
      "type": "bind"
    }
  ],
  "remoteEnv": {
    "AWS_ACCESS_KEY_ID": "${localEnv:AWS_ACCESS_KEY_ID}",
    "AWS_SECRET_ACCESS_KEY": "${localEnv:AWS_SECRET_ACCESS_KEY}"
  }
}

This is how you pass secrets from the host into the container without writing them into the repository. If you set a new variable on the host, you may need to restart VS Code before it sees it.

Configuration inside the image

Some devcontainer.json properties can be stored inside the image itself, in a label named devcontainer.metadata:

LABEL devcontainer.metadata='[{"customizations": {"vscode": \
  {"extensions": ["ms-python.python"]}}}]'

When a tool creates a container from such an image, it merges this metadata with the local devcontainer.json. The image then describes itself: a short devcontainer.json that names a prebuilt image gets the right extensions without listing them again. The Dev Container CLI adds this label for you when it prebuilds an image, including the metadata of every Feature it installed.

GitHub Codespaces

GitHub Codespaces builds its cloud environments from the same devcontainer.json. A configuration that works locally in VS Code works in Codespaces too.

{
  // Minimum machine size, for cloud services
  "hostRequirements": {
    "cpus": 4,
    "memory": "8gb",
    "storage": "32gb"
  },
  "portsAttributes": {
    "8000": {
      "label": "Web App",
      "onAutoForward": "openBrowser"
    }
  }
}

hostRequirements is part of the general specification, not something specific to Codespaces. It states the minimum CPUs, memory and storage. Cloud services such as Codespaces use it to pick a suitable machine, and other tools show a warning when the machine falls short. portsAttributes works the same everywhere; here it opens the browser when port 8000 is forwarded.

Prebuilding images for speed

The most common complaint about dev containers is the first open: building the image can take several minutes. The answer is to build it ahead of time.

# .github/workflows/prebuild.yml
name: Prebuild dev container
on:
  push:
    branches: [main]
    paths: ['.devcontainer/**']

permissions:
  contents: read
  packages: write

jobs:
  prebuild:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Prebuild and push
        uses: devcontainers/ci@v0.3
        with:
          imageName: ghcr.io/your-org/your-repo-devcontainer
          cacheFrom: ghcr.io/your-org/your-repo-devcontainer
          push: always

The action can only push after the workflow has logged in to the registry, hence the login step and the packages: write permission. Without imageTag, the action tags the image latest.

// .devcontainer/devcontainer.json
{
  // Use the prebuilt image
  "image": "ghcr.io/your-org/your-repo-devcontainer:latest",

  // Lifecycle scripts still run when the container is created
  "postCreateCommand": "pip install --user -r requirements.txt"
}

With this setup, opening the dev container pulls the prebuilt image from the registry instead of building it, so the first open costs an image pull rather than a full build.

Supporting tools: not only VS Code

The Dev Container specification is open, and any editor or service can implement it.

ToolSupport
VS CodeThe Dev Containers extension
GitHub CodespacesCloud environments built from devcontainer.json
JetBrains IDEsIntelliJ IDEA has early support, locally with Docker or remotely over SSH
CursorSupported since version 0.22.0 (January 2024)
Ona (formerly Gitpod)Follows the specification and uses devcontainer.json
DevPodClient-only tool that creates environments from devcontainer.json on any backend
GitHub ActionsThe devcontainers/ci action
Azure DevOpsThe devcontainers/ci Azure DevOps task
Dev Container CLIReference implementation, independent of any editor

Templates: start in seconds

If you do not have a devcontainer.json yet, you do not have to write one from scratch. VS Code offers a gallery of Templates:

  1. Open the Command Palette and run Dev Containers: Add Dev Container Configuration Files...
  2. Choose a Template (Python, Node, Go, Rust, Java and others).
  3. Select any extra Features.
  4. VS Code writes devcontainer.json, and a Dockerfile or Compose file if the Template uses one.

Common patterns and tips

Keep shell history across rebuilds

Store the history in a named volume. Using ${devcontainerId} in the volume name gives each dev container its own volume.

// .devcontainer/devcontainer.json
{
  "build": { "dockerfile": "Dockerfile" },
  "mounts": [
    {
      "source": "bashhistory-${devcontainerId}",
      "target": "/commandhistory",
      "type": "volume"
    }
  ],
  "remoteUser": "vscode"
}

A new volume belongs to root, so the Dockerfile creates the folder, hands it to the non-root user and points Bash at it:

# .devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/python:3.12
ARG USERNAME=vscode
RUN SNIPPET="export PROMPT_COMMAND='history -a' \
      && export HISTFILE=/commandhistory/.bash_history" \
    && mkdir /commandhistory \
    && touch /commandhistory/.bash_history \
    && chown -R $USERNAME /commandhistory \
    && echo "$SNIPPET" >> "/home/$USERNAME/.bashrc"

Dotfiles

VS Code can clone your personal dotfiles repository (.bashrc, .gitconfig and so on) into every dev container it creates and run its install script. These are VS Code user settings, not devcontainer.json properties, because they belong to you and not to the project:

// VS Code user settings (not devcontainer.json)
{
  "dotfiles.repository": "your-github-id/your-dotfiles-repo",
  "dotfiles.targetPath": "~/dotfiles",
  "dotfiles.installCommand": "install.sh"
}

Run as a non-root user

The Microsoft dev container images come with a non-root user: vscode in images such as base, python, go, java and rust, and node in the javascript-node image. Prefer it over root:

{
  "remoteUser": "vscode"
}

Without remoteUser, the tool runs as whatever user the container runs as, which is often root.

Dev containers and the alternatives

virtualenv or venv. A Python virtual environment isolates Python packages and nothing else. You still need the right Python version, system libraries and tools on the host. A dev container isolates all of it, down to the operating system packages.

Plain Docker. docker run gives you a container, not a development workflow: no editor integration, no extension management, no lifecycle scripts. Dev containers are the layer that turns a container into a full editor setup.

Vagrant. Vagrant provisions virtual machines, which are heavier and slower to start than containers, because each one boots its own kernel. Vagrant still makes sense when you need the stronger isolation of a full VM.

Nix and devenv. Nix gives reproducible environments at the package level and pins exact tool versions without containers. The cost is a steeper learning curve and less editor integration out of the box. The two also combine: there is an official Nix Feature (ghcr.io/devcontainers/features/nix) that installs Nix inside a dev container.

GitHub Codespaces on its own. Codespaces is dev containers running in the cloud. Running the same dev containers locally gives you the same environment without a network connection and without cloud costs.

Where to go from here

Closing

Dev containers feel like overhead until the first time a teammate says "I cloned the repository and everything just worked". After that they feel obvious.

The design is small: one JSON file, a clear set of lifecycle commands, modular Features, and editor integrations that do the rest. The work of keeping environments in line across machines, operating systems and CI systems mostly goes away.

Whether you are a solo developer who moves between machines, a team lead who wants predictable onboarding, or a platform engineer who needs reproducible CI environments, dev containers solve a real problem, and they work for one person as well as for a large team.

To try it, open the Command Palette, run "Dev Containers: Add Dev Container Configuration Files...", pick Python, and let VS Code reopen the folder in the container.

Sources