Development Containers in VS Code

Creating consistent, isolated development environments with containers

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.

graph TD A[Development Containers] -->|Provide| B[Consistent Environments] A -->|Include| C[Development Tools] A -->|Integrate with| D[VS Code] A -->|Isolate| E[Dependencies] A -->|Allow| F[Collaborative Development] B -->|Eliminate| G["'Works on My Machine' Issues"] C -->|Such as| H[Compilers, Linters, Debuggers] D -->|Through| I[Remote Development Extension] E -->|Avoid| J[Conflicts with Local System] F -->|Via| K[GitHub Codespaces]

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.

Traditional Development vs Dev Containers Traditional Development "Works on my machine" Hours/Days of Environment Setup Developer A Node v14, Python 3.8 Developer B Node v16, Python 3.9 Dev Containers Identical Environment - Node v16.15.1 - Python 3.9.12 - Same Extensions & Tools - Configuration in Version Control Minutes to Start Coding

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

Installing the Dev Containers Extension

  1. Open VS Code
  2. Click on the Extensions view icon in the Sidebar or press Ctrl+Shift+X
  3. Search for "Dev Containers" or "Remote - Containers"
  4. Click "Install"
flowchart TD A[Install VS Code] --> B[Install Docker] B --> C[Install Dev Containers Extension] C --> D[Create/Open Project] D --> E[Setup Dev Container Configuration] E --> F[Reopen in Container] F --> G[Start Developing]

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

  1. Open your project folder in VS Code
  2. Press F1 to open the command palette
  3. Type "Remote-Containers: Add Development Container Configuration Files..." and select it
  4. Choose a predefined container configuration that matches your project's needs (e.g., Node.js, Python, Java)
  5. Select the version that you need for your project
  6. Select any additional features you want to include (e.g., Git, specific database tools)
  7. VS Code will create a .devcontainer folder with configuration files
  8. Press F1 again and select "Remote-Containers: Reopen in Container"

Option 2: Create a Custom Configuration

  1. Create a .devcontainer folder in your project root
  2. Add a devcontainer.json file and optionally a Dockerfile
  3. Configure these files according to your project's needs
  4. 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:

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:

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:

graph TD A[VS Code] -->|Connects to| B[App Container] B -->|Communicates with| C[Database Container] D[Project Files] -->|Mounted in| B E[Database Data] -->|Persisted in| F[Named Volume] F -->|Attached to| C G[Dev Container Configuration] -->|Defines| B G -->|References| H[Docker Compose File] H -->|Defines| B H -->|Defines| C

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

  1. Open your project folder in VS Code
  2. If a devcontainer configuration is detected, VS Code will prompt you to reopen the folder in a container
  3. Click "Reopen in Container" or:
    • Press F1 to open the command palette
    • Select "Remote-Containers: Reopen in Container"
  4. 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:

  1. Press F1 to open the command palette
  2. Select "Remote-Containers: Forward Port from Container"
  3. Enter the port number to forward

Stopping and Rebuilding Containers

Dev Container Workflow Open Project Reopen in Container Develop & Test Container Commands - Install dependencies - Run build scripts Port Forwarding localhost:3000 → container:3000 Container Management Rebuild, restart, stop

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

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

Security Considerations

Maintainability and Collaboration

Common Gotchas and Solutions

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

  1. Install VS Code and the Dev Containers extension if you haven't already
  2. Create a new folder for a simple Node.js project
  3. Initialize a package.json file: npm init -y
  4. 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}`);
});
  1. Create a .devcontainer folder in your project
  2. Add devcontainer.json and Dockerfile files based on the examples in this lecture
  3. Reopen the project in a container and test your application

Activity 2: Set Up a Multi-Container Development Environment

  1. Extend your Node.js project to include a MongoDB database
  2. Create a docker-compose.yml file in your .devcontainer folder
  3. Update your devcontainer.json to use Docker Compose
  4. Modify your application to connect to the MongoDB database
  5. Test the complete environment

Activity 3: Customize Your Development Environment

  1. Add specific VS Code extensions to your devcontainer.json
  2. Configure editor settings in your devcontainer.json
  3. Add a postCreateCommand to install dependencies
  4. Customize your terminal prompt or add aliases in the Dockerfile
  5. Test the improved development experience

Resources for Further Learning

Summary

In this lecture, we've explored how development containers can transform your development workflow:

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.