Dev containers best practices help teams turn local setup from a fragile checklist into a reproducible, versioned development environment. Instead of asking every developer to install the same runtime, tools, editor extensions, database clients, and environment variables manually, a project can define those pieces in a .devcontainer configuration that supported editors and cloud environments can open consistently.
This tutorial walks through practical patterns for team projects: when to use dev containers, how to choose images, how to structure devcontainer.json, where to put secrets, how to speed up builds, and which mistakes commonly create friction.
1. What Dev Containers Are and When to Use Them
Dev Containers, officially called Development Containers, are a specification for defining reproducible development environments. According to the provided source data, Visual Studio Code, JetBrains IDEs, and GitHub Codespaces support this standard. When a developer opens a repository with a Dev Container configuration, the editor can build or connect to the containerized environment automatically.
At the center of the setup is the devcontainer.json file. It tells the editor how to access or construct the development container, which tools and runtimes should be available, which ports should be forwarded, and which editor extensions or settings should be applied.
Dev containers are most useful when the team wants the same runtime, dependencies, editor behavior, and supporting services across different developer machines.
A typical project structure looks like this:
project/
├── .devcontainer/
│ ├── devcontainer.json
│ ├── Dockerfile
│ └── docker-compose.yml
├── src/
└── package.json
When Dev Containers Are a Good Fit
Use dev containers when your team needs to standardize setup across contributors, operating systems, or project types.
Common use cases from the source data include:
- Team onboarding: New developers can clone a repository, open it in a supported editor, and work inside a prepared environment instead of spending days configuring packages and tools.
- Runtime consistency: Teams can define exact development runtimes, such as a Node.js image, Python tooling, Ansible modules, or database clients.
- Complex local stacks: Applications that require services like PostgreSQL and Redis can use Docker Compose to run the app, database, and cache together.
- Editor standardization: VS Code extensions, language servers, formatters, debug configurations, and settings can be installed inside the container.
- Remote development: Docker can be installed locally or on a remote environment, and dev containers can also work with Docker-compliant CLIs, although the VS Code documentation notes that other CLIs may work but are not officially supported.
When to Avoid Overusing Them
Dev containers should not become a dumping ground for every tool any developer might want. A DevOps practitioner in the source discussion described them as a common starting point, not a replacement for every personal workflow.
That is one of the most important dev containers best practices for teams: standardize what everyone needs, but leave room for individual preferences.
| Use Dev Containers For | Avoid Using Dev Containers For |
|---|---|
| Shared runtimes and project dependencies | Personal themes and unrelated editor preferences |
| Common extensions and language tools | Every optional tool a power user might install |
| Database clients, CLIs, and project services | Secrets committed into the repository |
| Repeatable onboarding and CI alignment | Replacing all local development options |
2. Choosing a Base Image and Runtime Strategy
Your base image determines what is available before your project-specific setup runs. The source data shows three common strategies: using a prebuilt image, extending a base image with a custom Dockerfile, or orchestrating multiple services with Docker Compose.
Option A: Use a Prebuilt Dev Container Image
For a Node.js project, the source data uses this image:
{
"name": "Node.js Development",
"image": "mcr.microsoft.com/devcontainers/javascript-node:18"
}
This approach works well when the base image already includes the core runtime and common development tools your team needs.
Best for:
- Simple projects: The runtime image already covers most needs.
- Fast adoption: Teams can start with minimal configuration.
- Lower maintenance: Fewer custom image layers to manage.
Option B: Extend the Image with a Dockerfile
When the base image does not include all required tools, add a Dockerfile under .devcontainer.
Example from the source data:
# .devcontainer/Dockerfile
FROM mcr.microsoft.com/devcontainers/javascript-node:18
RUN apt-get update && apt-get install -y \
postgresql-client \
redis-tools \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g typescript ts-node nodemon
RUN mkdir -p /home/node/.vscode-server/extensions
WORKDIR /workspace
Then reference it from devcontainer.json:
{
"name": "Custom Node.js Environment",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"postCreateCommand": "npm install",
"remoteUser": "node"
}
The source example also notes that combining package installation commands reduces image layers, and creating a VS Code extensions cache directory can help with rebuild behavior.
Option C: Use Docker Compose for Multi-Container Development
For applications that need databases, caches, or other services, use Docker Compose.
The source data includes a full-stack example with:
- app service
- PostgreSQL 15
- Redis 7 Alpine
- Named volumes for VS Code extensions and database persistence
- A shared Docker network
# .devcontainer/docker-compose.yml
version: '3.8'
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ..:/workspace:cached
- vscode-extensions:/home/node/.vscode-server/extensions
command: sleep infinity
networks:
- devnet
postgres:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_USER: devuser
POSTGRES_PASSWORD: devpass
POSTGRES_DB: appdb
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- devnet
redis:
image: redis:7-alpine
restart: unless-stopped
networks:
- devnet
volumes:
vscode-extensions:
postgres-data:
networks:
devnet:
And the matching devcontainer.json:
{
"name": "Full Stack Development",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"containerEnv": {
"DATABASE_URL": "postgresql://devuser:devpass@postgres:5432/appdb",
"REDIS_URL": "redis://redis:6379"
},
"postCreateCommand": "npm install && npm run db:migrate",
"remoteUser": "node"
}
Base Image Strategy Comparison
| Strategy | What It Uses | Best Use Case | Trade-Off |
|---|---|---|---|
| Prebuilt image | image in devcontainer.json |
Simple projects with common runtime needs | Less control over installed packages |
| Custom Dockerfile | build.dockerfile |
Projects needing system packages or global tools | More image maintenance |
| Docker Compose | dockerComposeFile and service |
Apps needing databases, caches, or multiple services | More moving parts to configure |
A useful rule from the source discussion: the dev container Dockerfile is not necessarily your production Dockerfile. They can be similar, but they serve different purposes.
3. Structuring devcontainer.json for Team Projects
The devcontainer.json file should be treated as team infrastructure. It defines the development experience in source control, so changes should be reviewed with the same care as build scripts or CI configuration.
A practical team configuration includes:
- Name: The display name shown by the editor.
- Image or build: Either a prebuilt image or a custom Dockerfile.
- Features: Modular tools added without editing the Dockerfile.
- Customizations: Editor extensions, settings, launch configurations, and AI chat instructions where supported.
- Lifecycle commands: Setup steps that run after creation, start, or attach.
- Ports: Application and service ports forwarded from container to host.
- User: A non-root user where supported by the image.
Example configuration from the source data:
{
"name": "Node.js Development",
"image": "mcr.microsoft.com/devcontainers/javascript-node:18",
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
},
"postCreateCommand": "npm install",
"forwardPorts": [3000, 5432],
"remoteUser": "node"
}
Use Lifecycle Hooks Deliberately
Dev Containers support different lifecycle hooks for different stages. The source data gives these examples:
{
"name": "Project with Lifecycle Hooks",
"image": "mcr.microsoft.com/devcontainers/javascript-node:18",
"postCreateCommand": "git submodule update --init",
"postStartCommand": "npm run dev:services",
"postAttachCommand": "echo 'Welcome to the dev environment!'"
}
| Hook | Runs When | Good For |
|---|---|---|
postCreateCommand |
Once after the container is created | Installing dependencies, initializing submodules |
postStartCommand |
Every time the container starts | Starting background services |
postAttachCommand |
Every time VS Code attaches | User-facing messages or attach-time setup |
A common mistake is putting everything into postCreateCommand. For example, one-time dependency installation belongs there, but recurring service startup belongs in postStartCommand.
Include Debug Configuration for Consistency
If the team debugs the same application entry point, include launch configuration in the container customization.
{
"name": "Debug-Ready Environment",
"image": "mcr.microsoft.com/devcontainers/javascript-node:18",
"customizations": {
"vscode": {
"extensions": ["ms-vscode.js-debug"],
"launch": {
"configurations": [
{
"name": "Debug Server",
"type": "node",
"request": "launch",
"program": "${workspaceFolder}/src/index.js",
"env": {
"DEBUG": "*"
}
}
]
}
}
}
}
This reduces local variance in how developers launch and inspect the app.
4. Managing Extensions, Tools, and Language Servers
One of the strongest dev containers best practices is to install project-critical tooling inside the container, not manually on every developer’s machine.
The source data explains that VS Code extensions installed in the container get access to the container’s tools, platforms, and filesystem. That matters for language servers, linters, formatters, and debuggers because they often need the same runtime and dependencies as the project.
Put Shared Extensions in devcontainer.json
For a JavaScript or TypeScript project, the source data shows extensions such as:
dbaeumer.vscode-eslintesbenp.prettier-vscodebradlc.vscode-tailwindcssms-vscode.js-debugms-azuretools.vscode-docker
Example:
{
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"bradlc.vscode-tailwindcss"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
}
}
Add Tools with Features or Dockerfile
The source data shows Features being used for tools such as:
{
"features": {
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {},
"ghcr.io/devcontainers/features/docker-in-docker:2": {}
}
}
Use Features when the tool is available as a reusable module. Use the Dockerfile when you need project-specific system packages or global packages, such as:
RUN apt-get update && apt-get install -y \
postgresql-client \
redis-tools \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g typescript ts-node nodemon
Watch for Missing Linux Dependencies
The VS Code documentation notes that some extensions rely on libraries that may not exist in certain Docker images. If an extension fails inside the container, do not assume the editor is broken; the image may be missing a Linux dependency required by the extension.
A practical response is to either choose a more complete base image or add the required libraries in the Dockerfile.
Configure AI Chat Instructions Where Supported
The VS Code Dev Containers documentation also describes custom instructions for AI chat responses. At the time of writing, teams can provide context about the languages or toolchains installed in the dev container by:
- Adding instructions: Put
github.copilot.chat.codeGeneration.instructionsdirectly indevcontainer.json. - Using dev container resources: Some published images and Features include custom instructions.
- Using a file: Add
copilot-instructions.mdas you would locally.
This is useful when generated suggestions should align with the container’s actual toolchain.
5. Handling Secrets, Environment Variables, and Credentials
Secrets are where dev container convenience can become risky. The source data is clear on a practical pattern: keep non-sensitive defaults in source-controlled configuration, and keep sensitive values in a local, gitignored environment file.
Example:
{
"name": "Secure Development",
"image": "mcr.microsoft.com/devcontainers/javascript-node:18",
"runArgs": ["--env-file", ".devcontainer/.env.local"],
"containerEnv": {
"NODE_ENV": "development",
"LOG_LEVEL": "debug"
}
}
Then provide a template:
# .devcontainer/.env.local.example
# Copy this file to .env.local and fill in your values
API_KEY=your-api-key-here
AWS_ACCESS_KEY_ID=your-aws-key
AWS_SECRET_ACCESS_KEY=your-aws-secret
Secret Handling Pattern
| Configuration Type | Where to Put It | Example |
|---|---|---|
| Non-sensitive defaults | containerEnv in devcontainer.json |
NODE_ENV=development, LOG_LEVEL=debug |
| Sensitive values | Local env file excluded from Git | API keys, access keys, secrets |
| Team documentation | Example file committed to repo | .env.local.example |
| Service URLs for local Compose | containerEnv when not sensitive |
DATABASE_URL, REDIS_URL for local services |
Do not commit real tokens, access keys, or personal credentials into
.devcontainerfiles. Commit templates and defaults; keep actual secrets local.
Git and SSH Credentials
The VS Code tips source includes several Git-related warnings:
- Avoid setting up Git in a container when using Docker Compose: The documentation points teams toward sharing Git credentials with the container instead.
- SSH passphrase issue: If a repository was cloned using SSH and the SSH key has a passphrase, VS Code pull and sync features may hang when running remotely.
- Workarounds: Use an SSH key without a passphrase, clone using HTTPS, or run
git pushfrom the command line.
The source discussion also mentions that local configurations such as SSH, Git configuration, or shell configuration can be brought into the dev container through mounts. If you use mounts for developer-specific files, document them clearly so new contributors know what is expected.
6. Improving Build Speed and Container Startup Time
Build speed matters because slow rebuilds reduce adoption. The source data suggests several concrete strategies: prebuild images, cache dependencies, persist editor extensions, stop unused containers, and allocate more Docker Desktop resources when needed.
Prebuild and Pull Images
For team projects, one source recommends prebuilding the image in CI and publishing it to a registry so developers can pull it instead of building locally.
{
"name": "Optimized Build",
"build": {
"dockerfile": "Dockerfile",
"context": "..",
"cacheFrom": "ghcr.io/yourorg/devcontainer-cache:latest"
},
"initializeCommand": "docker pull ghcr.io/yourorg/devcontainer:latest || true"
}
The exact registry path in the example is illustrative from the source data. Use your organization’s actual registry if you adopt this pattern.
Persist Extension and Data Volumes
The Docker Compose example persists VS Code extensions and PostgreSQL data:
volumes:
vscode-extensions:
postgres-data:
And mounts them into services:
volumes:
- vscode-extensions:/home/node/.vscode-server/extensions
- postgres-data:/var/lib/postgresql/data
This helps avoid redoing work after every container restart or rebuild.
Allocate Docker Desktop Resources Carefully
The VS Code documentation notes that Docker Desktop gives containers only a fraction of machine capacity by default. In most cases that is enough, but if a container needs more capacity, teams can increase CPU, memory, swap, or disk allocation.
Before changing resource settings:
- Stop unused containers: Running containers consume resources.
- Check whether CPU is actually the issue: The documentation suggests installing the Resource Monitor extension, which shows container capacity in the VS Code status bar.
- Increase resources if needed: In Docker Desktop, use Settings / Preferences and go to Advanced to increase CPU, memory, or swap. On macOS, disk allocation is under Disk; on Windows, it is under Advanced.
To install Resource Monitor by default:
{
"dev.containers.defaultExtensions": [
"mutantdino.resourcemonitor"
]
}
Clean Up Containers and Images
If Docker reports that it is out of disk space, the VS Code documentation recommends cleaning unused containers and images.
Useful Docker CLI commands include:
docker ps -a
docker rm <Container ID>
docker image prune
You can also remove containers through VS Code Remote Explorer or use the Container Tools extension to remove containers and images from the Container Explorer.
Speed Checklist
| Problem | Source-Grounded Response |
|---|---|
| Slow local builds | Prebuild image in CI and pull it before local build |
| Reinstalling extensions repeatedly | Persist /home/node/.vscode-server/extensions as a volume |
| Docker disk space errors | Remove unused containers and run docker image prune |
| Poor container responsiveness | Stop unused containers, check Resource Monitor, then adjust Docker Desktop resources |
| Disk-intensive operations feel slow | Review Docker Desktop disk performance settings; VS Code defaults favor convenience and broad support |
7. Integrating Dev Containers with CI and Remote Development
Dev containers are most valuable when they align local development with the rest of the engineering workflow. The source data recommends testing in CI and, where useful, running CI inside the same container to reduce drift between development and continuous integration.
Use the Same Container Concept in CI
The team development source explicitly recommends:
- Test in CI: Run your CI pipeline inside the same container to ensure consistency between development and continuous integration.
- Version images: Tag images with versions so teams can roll back if a new image breaks the workflow.
- Prebuild images: Publish images to a registry so developers can pull them rather than building everything locally.
This does not mean every production image must match the dev image exactly. The source discussion makes an important distinction: development and production Dockerfiles can be similar, but they serve different purposes.
| Environment | Goal | Container Guidance |
|---|---|---|
| Local dev container | Give developers tools, editors, debuggers, and dependencies | Include development tools and editor integrations |
| CI container | Validate code in a consistent environment | Reuse or align with dev container where practical |
| Production image | Run the application in production | Keep focused on production runtime needs |
Remote Docker and Remote Development
The VS Code documentation lists several supported or partially supported ways to use Docker with Dev Containers:
- Docker installed locally
- Docker installed on a remote environment
- Other Docker-compliant CLIs, locally or remotely, though the docs note these may work but are not officially supported
- Kubernetes attachment, which only requires a properly configured
kubectlCLI
The source discussion also describes using a local VS Code instance to connect to a remote Docker engine over SSH. This can be useful for development servers or cloud instances, especially when local hardware is not ideal for the project.
Editor and Platform Support
The source data states that Visual Studio Code, JetBrains IDEs, and GitHub Codespaces support the Dev Containers standard. The Dev Containers extension also supports the open Dev Containers Specification, which is intended to make development environment configuration more portable across tools and platforms.
At the time of writing, the most detailed configuration examples in the provided source data are VS Code-oriented, especially around customizations.vscode, extensions, launch configuration, and the Dev Containers extension.
8. Common Dev Container Mistakes to Avoid
This section turns the research into a practical checklist of dev containers best practices your team can apply during setup and review.
Mistake 1: Over-Engineering the First Version
Start simple. The team development source explicitly recommends beginning with a basic configuration and adding complexity as needed.
A good first version might include:
- Base image: A language runtime image such as
mcr.microsoft.com/devcontainers/javascript-node:18 - Extensions: Linter, formatter, and debugger
- Lifecycle command: Dependency install command such as
npm install - Ports: Only the ports the app actually uses
- User: A non-root user such as
node, where supported
Avoid adding Docker Compose, Docker-in-Docker, custom registries, multiple volumes, and lifecycle hooks before the project needs them.
Mistake 2: Treating Dev Containers as Mandatory for Everyone
The source data recommends providing escape hatches. Some team members may prefer local development, so keep the project buildable outside containers too.
That does not weaken the dev container strategy. It makes adoption smoother because dev containers become the reliable default rather than a rigid requirement.
Mistake 3: Putting Secrets in Source Control
Do not put real tokens in devcontainer.json, Dockerfile, or docker-compose.yml**. Use local env files referenced by **runArgs`, and commit an example file instead.
{
"runArgs": ["--env-file", ".devcontainer/.env.local"]
}
Mistake 4: Using Docker Compose When Simple Mounts Would Do
Docker Compose is useful for multi-container applications. But if you only need to bring in local SSH or Git configuration, the source discussion suggests using dev container mounts rather than defaulting to Compose.
Use Compose when you need services like:
- PostgreSQL
- Redis
- An app container plus supporting services
Use simpler dev container configuration when you only need a single development container.
Mistake 5: Ignoring Windows File Sharing and Line Endings
The VS Code documentation highlights several Windows-specific issues.
For Docker Desktop on Windows:
- Use WSL 2 backend where applicable: It can open folders inside WSL and locally, shares containers between Windows and WSL, and is less susceptible to file sharing issues.
- Use Linux containers: The Dev Containers extension currently supports Linux containers.
- Check firewall rules: Docker may need to set up a shared drive.
- Enable file sharing: If the source folder is not shared with Docker, the container may start but the workspace will be empty.
For Git line endings, add a .gitattributes file:
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
The VS Code documentation notes this works in Git v2.10+. Alternatively, developers can configure Git globally:
git config --global core.autocrlf input
Or disable line-ending conversion entirely:
git config --global core.autocrlf false
The documentation also notes that you may need to clone the repository again for these settings to take effect.
Mistake 6: Forgetting Docker Desktop Disk and Resource Limits
If developers see disk space errors, clean unused containers and images. If containers feel slow, stop unused containers first, then check whether CPU, memory, swap, or disk allocation needs adjustment.
Do not immediately increase all Docker Desktop resources without checking the actual bottleneck.
Mistake 7: Mixing Static and Dynamic Setup Poorly
A practical pattern from the source discussion is:
| Put This In | Use It For |
|---|---|
| Dockerfile | Static environment pieces: system packages, global tools, base setup |
| devcontainer.json | Dynamic and editor-specific pieces: environment variables, extensions, lifecycle commands |
| Docker Compose | Multi-service development stacks |
| Local env files | Developer-specific secrets and credentials |
This separation keeps the setup understandable and easier to maintain.
Bottom Line
The most reliable dev containers best practices are straightforward: start with a simple base image, add only the tools the whole team needs, keep secrets outside source control, use Docker Compose only for multi-service setups, and test the same environment concept in CI.
Dev containers work best as a standardized starting point, not as a place to force every personal workflow. When configured carefully, they reduce onboarding friction, make editor behavior more consistent, and help teams avoid environment-specific bugs without hiding how the project actually runs.
FAQ
What is a dev container?
A dev container is a reproducible development environment defined by configuration files, especially devcontainer.json. Supported tools such as Visual Studio Code, JetBrains IDEs, and GitHub Codespaces can use that configuration to build or connect to a containerized workspace.
What should go in devcontainer.json versus the Dockerfile?
Use devcontainer.json for editor-specific and dynamic configuration, such as extensions, settings, forwarded ports, environment variables, lifecycle commands, and the remote user. Use the Dockerfile for static environment setup, such as system packages, global tools, and the working directory.
Should my dev container Dockerfile be the same as my production Dockerfile?
Not necessarily. The source discussion emphasizes that a dev container Dockerfile and a production Dockerfile serve different purposes. A dev container often includes development tools, debuggers, editor support, and CLIs that may not belong in a production image.
How should teams handle secrets in dev containers?
Keep sensitive values out of source control. The source data recommends using a local env file such as .devcontainer/.env.local via runArgs, while committing an example file like .env.local.example so developers know which values to provide.
When should I use Docker Compose with dev containers?
Use Docker Compose when your local environment needs multiple services, such as an app container plus PostgreSQL and Redis. For a single runtime container, a basic image or Dockerfile configuration is usually simpler.
How can I make dev containers faster?
Use source-grounded techniques: prebuild images in CI and pull them locally, persist extension and database volumes, stop unused containers, clean unused images with docker image prune, and adjust Docker Desktop CPU, memory, swap, or disk allocation only after checking resource usage.










