| |

Docker 83 🐳 Local Development Stacks: Dev Containers (.devcontainer) and VS Code Integration

⏱️ Estimated read: 14 min | |

A Dev Container is not a production image with debugging tools bolted on. It is a separate artifact defined by a structured metadata file—devcontainer.json—that tells the editor how to build, start, and configure a containerized workspace . The distinction matters: production images should be minimal and hardened; development images should include compilers, debuggers, language servers, and test runners. VS Code’s Dev Containers extension reads this metadata, builds the environment, mounts the local source code, installs editor extensions inside the container, and connects the IDE to it as if it were a local folder .

The .devcontainer directory sits at the project root and contains devcontainer.json plus, optionally, a Dockerfile or docker-compose.yml. The simplest configuration references a pre-built image; the most maintainable configuration builds from a Dockerfile or composes multiple services. Lifecycle hooks (postCreateCommand, postStartCommand, postAttachCommand) run commands at specific points: after the container is first created, every time it starts, and every time the editor attaches . Getting these hooks right—and knowing which one to use for which task—is the difference between a workspace that “just works” and one that requires manual repair on every rebuild.

This chapter covers the devcontainer.json schema and its core properties, the three configuration patterns (image, Dockerfile, Docker Compose), lifecycle hook semantics, editor customizations and features, and the VS Code workflows for building, rebuilding, and attaching to containers. It assumes familiarity with Docker fundamentals and VS Code’s extension model.

Key point: postCreateCommand runs once; postStartCommand runs every start. Dependency installation belongs in the former, service startup in the latter. Confusing them produces either slow every-start setup or missing dependencies after rebuild.


Why Dev Containers exist as a separate specification

Development environments drift. A project that works on one developer’s machine fails on another’s because of differing Python versions, missing system libraries, or conflicting global packages. A Docker Compose file for production does not solve this: it describes services, not editor integration, language servers, or debugging configurations. The Dev Container Specification fills this gap by defining a metadata format that editor-agnostic tools—VS Code, GitHub Codespaces, the devcontainer CLI—can read and act on .

The editor must run inside the container, not outside it. Language servers, linters, formatters, and debuggers need to see the same filesystem and runtime that the application sees. VS Code’s Dev Containers extension achieves this by installing a VS Code Server inside the container and connecting the local UI to it. Extensions declared in devcontainer.json are installed in the container, not on the host . This means the editor’s tooling matches the application’s environment exactly.

Source code stays on the host, mounted into the container. The specification defines a default mount point for the workspace, typically /workspaces/<project-name>, and workspaceFolder controls which subdirectory the editor opens . This separation allows the container to be rebuilt without losing edits, and it allows the developer to use host-side Git tooling if desired. The mount is a bind mount, so file changes propagate in both directions.

The trade-off. Dev Containers add a layer of configuration that a simple docker run does not require. The devcontainer.json file must be maintained alongside the Dockerfile, lifecycle hooks must be correct, and rebuilds take time. For a single-developer project with a stable environment, the overhead may not justify the benefit. For teams, onboarding, and CI parity, the specification solves a real problem: every contributor gets the same environment, and the CI pipeline can run in the same container .


a. The devcontainer.json structure: image, Dockerfile, or Compose

The devcontainer.json file supports three mutually exclusive configuration sources. The image property references a pre-built image from a registry. The build property (or the shorthand dockerfile property) points to a Dockerfile that VS Code builds. The dockerComposeFile property references one or more Compose files and requires a service property to select which service the editor connects to .

The image pattern is the simplest and fastest to start. Microsoft maintains a set of development-focused images under mcr.microsoft.com/devcontainers/, including base images with common tools and language-specific images for Python, Node.js, Java, and others . A devcontainer.json that references one of these needs only a few lines.

{
  "name": "Python 3.12 Development",
  "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
  "postCreateCommand": "pip install -r requirements.txt",
  "customizations": {
    "vscode": {
      "extensions": ["ms-python.python", "ms-python.vscode-pylance"]
    }
  }
}

The Dockerfile pattern is required when the environment needs system packages, specific versions, or configuration that the pre-built images do not provide. The dockerfile property points to a Dockerfile relative to the devcontainer.json location. The Dockerfile’s CMD is overridden by default (overrideCommand defaults to true), so the container stays running for the editor to attach .

