← Back to DevBytes

Docker with VSCode Dev Containers: Best Practices and Common Pitfalls

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:

Setting Up Your First Dev Container

Prerequisites

Before diving in, ensure you have the following installed:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles