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:
- Your source code stays on the host. It is bind-mounted into the container, so edits show up at once, and rebuilding the container does not lose your work.
- Tools run inside the container. Python, Node, compilers, linters and databases live there. Nothing gets installed on the host.
- The editor runs on the host and connects to the container. VS Code's window runs locally, while terminals, the debugger and most extensions run inside the container. UI extensions such as themes stay on the local side.
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:
.devcontainer/devcontainer.json.devcontainer.jsonin the project root.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:
- Self-contained. It is a folder with at least a
devcontainer-feature.jsonmetadata file and aninstall.shscript. - Distributed as an OCI artifact. Tools pull it from a container registry such as the GitHub Container Registry (
ghcr.io). - Versioned with semver. Each release is tagged with its major, minor and full version, so you can pin
:1,:1.2or:1.2.3. - Composable. Add as many as you need. They are installed one after another, in an order the tool works out. A Feature can declare
dependsOnorinstallsAfter, and you can force an order withoverrideFeatureInstallOrderindevcontainer.json.
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:
| Command | Runs on | When |
|---|---|---|
initializeCommand | Host | During 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. |
onCreateCommand | Container | First 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. |
updateContentCommand | Container | Second 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. |
postCreateCommand | Container | Last 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. |
postStartCommand | Container | Every time the container starts. |
postAttachCommand | Container | Every 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
| Task | Best place | Why |
|---|---|---|
| Check a host prerequisite | initializeCommand | Runs on the host, before the container is created |
| Install system packages | Dockerfile or Features | Cached in an image layer |
pip install -r requirements.txt | updateContentCommand or postCreateCommand | Needs the workspace files |
npm install | updateContentCommand or postCreateCommand | Needs the workspace files |
| Database migrations | postCreateCommand | One-time setup |
| Start a dev server | postStartCommand | Has to start again with the container |
| Start background workers | postStartCommand | Every container start |
| Print a welcome message | postAttachCommand | Every 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:
- VS Code finds
.devcontainer/devcontainer.jsonand offers to reopen the folder in a container. - Docker pulls the Python image and starts Postgres and Redis.
updateContentCommandinstalls the dependencies, thenpostCreateCommandinstalls the Git hooks and runs the migrations.- VS Code installs the listed extensions inside the container.
- 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:
| Variable | Value |
|---|---|
${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.
| Tool | Support |
|---|---|
| VS Code | The Dev Containers extension |
| GitHub Codespaces | Cloud environments built from devcontainer.json |
| JetBrains IDEs | IntelliJ IDEA has early support, locally with Docker or remotely over SSH |
| Cursor | Supported since version 0.22.0 (January 2024) |
| Ona (formerly Gitpod) | Follows the specification and uses devcontainer.json |
| DevPod | Client-only tool that creates environments from devcontainer.json on any backend |
| GitHub Actions | The devcontainers/ci action |
| Azure DevOps | The devcontainers/ci Azure DevOps task |
| Dev Container CLI | Reference 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:
- Open the Command Palette and run Dev Containers: Add Dev Container Configuration Files...
- Choose a Template (Python, Node, Go, Rust, Java and others).
- Select any extra Features.
- 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
- containers.dev: the specification site, with the reference, the Feature gallery and the Templates.
- devcontainer.json reference: every property, documented.
- Available Features: a searchable list of Features.
- Available Templates: starting configurations for common stacks.
- VS Code Dev Containers documentation: the VS Code guide.
- Dev Container CLI: the reference implementation.
- devcontainers/ci: the GitHub Action and the Azure DevOps task.
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
- Development Containers, containers.dev, Overview and Supporting tools and services
- Development Containers, Dev Container specification and devcontainer.json reference
- Development Containers, Features specification and Features distribution
- GitHub, devcontainers/features (python, node, docker-in-docker, git, github-cli and nix Features)
- GitHub, devcontainers/images, including the Python image README
- GitHub, devcontainers/cli and devcontainers/ci with its GitHub Action documentation
- Visual Studio Code, Developing inside a Container and Persist bash history
- Docker, Compose file reference: version and name top-level elements
- Cursor, changelog 0.22.0: Dev Containers (6 January 2024)