{
  "name": "Custom Python Environment",
  "build": {
    "dockerfile": "Dockerfile",
    "context": ".."
  },
  "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
  "postCreateCommand": "pip install -e ."
}

When using a Dockerfile, workspaceFolder and workspaceMount may need explicit declaration. The default mount is /workspaces/<project-name>, but if the Dockerfile sets a different WORKDIR, the editor needs to know where the source code lives . The variable ${localWorkspaceFolderBasename} resolves to the project folder’s name on the host.

The Docker Compose pattern is for multi-service applications: a web app, a database, a cache, and a message broker. The dockerComposeFile property accepts a string or an array of strings (for multiple Compose files that extend each other). The service property names the container the editor connects to. The runServices property controls which services start; by default, all services in the Compose file start .

{
  "name": "Full Stack",
  "dockerComposeFile": ["../docker-compose.yml", "docker-compose.extend.yml"],
  "service": "app",
  "workspaceFolder": "/workspace",
  "shutdownAction": "stopCompose"
}

The shutdownAction property determines what happens when the editor disconnects. For Compose setups, "stopCompose" stops all services; "none" leaves them running. The default for single-container setups is "stopContainer" . A development-focused Compose file often overrides the production entrypoint with a command that keeps the container alive, such as sleep infinity, so the editor can attach without the application exiting immediately .


b. Lifecycle hooks: when setup commands run

The specification defines five lifecycle commands, executed in order: initializeCommand (on the host before container creation), onCreateCommand (first creation), updateContentCommand (after source is mounted), postCreateCommand (after creation completes), and postStartCommand (every start) . In practice, postCreateCommand and postStartCommand are the two that matter most for application setup.

postCreateCommand runs once, after the container has been created and the source code mounted. It is the correct place for dependency installation: pip install -r requirements.txt, npm install, cargo fetch, or database migrations. If the command fails, subsequent hooks do not run, and the container is considered not properly created . The command must exit; a blocking process like a development server will prevent the container from finishing setup .

{
  "postCreateCommand": "pip install -r requirements.txt && python manage.py migrate",
  "postStartCommand": "python manage.py runserver 0.0.0.0:8000"
}

postStartCommand runs every time the container starts. It is suitable for starting background services that should be running whenever the developer is working, but it must not block indefinitely. A common pattern is to start a watcher or a development server in the background using shell job control, or to rely on the developer to start the server manually while postStartCommand handles lighter setup. The VS Code documentation warns that commands in postCreateCommand and postStartCommand must exit for the container to start .

A subtlety: if postCreateCommand installs dependencies, and the developer later rebuilds the container, postCreateCommand runs again. If the dependency installation is slow, the rebuild takes time. A Dockerfile that pre-installs dependencies using layer caching is faster than a postCreateCommand that installs them on every rebuild. The best practice is to move slow, stable installations into the Dockerfile and keep postCreateCommand for project-specific, frequently-changing setup .


c. Editor customizations, features, and the VS Code workflow

The customizations.vscode property controls editor configuration inside the container: extensions to install, settings to apply, and workspace-specific options. Extensions declared here are installed in the container’s VS Code Server, not on the host. This means a Python project can specify ms-python.python and ms-python.vscode-pylance without requiring the developer to install them manually .

{
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-python.python",
        "ms-python.vscode-pylance",
        "charliermarsh.ruff"
      ],
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python",
        "editor.formatOnSave": true
      }
    }
  }
}

Features are a separate mechanism for installing tools without a custom Dockerfile. The features property accepts a map of feature identifiers to configuration objects. The ghcr.io/devcontainers/features/ registry includes features for Node.js, Python, Go, Docker-in-Docker, GitHub CLI, and others . Features compose: a single devcontainer.json can install multiple language runtimes and tools, and each feature’s installation is layered and cached independently.

{
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "22" },
    "ghcr.io/devcontainers/features/github-cli:1": {},
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  }
}

