VSCode with DevContainers: Building Secure, Isolated and Reproducible Development Environments

>by Roman Tsyupryk
>

I'd like to share my observations and insights about pair programming with AI agents.

Currently, I'm involved in several projects with different technology stacks. For example, over the past few months, I've been working on three different projects with three distinct technology stacks:

  1. A Python Stack project developing an AI agent using LangChain, LangGraph, LangSmith, and other Python libraries
  2. A JavaScript Stack project developing a web UI to interact with an AI agent
  3. A Java Stack project developing a backend that exposes REST APIs for AI agents

P.S. I agree this sounds like a zoo of technologies. However, this decision was made deliberately to have the flexibility to choose the best tool from each ecosystem for specific use cases.

To maintain flexibility across these projects, I need multiple libraries and tools installed on my local system:

  • All required Python ecosystem tools (e.g., pip, venv, libraries)
  • All required JavaScript and Node.js tools (e.g., npm, nvm, libraries)
  • All required Java tools (e.g., Maven, Gradle, JDK)
  • Docker with docker-compose
  • VSCode with various extensions
  • kubectl with access to multiple Kubernetes clusters
  • Kind for creating local Kubernetes clusters for testing
  • Helm for managing Kubernetes charts
  • Terraform for infrastructure as code
  • And more...

The Problem

Having all these tools installed on my local system is uncomfortable and risky. It can cause:

  • Security issues affecting both my local system and the projects themselves
  • Reproducibility challenges
  • AI Agent safety concerns
  • Version conflicts
  • Broken dependencies

I'm also constantly cloning GitHub repositories to test different technologies and tools. This poses security risks if any repository contains malicious code or vulnerabilities.

Additionally, switching between projects with different technology stacks requires manual environment changes. Sharing my development environment with colleagues or the community becomes a headache.

The Solution: VSCode with DevContainers

I've decided to use VSCode with DevContainers to create secure, isolated and reproducible development environments for each project. With DevContainers, I can define a complete development environment that:

  • With all required tools and dependencies pre-installed
  • Clones necessary repositories
  • Installs all desired tools and dependencies
  • Executes everything needed for testing without risking my local system

Pros and Cons

Cons

  1. Higher resource consumption: Running containers in the background consumes more CPU, RAM, and disk space
  2. Initial setup time: Projects with many dependencies may take time to build initially (though subsequent builds are faster thanks to Docker layer caching). Rebuilding after modifying devcontainer.json also requires time

Pros

  1. Security: Working in a container reduces the risk of affecting your local system or introducing vulnerabilities, protecting both your local environment and development projects
  2. Isolation: Each project is encapsulated in its own environment with everything it needs
  3. Reproducibility: Easy to share development environments with others, ensuring everyone works with the same tools and configurations
  4. AI Agent Safety: Running AI agents inside DevContainers allows you to grant necessary permissions without worrying about local system security. In my case, I've restricted AI agent interactions with git and MCP tools that execute commands capable of making external system modifications

DevContainer Configuration

Let me share my DevContainer configuration for web UI development.

VSCode Configuration Strategy

Before diving into the configuration files, it's important to understand my approach to VSCode settings and extensions. VSCode has different configuration levels:

  • User settings: Global settings that apply to all projects
  • Workspace settings: Settings specific to a workspace/project
  • DevContainer settings: Settings that only apply within the container

In my case, I keep both user settings and workspace settings empty. All my configuration lives at the DevContainer level. This ensures complete isolation and reproducibility.

Similarly, for extensions, I only have two extensions installed locally:

  • ms-vscode-remote.remote-containers - Required for DevContainer functionality
  • github.github-vscode-theme - Personal theme preference shared across all DevContainers

All other extensions are installed at the DevContainer level. This means each project gets exactly the extensions it needs, without cluttering my local VSCode installation or creating conflicts between projects.

Folder Structure

.devcontainer/
โ”œโ”€โ”€ devcontainer.json    # Main configuration file
โ”œโ”€โ”€ post-create.sh       # Post-create script to set up the environment
โ””โ”€โ”€ post-start.sh        # Post-start script that runs when the container starts

devcontainer.json

The devcontainer.json file is the main configuration file for the DevContainer, where we define all container settings:

{
	"name": "BIO-LINK",
	"image": "mcr.microsoft.com/devcontainers/javascript-node:22",
	"containerEnv": {},
	"features": {
		// https://containers.dev/features
		"ghcr.io/devcontainers/features/common-utils:2": {
			"installZsh": true,
			"installOhMyZsh": true,
			"installOhMyZshConfig": true,
			"upgradePackages": true,
			"username": "devcontainer",
			"userUid": "automatic",
			"userGid": "automatic"
		}
	},
	"containerUser": "devcontainer",
	// "updateRemoteUserUID": true,
	"postCreateCommand": "chmod +x ./.devcontainer/post-create.sh && ./.devcontainer/post-create.sh",
	"postStartCommand": "chmod +x ./.devcontainer/post-start.sh && ./.devcontainer/post-start.sh",
	"customizations": {
		"vscode": {
			"settings": {
				"window.zoomLevel": 0.5,
				"window.title": "${rootName}",
				"window.autoDetectColorScheme": true,
				"workbench.sideBar.location": "left",
				"workbench.editor.showTabs": "none",
				"workbench.tree.indent": 24,
				"workbench.preferredLightColorTheme": "GitHub Light",
				"workbench.preferredDarkColorTheme": "GitHub Dark",
				"editor.minimap.enabled": false,
				"editor.scrollbar.vertical": "hidden",
				"editor.overviewRulerBorder": false,
				"editor.hideCursorInOverviewRuler": true,
				"editor.cursorSmoothCaretAnimation": "on",
				"editor.wordWrap": "on",
				"editor.formatOnSave": true,
				"editor.cursorBlinking": "phase",
				"editor.linkedEditing": true,
				"editor.guides.bracketPairs": true,
				"editor.foldingStrategy": "indentation",
				"editor.foldingImportsByDefault": true,
				"editor.foldingMethodsByDefault": true,
				"terminal.integrated.defaultProfile.osx": "zsh",
				"terminal.integrated.defaultProfile.linux": "zsh",
				"breadcrumbs.enabled": false,
				"git.confirmSync": false,
				"git.autofetch": true,
				"github.copilot.enable": {
					"*": true,
					"markdown": true,
					// "scminput": false,
					"plaintext": true
				},
				"github.copilot.nextEditSuggestions.enabled": true,
				"diffEditor.codeLens": true,
				"files.autoSave": "afterDelay",
				"files.autoSaveDelay": 1000,
				"explorer.confirmDelete": false,
				"explorer.confirmDragAndDrop": false,
				"outline.collapseItems": "alwaysCollapse",
				"chat.mcp.gallery.enabled": true,
				// "debug.javascript.autoAttachFilter": "disabled",
				"chat.promptFilesRecommendations": {
					"speckit.constitution": true,
					"speckit.specify": true,
					"speckit.plan": true,
					"speckit.tasks": true,
					"speckit.implement": true
				},
				"chat.tools.terminal.autoApprove": {
					"cd": true,
					"echo": true,
					"ls": true,
					"pwd": true,
					"cat": true,
					"head": true,
					"tail": true,
					"findstr": true,
					"wc": true,
					"tr": true,
					"cut": true,
					"cmp": true,
					"which": true,
					"basename": true,
					"dirname": true,
					"realpath": true,
					"readlink": true,
					"stat": true,
					"file": true,
					"du": true,
					"df": true,
					"sleep": true,
					"grep": true,
					"git status": true,
					"git log": true,
					"git show": true,
					"git diff": true,
					"git grep": true,
					"git rev-parse": true,
					"Get-ChildItem": true,
					"Get-Content": true,
					"Get-Date": true,
					"Get-Random": true,
					"Get-Location": true,
					"Write-Host": true,
					"Write-Output": true,
					"Split-Path": true,
					"Join-Path": true,
					"Start-Sleep": true,
					"Where-Object": true,
					"/^Select-[a-z0-9]/i": true,
					"/^Measure-[a-z0-9]/i": true,
					"/^Compare-[a-z0-9]/i": true,
					"/^Format-[a-z0-9]/i": true,
					"/^Sort-[a-z0-9]/i": true,
					"column": true,
					"/^column\\b.*-c\\s+[0-9]{4,}/": true,
					"date": true,
					"/^date\\b.*(-s|--set)\\b/": true,
					"find": true,
					"/^find\\b.*-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/": true,
					"sort": true,
					"/^sort\\b.*-(o|S)\\b/": true,
					"tree": true,
					"/^tree\\b.*-o\\b/": true,
					"/\\(.+\\)/s": {
						"approve": true,
						"matchCommandLine": true
					},
					"/\\{.+\\}/s": {
						"approve": true,
						"matchCommandLine": true
					},
					"/`.+`/s": {
						"approve": true,
						"matchCommandLine": true
					},
					"rm": true,
					"rmdir": true,
					"del": true,
					"Remove-Item": true,
					"ri": true,
					"rd": true,
					"erase": true,
					"dd": true,
					"kill": true,
					"ps": true,
					"top": true,
					"Stop-Process": true,
					"spps": true,
					"taskkill": true,
					"taskkill.exe": true,
					"curl": true,
					"wget": true,
					"Invoke-RestMethod": true,
					"Invoke-WebRequest": true,
					"irm": true,
					"iwr": true,
					"chmod": true,
					"chown": true,
					"sp": true,
					"Set-Acl": true,
					"jq": true,
					"xargs": true,
					"eval": true,
					"Invoke-Expression": true,
					"iex": true,
					"bash .specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks": {
						"approve": true,
						"matchCommandLine": true
					},
					"/dev/null": true,
					"npm": {
						"approve": true,
						"matchCommandLine": true
					},
					"npx": {
						"approve": true,
						"matchCommandLine": true
					},
					"sed": true,
					".specify/scripts/bash/": true,
					".specify/scripts/powershell/": true,
					"claude": true
				}
			},
			"extensions": [
				// GitHub Copilot
				"github.copilot",
				"github.copilot-chat",
				// Claude Code
				// "anthropic.claude-code"
				// Codex
				// "openai.chatgpt",
				// Gemini
				// "google.geminicodeassist",
				// Kilo Code
				// "kilocode.Kilo-Code",
				// Cline
				// "saoudrizwan.claude-dev",
				// ESLint
				"dbaeumer.vscode-eslint",
				"mhutchie.git-graph",
				"anweber.reveal-button",
				"chrisdias.promptboost"
			]
		}
	}
}

