What Are VSCode Dev Containers?
Visual Studio Code Dev Containers allow you to use Docker containers as full-featured development environments. Instead of installing dependencies directly on your local machine, you define a container configuration that VSCode uses to spin up an isolated, reproducible environment. Your workspace files are mounted into the container, and you work inside it as if it were your local filesystem—because from VSCode's perspective, it essentially is.
The magic happens through two core components: the .devcontainer/devcontainer.json configuration file and the Remote — Containers extension (now part of the Remote Development extension pack). Together, they let you specify a Docker image or Dockerfile, forward ports, mount volumes, set environment variables, install extensions, and even run post-creation scripts—all automatically when you open a project in a container.
Why Dev Containers Matter
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Dev containers solve a set of persistent problems that plague development teams:
- Consistency across machines: Every developer gets the exact same toolchain, libraries, and runtime versions, eliminating "works on my machine" issues.
- Onboarding speed: New team members can start coding within minutes instead of spending days configuring their local environment.
- Isolation: Projects with conflicting dependencies (e.g., two Node.js projects requiring different versions) can run side by side without polluting each other or the host system.
- Production parity: Since containers can mirror production deployments, you catch environment-specific bugs early.
- Security: Untrusted code or dependencies run in a sandboxed container, reducing the risk to your host OS.
- Cloud-native CI/CD: The same dev container configuration can be used in GitHub Codespaces, CI pipelines, and other container-based environments.
Setting Up Your First Dev Container
Prerequisites
Before diving in, ensure you have the following installed:
- Docker Desktop (or an equivalent Docker runtime like Rancher Desktop or OrbStack) running on your machine.
- Visual Studio Code with the Remote — Containers extension installed (search for
ms-vscode-remote.remote-containersin the extensions marketplace). - A project folder you want to containerize.
Step 1: Create the Dev Container Configuration
Open your project in VSCode, then press Ctrl+Shift+P (or Cmd+Shift+P on macOS) and run the command:
Remote-Containers: Add Development Container Configuration Files...
VSCode will prompt you to select a base configuration from a list of predefined templates (Node.js, Python, Go, Rust, etc.). Choose the one that matches your stack. This generates a .devcontainer folder with at least a devcontainer.json file and possibly a Dockerfile.
Step 2: Understand devcontainer.json
Here's a typical devcontainer.json for a Node.js project using a predefined image:
{
"name": "my-node-app",
"image": "mcr.microsoft.com/devcontainers/javascript-node:1-20-bullseye",
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"forwardPorts": [3000, 9229],
"postCreateCommand": "npm ci",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
},
"mounts": [
"source=${localEnv:HOME}/.npmrc,target=/home/node/.npmrc,type=bind,consistency=cached"
],
"remoteUser": "node"
}
Let's break down the key fields:
- name: A human-readable name displayed in the VSCode UI and terminal.
- image: A pre-built Docker image to use. Microsoft maintains a rich library of dev container images for various stacks.
- features: Modular additions that layer extra capabilities onto your base image (like Docker-in-Docker or the GitHub CLI). Features are self-contained install scripts sourced from GitHub repositories.
- forwardPorts: Ports on the container that should be forwarded to the host. VSCode handles the port mapping automatically.
- postCreateCommand: A command that runs after the container is created and the workspace is mounted. Ideal for installing dependencies.
- customizations.vscode.extensions: Extensions that VSCode should install inside the container. This ensures every developer has the same tooling.
- customizations.vscode.settings: Project-specific VSCode settings applied only when working inside the container.
- mounts: Bind mounts for sharing host files (like credentials or config files) into the container.
- remoteUser: The user account inside the container that VSCode should run as.
Step 3: Using a Custom Dockerfile
If the predefined images don't fit your needs, you can use a custom Dockerfile. Reference it in devcontainer.json like this:
{
"name": "custom-python-env",
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"args": {
"PYTHON_VERSION": "3.12"
}
},
"features": {},
"forwardPorts": [],
"postCreateCommand": "pip install -r requirements.txt",
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance"
]
}
}
}
And here's the corresponding Dockerfile:
ARG PYTHON_VERSION=3.12
FROM python:${PYTHON_VERSION}-slim-bullseye
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
git \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user
ARG USERNAME=vscode
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN groupadd --gid $USER_GID $USERNAME \
&& useradd --uid $USER_UID --gid $USER_GID -m $USERNAME \
&& apt-get update && apt-get install -y sudo \
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME
# Switch to non-root user
USER $USERNAME
WORKDIR /workspace
Step 4: Open the Project in the Container
With the configuration files in place, press Ctrl+Shift+P and run:
Remote-Containers: Reopen in Container
Alternatively, VSCode may detect the .devcontainer folder and offer a notification: "Folder contains a Dev Container configuration file. Reopen in Container?"
The first build will take a few minutes as Docker pulls images and runs setup commands. Subsequent rebuilds are much faster thanks to Docker layer caching.
Best Practices for Dev Containers
1. Keep Images Lean and Purpose-Built
A bloated image slows down container startup and consumes unnecessary disk space. Start from a slim base image and add only what your project needs. If you find yourself installing the same system packages repeatedly across projects, consider building a shared base image that your Dockerfiles inherit from.
# Good: slim image with targeted dependencies
FROM python:3.12-slim-bullseye
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Avoid: full-fat image with unnecessary packages
FROM ubuntu:22.04 # 600MB+ vs 120MB for slim variants
2. Leverage Features Instead of Embedding Everything
Dev container Features are composable, shareable units of configuration. Instead of baking Docker-in-Docker, the Azure CLI, or Node.js into your Dockerfile manually, use official or community-maintained Features. This keeps your Dockerfile simple and lets you mix and match capabilities declaratively.
{
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {},
"ghcr.io/devcontainers/features/azure-cli:1": {
"version": "latest"
},
"ghcr.io/devcontainers/features/terraform:1": {
"version": "1.7"
}
}
}
3. Distinguish Between postCreateCommand and postStartCommand
The postCreateCommand runs once when the container is first created. Use it for one-time setup like installing npm packages or setting up a Python virtual environment. The postStartCommand runs every time the container starts. Use it for tasks like starting a development server or database.
{
"postCreateCommand": "npm ci && npx husky install",
"postStartCommand": "npm run dev"
}
4. Use Docker Compose for Multi-Service Scenarios
When your project requires a database, cache, or other services, Docker Compose is the way to go. Reference a docker-compose.yml file in your devcontainer.json:
{
"name": "fullstack-app",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"forwardPorts": [3000, 5432],
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"ms-azuretools.vscode-docker"
]
}
}
}
And the corresponding docker-compose.yml:
version: '3.8'
services:
app:
build:
context: .
dockerfile: Dockerfile
volumes:
- ..:/workspace:cached
command: sleep infinity
depends_on:
- db
environment:
DATABASE_URL: postgres://user:password@db:5432/mydb
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: mydb
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
The command: sleep infinity trick keeps the container running without doing anything, allowing VSCode to attach to it as the development environment.
5. Commit .devcontainer to Version Control
Always commit your .devcontainer directory to the repository. It's part of the project's infrastructure, just like package.json or Makefile. Use a .dockerignore file to exclude node_modules, .git, and other unnecessary artifacts from the Docker build context to speed up image builds.
# .dockerignore
node_modules
.git
*.log
.DS_Store
dist
build
coverage
6. Handle Secrets Securely
Never hardcode secrets in your Dockerfile or devcontainer.json. Instead, pass them at build time via build arguments (with caution—they remain in image layers) or mount them at runtime using bind mounts or environment variables sourced from the host.
{
"mounts": [
"source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached",
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
],
"containerEnv": {
"NPM_TOKEN": "${localEnv:NPM_TOKEN}"
}
}
7. Optimize for Cross-Platform Compatibility
Developers on macOS, Windows, and Linux should all have a smooth experience. Avoid hardcoding paths and use ${localEnv} variables for host-specific configuration. Test your dev container on different operating systems periodically. Be mindful of line endings and file permissions, especially when mounting volumes on Windows with WSL2.
8. Keep Extension Configurations Inside devcontainer.json
Specify extensions and settings in devcontainer.json rather than relying on developers to install them manually. This guarantees consistent formatting, linting, and debugging configurations across the team.
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"eamodio.gitlens",
"streetsidesoftware.code-spell-checker"
],
"settings": {
"editor.formatOnSave": true,
"editor.rulers": [80, 120],
"files.trimTrailingWhitespace": true
}
}
}
Common Pitfalls and How to Avoid Them
Pitfall 1: Slow Container Rebuilds
The Problem: Every time you change something in your Dockerfile or devcontainer.json, VSCode rebuilds the entire image from scratch, which can take several minutes.
The Solution: Structure your Dockerfile to maximize layer caching. Put frequently-changing steps (like copying source code) near the end, and stable steps (like installing system packages) near the beginning. Use .dockerignore to prevent the build context from ballooning. Consider using a pre-built base image for rarely-changing dependencies.
# Order matters for caching: stable layers first
FROM node:20-slim
# Layer 1: System packages (rarely changes)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl git && rm -rf /var/lib/apt/lists/*
# Layer 2: Global tools (changes occasionally)
RUN npm install -g pnpm@latest
# Layer 3: Project dependencies (changes with package.json)
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# Layer 4: Source code (changes frequently)
COPY . .
Pitfall 2: File Permission Issues on Linux Hosts
The Problem: When the container runs as a non-root user (e.g., UID 1000) but the host user has a different UID, files created inside the container may have incorrect ownership on the host, leading to permission errors.
The Solution: Use the updateRemoteUserUID feature or set the container user's UID to match the host user at build time. Alternatively, use the remoteUser field and ensure your Dockerfile creates a user with a configurable UID.
{
"features": {
"ghcr.io/devcontainers/features/common-utils:2": {
"username": "vscode",
"userUid": "1000",
"userGid": "1000",
"upgradePackages": "true"
}
}
}
Pitfall 3: Port Already in Use
The Problem: The container tries to forward a port that's already bound on the host, causing a conflict.
The Solution: Use forwardPorts in devcontainer.json and let VSCode handle port mapping dynamically. If a port is taken, VSCode will pick an alternative and display it in the Ports view. You can also specify an alternative port in the container configuration.
{
"forwardPorts": [3000],
"portsAttributes": {
"3000": {
"label": "App Dev Server",
"onAutoForward": "notify"
}
}
}
Pitfall 4: Docker-in-Docker Bind Mount Woes
The Problem: When using Docker inside a dev container (via the docker-in-docker or docker-from-docker feature), volume paths can become confusing. The inner Docker daemon sees paths relative to the container, not the host.
The Solution: Prefer the docker-from-docker feature, which shares the host's Docker socket rather than running a separate daemon. If you must use docker-in-docker, be explicit about volume paths and prefer relative paths within the workspace.
{
"features": {
// Shares host Docker socket - fewer path issues
"ghcr.io/devcontainers/features/docker-from-docker:1": {}
}
}
Pitfall 5: Large Workspace Folders Degrade Performance
The Problem: Mounting a massive workspace (tens of thousands of files, large node_modules directories) into a container can slow down file operations, especially on macOS where bind mount performance is noticeably slower.
The Solution: Use .dockerignore to exclude unnecessary files from the build context. For runtime, consider using Docker Compose volumes or named volumes for directories that don't need live host synchronization (like node_modules). Some teams use a volume for node_modules while bind-mounting only the source code.
# In docker-compose.yml
services:
app:
volumes:
- ..:/workspace:cached
- node_modules:/workspace/node_modules # Named volume overrides bind mount
volumes:
node_modules:
Pitfall 6: Ignoring the Host Environment Completely
The Problem: Developers treat the dev container as a black box and lose access to host tools like custom shell aliases, SSH agent forwarding, or local Git configurations.
The Solution: Use mount directives to share SSH and Git configurations. Enable SSH agent forwarding in devcontainer.json. This allows seamless Git operations inside the container while leveraging host credentials.
{
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind",
"source=${localEnv:HOME}/.gitconfig,target=/home/vscode/.gitconfig,type=bind"
],
"initializeCommand": "ssh-add -l || ssh-add",
"remoteEnv": {
"SSH_AUTH_SOCK": "/tmp/ssh-agent.sock"
}
}
Pitfall 7: Overlooking postCreateCommand Failures
The Problem: If postCreateCommand fails (e.g., due to a network issue or missing dependency), the container still starts, leaving developers with a partially-configured environment. There's no clear indication that something went wrong unless they check the logs.
The Solution: Make your post-create scripts idempotent and include error handling. Use set -e in shell scripts to fail fast. Consider adding a health check script that verifies the environment is ready, and log the output to a file for easy inspection.
{
"postCreateCommand": "bash .devcontainer/post-create.sh"
}
#!/bin/bash
# .devcontainer/post-create.sh
set -e
echo "Running post-create setup..."
npm ci
npx playwright install --with-deps
echo "Setup complete! Environment is ready."
Advanced Techniques
Multi-Container Dev Environments
For complex microservice projects, you can define multiple services in a single dev container setup using Docker Compose. Each service gets its own container, and VSCode attaches to the primary service while others run in the background.
version: '3.8'
services:
api:
build:
context: ./api
dockerfile: Dockerfile
volumes:
- ./api:/workspace:cached
command: sleep infinity
depends_on:
- db
- redis
worker:
build:
context: ./worker
dockerfile: Dockerfile
volumes:
- ./worker:/workspace:cached
command: sleep infinity
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev_password
redis:
image: redis:7-alpine
You can switch between services using the command palette: Remote-Containers: Switch Container.
Using Dev Container Templates for Team Standardization
Create a repository of shared dev container templates for your organization. Reference them in projects via a URL or a local path. This centralizes updates—when you improve a template, all projects benefit after a rebuild.
{
"image": "ghcr.io/my-org/devcontainer-base:latest",
"features": {
"ghcr.io/my-org/devcontainer-features/custom-linter:1": {}
}
}
CI/CD Integration
Dev containers shine in CI pipelines. Use the devcontainer.json to build and test your application in the exact same environment locally and in CI. Tools like devcontainer CLI (the open-source specification behind the extension) can build and run dev containers outside of VSCode.
# In a GitHub Actions workflow
- name: Build dev container
run: |
npm install -g @devcontainers/cli
devcontainer build --workspace-folder .
- name: Run tests in dev container
run: |
devcontainer exec --workspace-folder . -- npm test
Conclusion
VSCode Dev Containers represent a significant leap forward in development environment management. They transform the age-old problem of environment setup from a multi-day ordeal into a single-click operation, while simultaneously improving consistency, security, and production alignment. By following the best practices outlined here—keeping images lean, leveraging Features, handling secrets securely, committing configurations to version control, and being mindful of common pitfalls like slow rebuilds and permission issues—you can build a development workflow that is both robust and delightful. The key is to treat your dev container configuration as a first-class citizen of your project, evolving it alongside your codebase. Start small with a predefined image, gradually customize as you discover your project's specific needs, and soon you'll wonder how you ever developed without it.