The VS Code workflow for creating and editing a Dev Container is iterative. The Command Palette command Dev Containers: Add Dev Container Configuration Files... scaffolds a .devcontainer directory based on the project’s contents, offering templates from the first-party and community index . After editing the configuration, Dev Containers: Reopen in Container builds and connects. If the build fails, the dialog offers Open Folder Locally to inspect the build log, and Dev Containers: Show Container Log retrieves it later . After a successful build, further changes require Dev Containers: Rebuild Container to take effect; VS Code does not automatically rebuild on configuration changes.


Complete Example Session

// ============================================
// PART 1: MINIMAL IMAGE-BASED CONFIGURATION
// ============================================
// The simplest setup: pre-built image, one extension.
// No Dockerfile, no Compose. Fastest to start.
{
  "name": "Python Minimal",
  "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
  "customizations": {
    "vscode": { "extensions": ["ms-python.python"] }
  }
}

// ============================================
// PART 2: DOCKERFILE-BASED CONFIGURATION
// ============================================
// Custom system packages and a specific Python version.
// workspaceFolder declared explicitly for clarity.
{
  "name": "Custom Python",
  "build": {
    "dockerfile": "Dockerfile",
    "context": ".."
  },
  "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
  "postCreateCommand": "pip install -r requirements.txt"
}

// ============================================
// PART 3: THE DOCKERFILE
// ============================================
// FROM mcr.microsoft.com/devcontainers/python:1-3.12-bookworm
//
// RUN apt-get update && apt-get install -y \
//     postgresql-client \
//     && rm -rf /var/lib/apt/lists/*
//
// # Keep container running for editor attachment.
// CMD ["sleep", "infinity"]

// ============================================
// PART 4: DOCKER COMPOSE CONFIGURATION
// ============================================
// Multi-service: app + database.
{
  "name": "App + DB",
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "shutdownAction": "stopCompose",
  "postCreateCommand": "pip install -r requirements.txt",
  "forwardPorts": [8000, 5432]
}

// ============================================
// PART 5: THE COMPOSE FILE
// ============================================
// services:
//   app:
//     build: .
//     volumes:
//       - ..:/workspace:cached
//     command: sleep infinity
//     environment:
//       DATABASE_URL: postgresql://user:pass@db:5432/app
//   db:
//     image: postgres:16
//     environment:
//       POSTGRES_USER: user
//       POSTGRES_PASSWORD: pass
//       POSTGRES_DB: app

// ============================================
// PART 6: LIFECYCLE HOOKS
// ============================================
// postCreateCommand: one-time setup.
//   - Dependency installation.
//   - Database migrations.
//   - Pre-commit hook installation.
//
// postStartCommand: every start.
//   - Start background watcher.
//   - Ensure services are running.
//
// postAttachCommand: after editor attaches.
//   - Welcome message.
//   - Open a specific file.

// ============================================
// PART 7: FEATURES INSTEAD OF DOCKERFILE
// ============================================
// Add tools without modifying the Dockerfile.
{
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "22" },
    "ghcr.io/devcontainers/features/github-cli:1": {}
  }
}

// ============================================
// PART 8: EXTENSIONS AND SETTINGS
// ============================================
// Installed in the container, not on the host.
{
  "customizations": {
    "vscode": {
      "extensions": [
        "ms-python.python",
        "ms-python.vscode-pylance",
        "charliermarsh.ruff"
      ],
      "settings": {
        "python.defaultInterpreterPath": "/usr/local/bin/python",
        "editor.formatOnSave": true
      }
    }
  }
}

// ============================================
// PART 9: THE VS CODE WORKFLOW
// ============================================
// 1. Command Palette: Add Dev Container Configuration Files.
// 2. Edit .devcontainer/devcontainer.json.
// 3. Command Palette: Reopen in Container.
// 4. On failure: Open Folder Locally, read build log.
// 5. After success: Rebuild Container for config changes.

// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// Image or Dockerfile or Compose.
// Lifecycle hooks: create once, start every time.
// Features: tools without Dockerfile edits.
// Extensions: editor tooling inside the container.
// Source code: mounted from host, survives rebuilds.

Quick Reference

Configuration Sources

SourcePropertyUse When
Pre-built imageimageStandard environment, no customization
Dockerfilebuild.dockerfileSystem packages, specific versions
Docker ComposedockerComposeFile + serviceMulti-service application

Lifecycle Hooks

HookFrequencyUse For
postCreateCommandOnce, after creationpip install, migrations
postStartCommandEvery startBackground services
postAttachCommandAfter editor attachesWelcome messages

Common Features

FeatureRegistry Path
Node.jsghcr.io/devcontainers/features/node:1
Pythonghcr.io/devcontainers/features/python:1
GitHub CLIghcr.io/devcontainers/features/github-cli:1
Docker-in-Dockerghcr.io/devcontainers/features/docker-in-docker:2

Key Properties

PropertyPurpose
workspaceFolderPath editor opens inside container
remoteUserUser for editor processes
forwardPortsPorts to forward to host
shutdownActionWhat happens on disconnect

Best Practices

✅ Do This:

// Install slow dependencies in Dockerfile.                   // ✅
// Dockerfile RUN pip install -r requirements.txt
// devcontainer.json postCreateCommand for project-specific.

// Use postCreateCommand for one-time setup.                  // ✅
"postCreateCommand": "pip install -e . && pre-commit install"

// Use postStartCommand for services.                         // ✅
"postStartCommand": "./scripts/start-watcher.sh &"

// Declare extensions in customizations.                      // ✅
"extensions": ["ms-python.python"]

❌ Don’t Do This:

// Put long-running server in postCreateCommand.              // ❌
"postCreateCommand": "python manage.py runserver"
// Container never finishes setup.

// Install every tool via postCreateCommand.                  // ❌
"postCreateCommand": "apt-get install -y gcc && pip install ..."
// Slow rebuilds; use Dockerfile or features.

// Forget workspaceFolder with custom Dockerfile.             // ❌
// Editor opens empty window; source not found.

Common Pitfalls

PitfallWhy It HappensFix
Container never startspostCreateCommand runs blocking processEnsure command exits
Editor opens empty windowworkspaceFolder not set for DockerfileDeclare path explicitly
Dependencies missing after rebuildInstalled via command, not DockerfileMove slow deps to Dockerfile
Extensions not availableInstalled on host, not containerDeclare in customizations
Config changes ignoredVS Code does not auto-rebuildRun Rebuild Container
Root-owned files on LinuxBind mount with root container userSet remoteUser

Real-World Examples

1. Minimal Python Dev Container

{
  "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bookworm",
  "postCreateCommand": "pip install -r requirements.txt"
}

2. Custom Dockerfile with System Packages

{
  "build": { "dockerfile": "Dockerfile" },
  "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}"
}

3. Compose with Database

{
  "dockerComposeFile": "docker-compose.yml",
  "service": "app",
  "workspaceFolder": "/workspace"
}

4. Install Extensions

{
  "customizations": {
    "vscode": { "extensions": ["ms-python.python"] }
  }
}

5. Add Node via Feature

{
  "features": {
    "ghcr.io/devcontainers/features/node:1": { "version": "22" }
  }
}

6. Forward Application Port

{
  "forwardPorts": [8000, 5432]
}

7. Non-Root User

{
  "remoteUser": "vscode"
}

8. Stop Compose on Disconnect

{
  "shutdownAction": "stopCompose"
}

9. Run Migrations Once

{
  "postCreateCommand": "python manage.py migrate"
}

10. Start Dev Server Every Time

{
  "postStartCommand": "python manage.py runserver 0.0.0.0:8000 &"
}

Visual

Dev Container Architecture

┌──────────────────────────────────────────────┐
│  HOST MACHINE                                │
│                                              │
│  ┌────────────────────────────────────┐      │
│  │  VS Code (UI)                      │      │
│  │  Extensions installed on host      │      │
│  └────────────────────────────────────┘      │
│         │                                    │
│         │ VS Code Server connection          │
│         ▼                                    │
│  ┌────────────────────────────────────┐      │
│  │  DOCKER CONTAINER                  │      │
│  │  VS Code Server                    │      │
│  │  Extensions from devcontainer.json │      │
│  │  Language servers, debuggers       │      │
│  │                                    │      │
│  │  /workspaces/project ← mount       │      │
│  └────────────────────────────────────┘      │
│         │                                    │
│         │ bind mount                         │
│         ▼                                    │
│  ┌────────────────────────────────────┐      │
│  │  PROJECT SOURCE ON HOST            │      │
│  │  Survives container rebuild        │      │
│  └────────────────────────────────────┘      │
│                                              │
└──────────────────────────────────────────────┘

Lifecycle Hook Timing

┌──────────────────────────────────────────────┐
│  HOOK EXECUTION ORDER                        │
│                                              │
│  initializeCommand (host)                    │
│       │                                      │
│       ▼                                      │
│  onCreateCommand (first creation)            │
│       │                                      │
│       ▼                                      │
│  updateContentCommand (after source mount)   │
│       │                                      │
│       ▼                                      │
│  postCreateCommand (once, after creation)    │
│       │                                      │
│       ▼                                      │
│  postStartCommand (every start)              │
│       │                                      │
│       ▼                                      │
│  postAttachCommand (after editor attaches)   │
│                                              │
│  Failure in any hook skips subsequent hooks. │
│                                              │
└──────────────────────────────────────────────┘

Configuration Source Decision

┌──────────────────────────────────────────────┐
│  WHICH CONFIGURATION?                        │
│                                              │
│  Standard environment, no custom tools?      │
│       └── image (mcr.microsoft.com/...)      │
│                                              │
│  Need system packages or specific versions?  │
│       └── Dockerfile (build.dockerfile)      │
│                                              │
│  Multi-service (app + database + cache)?     │
│       └── Docker Compose                     │
│                                              │
│  Add tools without Dockerfile?               │
│       └── features                           │
│                                              │
│  Image + features is often enough.           │
│                                              │
└──────────────────────────────────────────────┘

VS Code Workflow Loop

┌──────────────────────────────────────────────┐
│  EDIT → REBUILD → CONNECT → EDIT             │
│                                              │
│  Add Dev Container Configuration Files       │
│       │                                      │
│       ▼                                      │
│  Edit .devcontainer/ files                   │
│       │                                      │
│       ▼                                      │
│  Reopen in Container                         │
│       │                                      │
│       ├── Success → work in container        │
│       │                                      │
│       └── Failure → Open Folder Locally      │
│              │                               │
│              ▼                               │
│         Read build log                       │
│              │                               │
│              ▼                               │
│         Fix config → Rebuild and Reopen      │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
devcontainer.jsonMetadata file defining the environment
Location.devcontainer/ at project root
Image patternimage property, pre-built base
Dockerfile patternbuild.dockerfile + workspaceFolder
Compose patterndockerComposeFile + service
postCreateCommandOnce, after creation (dependencies)
postStartCommandEvery start (services)
FeaturesTools without Dockerfile edits
ExtensionsDeclared in customizations.vscode
Source mountHost folder bind-mounted into container

Key takeaways:

  • Dev Containers separate development environments from production images. The devcontainer.json file defines what the editor needs—compilers, debuggers, language servers—while the production Dockerfile remains minimal .
  • Three configuration sources exist. Image for simplicity, Dockerfile for customization, Compose for multi-service. Features add tools without either .
  • postCreateCommand runs once; postStartCommand runs every start. Dependency installation belongs in the former; service startup in the latter. A blocking command in either prevents the container from starting .
  • Slow dependencies belong in the Dockerfile. A postCreateCommand that runs pip install on every rebuild is slower than a cached Dockerfile layer. Use postCreateCommand for project-specific, frequently-changing setup.
  • Extensions are installed inside the container, not on the host. This ensures the language server and debugger see the same runtime as the application .
  • VS Code does not automatically rebuild on configuration changes. After editing devcontainer.json or the Dockerfile, run Dev Containers: Rebuild Container for changes to take effect .

Remember: A Dev Container is a development artifact, not a deployment artifact. Its purpose is to give every developer on the team the same environment, with the same tools, the same extensions, and the same setup commands. The devcontainer.json file declares the source (image, Dockerfile, or Compose), the lifecycle hooks that prepare the environment, and the editor customizations that make the container feel like a local workspace. The source code lives on the host and is mounted in, so rebuilds do not lose work. Get the hooks right—one-time setup in postCreateCommand, recurring services in postStartCommand—and the development loop becomes: edit, rebuild, connect, code. Get them wrong, and every rebuild requires manual repair.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!