post-create.sh

The post-create.sh script runs once after the container is created. This is where you install additional dependencies and configure the environment:

#!/bin/bash

# Exit immediately on error, treat unset variables as an error, and fail if any command in a pipeline fails.
set -euo pipefail

# Function to run a command and show logs only on error
run_command() {
    local command_to_run="$*"
    local output
    local exit_code
    
    # Capture all output (stdout and stderr)
    output=$(eval "$command_to_run" 2>&1) || exit_code=$?
    exit_code=${exit_code:-0}
    
    if [ $exit_code -ne 0 ]; then
        echo -e "\033[0;31m[ERROR] Command failed (Exit Code $exit_code): $command_to_run\033[0m" >&2
        echo -e "\033[0;31m$output\033[0m" >&2
        
        exit $exit_code
    fi
}

echo -e "\n๐Ÿค– Installing project dependencies..."
run_command "npm install"
echo "โœ… Done"

echo -e "\n๐Ÿค– Installing Claude CLI..."
run_command "npm install -g @anthropic-ai/claude-code@latest"
# run_command "claude --dangerously-skip-permissions"
echo "โœ… Done"

echo -e "\n๐Ÿค– Installing Claude Flow CLI..."
run_command "npm install -g claude-flow@alpha"
echo "โœ… Done"

# echo -e "\n๐Ÿค– Installing Agentic Flow..."
# run_command "npm i agentic-flow"
# echo "โœ… Done"

# echo -e "\n๐Ÿค– Installing Copilot CLI..."
# run_command "npm install -g @github/copilot@latest"
# echo "โœ… Done"

