Introduction to Development Containers
One of the greatest challenges in software development is ensuring that all team members work in identical environments. The classic "it works on my machine" problem has plagued development teams for decades. Development containers (also called "devcontainers") solve this problem by providing consistent, reproducible development environments encapsulated in containers.
Development containers combine the power of containers with the convenience of a full-featured development environment. They allow you to define not just your application's runtime environment, but your entire development toolchain—editor configurations, extensions, debugging tools, and more.
Think of a development container like a fully equipped kitchen that moves with you. No matter where you go, you have the exact same tools, ingredients, and setup. When every developer on a team uses the same devcontainer, the entire team works in an identical environment, significantly reducing environment-related bugs and configuration issues.
Benefits of Using Development Containers
Consistency Across Team Members
Everyone on your team works with the same development tools, same versions, same extensions, and same settings. A new team member can be productive within minutes rather than spending days setting up their development environment.
Isolation from Host System
Your development environment is isolated from your local machine. You can have multiple projects with different (even conflicting) dependencies without them interfering with each other or with your host system.
Ready-to-Code Experience
When you open a project with a devcontainer, VS Code automatically builds the container, mounts your project files, and configures the development environment according to specifications. You're immediately ready to code without manual setup steps.
Version-controlled Development Environment
Your development environment configuration lives in your repository, making it part of your project's version control. When the project's dependencies change, everyone gets the updates automatically.
Language-specific Tooling
Different projects might require different language versions or toolchains. With devcontainers, you can tailor the environment specifically for each project's needs.
Simplified Onboarding
New team members can get started with a project immediately. Instead of following pages of setup instructions, they simply clone the repository and open it in VS Code with the Remote Containers extension.
Setting Up VS Code for Development Containers
Before you can use development containers, you need to set up your environment with the necessary tools.
Prerequisites
- VS Code: Download and install Visual Studio Code
- Docker: Install Docker Desktop (Windows/macOS) or Docker Engine (Linux)
- Dev Containers Extension: Install the "Remote - Containers" extension (or "Dev Containers" in newer versions) from the VS Code marketplace
Installing the Dev Containers Extension
- Open VS Code
- Click on the Extensions view icon in the Sidebar or press
Ctrl+Shift+X - Search for "Dev Containers" or "Remote - Containers"
- Click "Install"
Verifying Installation
After installation, you should see a new status bar item in the bottom-left corner of VS Code. It might appear as "><" or as a containers icon. This indicates that the Remote Development extension pack is installed correctly.
You can also verify that Docker is running correctly by opening a terminal and running:
docker --version
docker run hello-world
If both commands complete successfully, your environment is ready for development containers.
Creating a Development Container
There are two main approaches to creating a development container for your project:
Option 1: Use a Predefined Container Configuration
- Open your project folder in VS Code
- Press
F1to open the command palette - Type "Remote-Containers: Add Development Container Configuration Files..." and select it
- Choose a predefined container configuration that matches your project's needs (e.g., Node.js, Python, Java)
- Select the version that you need for your project
- Select any additional features you want to include (e.g., Git, specific database tools)
- VS Code will create a
.devcontainerfolder with configuration files - Press
F1again and select "Remote-Containers: Reopen in Container"
Option 2: Create a Custom Configuration
- Create a
.devcontainerfolder in your project root - Add a
devcontainer.jsonfile and optionally aDockerfile - Configure these files according to your project's needs
- Use the "Remote-Containers: Reopen in Container" command to start developing in your container
Example: Basic Node.js Development Container
Here's a simple devcontainer.json file for a Node.js project:
{
"name": "Node.js Development",
"image": "mcr.microsoft.com/devcontainers/javascript-node:16",
"forwardPorts": [3000, 3001],
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
},
"postCreateCommand": "npm install"
}
This configuration:
- Uses the official Microsoft Node.js 16 devcontainer image
- Forwards ports 3000 and 3001 from the container to your host machine
- Installs the ESLint and Prettier VS Code extensions in the container
- Configures VS Code to format code on save using Prettier
- Runs
npm installafter the container is created
Anatomy of devcontainer.json
The devcontainer.json file defines the development container configuration. Let's explore its key components:
Basic Properties
{
"name": "Project Name", // Descriptive name for the container
"image": "image:tag", // Use an existing Docker image
// OR
"build": { // Build a custom image
"dockerfile": "Dockerfile", // Path to Dockerfile
"context": "..", // Build context path
"args": { // Build arguments
"VARIANT": "16-bullseye"
}
},
"remoteUser": "node", // User to run commands as
"updateRemoteUserUID": true // Match container user UID with host user
}
Ports and Networking
{
"forwardPorts": [3000, 8080], // Ports to forward to host
"appPort": ["3000:3000", "9229:9229"], // Alternative port formatting
"runArgs": ["--network=host"], // Docker run arguments
"portsAttributes": { // Port-specific attributes
"3000": {
"label": "Application",
"onAutoForward": "notify"
}
}
}
VS Code Customization
{
"customizations": {
"vscode": {
"extensions": [ // VS Code extensions to install
"dbaeumer.vscode-eslint",
"ms-python.python"
],
"settings": { // VS Code settings
"python.linting.enabled": true,
"editor.formatOnSave": true
}
}
}
}
Lifecycle Scripts
{
"initializeCommand": "echo 'Preparing host...'", // Before container creation
"onCreateCommand": "echo 'Container created'", // After container creation
"postCreateCommand": "npm install", // After creation, before VS Code connects
"postStartCommand": "npm run dev", // After VS Code connects
"postAttachCommand": "echo 'Attached to container'" // After attaching to running container
}
Mounts and Volumes
{
"mounts": [
"source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
"source=node_modules,target=${containerWorkspaceFolder}/node_modules,type=volume"
],
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=cached",
"workspaceFolder": "/workspace"
}
Environment Variables
{
"remoteEnv": {
"NODE_ENV": "development",
"DATABASE_URL": "postgres://user:pass@localhost:5432/mydb"
}
}
These examples demonstrate the flexibility of devcontainer.json. You can customize almost every aspect of your development environment, from the container itself to the editor configuration and the development workflow.
Using Dockerfiles with Dev Containers
While you can use pre-built images for your dev containers, you often need to customize them further with a Dockerfile. This is especially useful when you need to install additional tools or configure the environment in ways not covered by the base image.
Basic Dev Container with Dockerfile
Here's a simple setup using a custom Dockerfile:
.devcontainer/devcontainer.json:
{
"name": "Custom Node.js",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint"
]
}
},
"forwardPorts": [3000]
}
.devcontainer/Dockerfile:
FROM node:16-bullseye
# Install additional tools
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install git curl jq vim \
&& apt-get clean -y
# Install global node packages
RUN npm install -g typescript@4.5.5 ts-node nodemon
# Create a non-root user with sudo access
ARG USERNAME=node
ARG USER_UID=1000
ARG USER_GID=$USER_UID
# Create the user
RUN groupmod --gid $USER_GID $USERNAME \
&& usermod --uid $USER_UID --gid $USER_GID $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
# Set the default user
USER $USERNAME
This setup:
- Starts with a Node.js 16 base image
- Installs additional system tools (git, curl, jq, vim)
- Installs global Node.js packages (typescript, ts-node, nodemon)
- Configures a non-root user with sudo access
- Sets up the VS Code environment with ESLint
- Forwards port 3000 from the container to the host
Multi-stage Dockerfiles for Development
You can use multi-stage builds to create specialized development environments while keeping the image size reasonable:
FROM node:16-bullseye AS base
# Install common tools
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install git curl jq
FROM base AS development
# Install development-specific tools
RUN apt-get update && apt-get -y install vim zsh \
&& npm install -g typescript ts-node nodemon
# Configure zsh
RUN sh -c "$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)"
# Non-root user setup
ARG USERNAME=node
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN groupmod --gid $USER_GID $USERNAME \
&& usermod --uid $USER_UID --gid $USER_GID $USERNAME \
&& apt-get install -y sudo \
&& echo $USERNAME ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/$USERNAME \
&& chmod 0440 /etc/sudoers.d/$USERNAME
USER $USERNAME
Using Docker Compose with Dev Containers
Many real-world applications require multiple services (e.g., a web server and a database). Docker Compose is the perfect tool for managing these multi-container setups, and it integrates seamlessly with VS Code's development containers.
Setting Up a Dev Container with Docker Compose
Here's how to configure a development container with Docker Compose:
.devcontainer/devcontainer.json:
{
"name": "Web App with Database",
"dockerComposeFile": "docker-compose.yml",
"service": "app", // The service to attach VS Code to
"workspaceFolder": "/workspace", // Path in the container where code is mounted
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"mtxr.sqltools",
"mtxr.sqltools-driver-pg" // PostgreSQL extension
]
}
},
"forwardPorts": [3000, 5432], // App and PostgreSQL ports
"postCreateCommand": "npm install" // Run after container is created
}
.devcontainer/docker-compose.yml:
version: '3.8'
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ..:/workspace:cached
command: sleep infinity # Keeps container running
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp
- NODE_ENV=development
depends_on:
- db
network_mode: service:db # Allows app to reach db on localhost
db:
image: postgres:13
restart: unless-stopped
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=postgres
- POSTGRES_USER=postgres
- POSTGRES_DB=myapp
volumes:
postgres-data:
.devcontainer/Dockerfile:
FROM node:16-bullseye
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install postgresql-client \
&& apt-get clean -y
WORKDIR /workspace
# Non-root user setup
ARG USERNAME=node
ARG USER_UID=1000
ARG USER_GID=$USER_UID
RUN groupmod --gid $USER_GID $USERNAME \
&& usermod --uid $USER_UID --gid $USER_GID $USERNAME
USER $USERNAME
This configuration:
- Sets up two services: a Node.js application and a PostgreSQL database
- Mounts your project folder into the app container
- Installs PostgreSQL client tools in the app container
- Configures environment variables for database connection
- Installs VS Code extensions for both Node.js and PostgreSQL development
- Persists database data using a named volume
- Runs npm install after the container is created
Features and Advanced Configuration
Development containers support a variety of advanced features that can enhance your development experience.
Installing Additional Tools and Features
VS Code's devcontainer configuration supports "features" - prepackaged sets of tools that can be added to your container. These are defined in the features section of devcontainer.json:
{
"name": "Advanced Development",
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "16"
},
"ghcr.io/devcontainers/features/python:1": {
"version": "3.10"
},
"ghcr.io/devcontainers/features/docker-in-docker:1": {},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/github-cli:1": {}
}
}
This configuration adds Node.js, Python, Docker-in-Docker, Git, and the GitHub CLI to the base container.
Debugging in Containers
VS Code's debugging capabilities work seamlessly with development containers. You just need to configure the appropriate debug configuration in .vscode/launch.json.
For example, for a Node.js application:
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Launch Program",
"skipFiles": ["/**"],
"program": "${workspaceFolder}/index.js",
"outFiles": ["${workspaceFolder}/**/*.js"]
}
]
}
GPU Support
For development that requires GPU acceleration (like machine learning), you can enable GPU access in your container:
{
"runArgs": ["--gpus=all"]
}
Docker from Within a Container (Docker-in-Docker)
To use Docker commands inside your development container:
{
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:1": {}
}
}
Development Container Templates
You can create template definitions for your organization's standard development environments and reuse them across projects. This ensures consistency across your entire development ecosystem.
{
"image": "mcr.microsoft.com/devcontainers/universal:2",
"features": {
"template-features/company-tools:1": {}
}
}
Working with Development Containers
Once your devcontainer is set up, you'll interact with it in various ways during your development workflow.
Opening Projects in Containers
- Open your project folder in VS Code
- If a devcontainer configuration is detected, VS Code will prompt you to reopen the folder in a container
- Click "Reopen in Container" or:
- Press
F1to open the command palette - Select "Remote-Containers: Reopen in Container"
- Press
- VS Code will build the container (if needed) and connect to it
Terminal Access
When you open a terminal in VS Code (Ctrl+`), it opens inside the container. You can run commands, install tools, and interact with the container environment directly.
Installing Dependencies
You can install dependencies (npm packages, Python packages, etc.) directly in the container without affecting your host system:
# In the VS Code terminal (inside container)
npm install express
pip install requests
apt-get install -y some-package # With sudo if needed
File Access and Management
Files in your project directory are automatically synced between your host and the container. Changes you make in VS Code are immediately available in the container, and vice versa.
Port Forwarding
Ports specified in forwardPorts are automatically forwarded from the container to your host machine. You can also forward additional ports on demand:
- Press
F1to open the command palette - Select "Remote-Containers: Forward Port from Container"
- Enter the port number to forward
Stopping and Rebuilding Containers
- To stop the container: Close VS Code or press
F1and select "Remote-Containers: Reopen Folder Locally" - To rebuild the container: Press
F1and select "Remote-Containers: Rebuild Container"
GitHub Codespaces Integration
GitHub Codespaces is a cloud-based development environment that uses the same devcontainer configuration as VS Code. This allows you to create a consistent development experience that works both locally and in the cloud.
Benefits of GitHub Codespaces
- Develop from any device with a web browser
- No need to install Docker or other tools locally
- Quickly onboard new team members
- Easily spin up new development environments
- Seamless integration with GitHub repositories
Using the Same Configuration for Codespaces
The same .devcontainer folder used for local development containers works with GitHub Codespaces. When you create a codespace for a repository, GitHub uses the devcontainer configuration to set up the environment in the cloud.
Codespaces-Specific Features
You can add Codespaces-specific configuration to your devcontainer.json file:
{
"name": "My Project",
"image": "mcr.microsoft.com/devcontainers/universal:2",
"hostRequirements": {
"cpus": 4,
"memory": "8gb",
"storage": "32gb"
},
"customizations": {
"codespaces": {
"openFiles": ["README.md", "src/app.js"]
}
}
}
This configuration specifies resource requirements for the codespace and files to open automatically when the codespace is created.
Best Practices for Development Containers
Performance Optimization
- Use volume mounts for node_modules: This improves performance, especially on Windows and macOS
- Use cached bind mounts: Specify
consistency=cachedfor bind mounts to improve performance - Optimize your Dockerfile: Use multi-stage builds and minimize layers
- Use lightweight base images: Alpine-based images or slim variants reduce container size and startup time
Security Considerations
- Run as non-root user: Configure your container to run as a non-root user
- Keep images updated: Regularly update base images to get security patches
- Scan for vulnerabilities: Use tools like Snyk or Trivy to scan container images
- Isolate sensitive operations: Use separate services for sensitive operations
Maintainability and Collaboration
- Document your devcontainer: Include comments in your configuration files
- Use version control: Commit your devcontainer configuration to your repository
- Standardize across projects: Use consistent configurations across your organization
- Test your devcontainer: Ensure it works for all team members before committing
Common Gotchas and Solutions
- File permission issues: Use
updateRemoteUserUIDto match container user ID with host - Slow file performance: Use
consistency=cachedfor bind mounts - Extensions not working: Ensure they're included in
customizations.vscode.extensions - Container startup failures: Check Docker logs and devcontainer.json for errors
Real-World Examples
Example 1: Full-Stack JavaScript Development
.devcontainer/devcontainer.json:
{
"name": "Full-Stack JS Development",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspace",
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-vscode.vscode-typescript-next",
"ms-azuretools.vscode-docker",
"mongodb.mongodb-vscode"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
}
}
}
},
"forwardPorts": [3000, 3001, 27017, 8080],
"postCreateCommand": "npm install"
}
.devcontainer/docker-compose.yml:
version: '3.8'
services:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ..:/workspace:cached
- node_modules:/workspace/node_modules
command: sleep infinity
environment:
- MONGO_URL=mongodb://mongo:27017/myapp
depends_on:
- mongo
network_mode: service:mongo
mongo:
image: mongo:5.0
restart: unless-stopped
volumes:
- mongodb-data:/data/db
volumes:
node_modules:
mongodb-data:
Example 2: Python Data Science Environment
.devcontainer/devcontainer.json:
{
"name": "Python Data Science",
"build": {
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-toolsai.jupyter",
"ms-python.vscode-pylance"
],
"settings": {
"python.linting.enabled": true,
"python.linting.pylintEnabled": true,
"editor.formatOnSave": true,
"python.formatting.provider": "black"
}
}
},
"forwardPorts": [8888],
"postCreateCommand": "pip install --user -r requirements.txt"
}
.devcontainer/Dockerfile:
FROM python:3.10
# Install system dependencies
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install git wget curl \
&& apt-get clean -y
# Install common data science packages
RUN pip install numpy pandas matplotlib seaborn scikit-learn jupyter black pylint
# Set up 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
# Set the default user
USER $USERNAME
# Configure Jupyter
RUN jupyter notebook --generate-config
RUN echo "c.NotebookApp.ip = '0.0.0.0'" >> /home/$USERNAME/.jupyter/jupyter_notebook_config.py
RUN echo "c.NotebookApp.open_browser = False" >> /home/$USERNAME/.jupyter/jupyter_notebook_config.py
# Set working directory
WORKDIR /workspace
Practice Activities
Activity 1: Create a Basic Node.js Development Container
- Install VS Code and the Dev Containers extension if you haven't already
- Create a new folder for a simple Node.js project
- Initialize a package.json file:
npm init -y - Create a simple Express application (index.js):
const express = require('express');
const app = express();
const port = process.env.PORT || 3000;
app.get('/', (req, res) => {
res.send('Hello from my development container!');
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});
- Create a .devcontainer folder in your project
- Add devcontainer.json and Dockerfile files based on the examples in this lecture
- Reopen the project in a container and test your application
Activity 2: Set Up a Multi-Container Development Environment
- Extend your Node.js project to include a MongoDB database
- Create a docker-compose.yml file in your .devcontainer folder
- Update your devcontainer.json to use Docker Compose
- Modify your application to connect to the MongoDB database
- Test the complete environment
Activity 3: Customize Your Development Environment
- Add specific VS Code extensions to your devcontainer.json
- Configure editor settings in your devcontainer.json
- Add a postCreateCommand to install dependencies
- Customize your terminal prompt or add aliases in the Dockerfile
- Test the improved development experience
Resources for Further Learning
Summary
In this lecture, we've explored how development containers can transform your development workflow:
- Development containers provide consistent, isolated environments for software development
- VS Code's Dev Containers extension integrates seamlessly with Docker to create a powerful development experience
- The
devcontainer.jsonfile defines your development environment, from the container configuration to editor settings - You can use predefined configurations or create custom Dockerfiles and Docker Compose setups
- Dev containers support advanced features like debugging, multi-container setups, and GitHub Codespaces integration
- Best practices include performance optimization, security considerations, and standardization across projects
By adopting development containers, you eliminate the "works on my machine" problem and create a more efficient, consistent development experience for your entire team. As containerization continues to grow in importance, mastering these tools will be an essential skill for modern software development.