# echo -e "\n๐Ÿค– Installing Codex CLI..."
# run_command "npm install -g @openai/codex@latest"
# echo "โœ… Done"

# echo -e "\n๐Ÿค– Installing Gemini CLI..."
# run_command "npm install -g @google/gemini-cli@latest"
# echo "โœ… Done"

# echo -e "\n๐Ÿค– Installing Ollama CLI..."
# run_command "npm install -g ollama@latest"
# echo "โœ… Done"

echo -e "\n๐Ÿค– Installing zsh-autosuggestions..."
run_command "git clone https://github.com/zsh-users/zsh-autosuggestions ~/.oh-my-zsh/custom/plugins/zsh-autosuggestions"
# Enable the plugin in .zshrc
if grep -q "^plugins=(git)$" ~/.zshrc; then
    sed -i 's/^plugins=(git)$/plugins=(git zsh-autosuggestions)/' ~/.zshrc
fi
echo "โœ… Done"

echo -e "\n๐Ÿค– Installing zsh-syntax-highlighting..."
run_command "git clone https://github.com/zsh-users/zsh-syntax-highlighting ~/.oh-my-zsh/custom/plugins/zsh-syntax-highlighting"
# Enable the plugin in .zshrc
if grep -q "^plugins=(git zsh-autosuggestions)$" ~/.zshrc; then
    sed -i 's/^plugins=(git zsh-autosuggestions)$/plugins=(git zsh-autosuggestions zsh-syntax-highlighting)/' ~/.zshrc
fi
echo "โœ… Done"

echo -e "\n๐Ÿงน Cleaning cache..."
run_command "sudo apt-get autoclean"
run_command "sudo apt-get clean"

echo "โœ… Setup completed. Happy coding! ๐Ÿš€"

post-start.sh

The post-start.sh script runs every time the container starts. This is where you execute commands needed on each startup:

#!/bin/bash

# Exit immediately on error, treat unset variables as an error, and fail if any command in a pipeline fails.
set -euo pipefail

# Function to run a command and show logs only on error
run_command() {
    local command_to_run="$*"
    local output
    local exit_code
    
    # Capture all output (stdout and stderr)
    output=$(eval "$command_to_run" 2>&1) || exit_code=$?
    exit_code=${exit_code:-0}
    
    if [ $exit_code -ne 0 ]; then
        echo -e "\033[0;31m[ERROR] Command failed (Exit Code $exit_code): $command_to_run\033[0m" >&2
        echo -e "\033[0;31m$output\033[0m" >&2
        
        exit $exit_code
    fi
}

# echo -e "\n๐Ÿค– Configuring Git safe directory..."
# run_command "git config --global --add safe.directory ${containerWorkspaceFolder}"
# echo "โœ… Done"

echo -e "\n๐Ÿค– Setting up Claude CLI alias..."
# Add alias to .bashrc
if ! grep -q "alias claude=" ~/.bashrc 2>/dev/null; then
    echo 'alias claude="claude --dangerously-skip-permissions"' >> ~/.bashrc
fi
# Add alias to .zshrc (for zsh shell)
if ! grep -q "alias claude=" ~/.zshrc 2>/dev/null; then
    echo 'alias claude="claude --dangerously-skip-permissions"' >> ~/.zshrc
fi
echo "โœ… Done"

echo -e "\n๐Ÿค– Setting zsh as default shell..."
# Change default shell to zsh for the current user
if [ "$SHELL" != "$(which zsh)" ]; then
    run_command "sudo chsh -s $(which zsh) $(whoami)"
fi
echo "โœ… Done"

echo -e "\nโœ… Container started and ready!"

Taking It Further: GitHub Codespaces

You can evolve this approach even further by using GitHub Codespaces to run DevContainers in the cloud. This provides an additional layer of protection for your local machine.

However, this approach has additional costs since GitHub Codespaces isn't free. Pricing varies depending on the virtual machine configuration you choose for your Codespace.

Conclusion

Using VSCode with DevContainers for pair programming with AI agents is an excellent way to maintain security, isolation, and reproducibility in your development environments. It provides peace of mind when working with AI-powered tools while keeping your local system protected.

Resources and Further Reading

Share this post: