The new hire spends their first day installing Node 20 instead of 22, Postgres 15 instead of 17, and a globally installed CLI that silently shadows the project’s own. Nobody notices until the test suite fails on their machine and passes everywhere else. “Works on my machine” is not a personality flaw — it is a missing environment contract.

Two tools claim to fix it: Dev Containers, which put the entire toolchain inside a container the editor attaches to, and Docker Compose, which has been describing multi-service stacks since 2014 and now ships a file-watching dev loop. They overlap just enough to be confusing, and choosing wrong costs you either flexibility or weeks of onboarding friction.

TL;DR: The 30-Second Verdict

  • One language, one toolchain, editor-integrated → Dev Containers. devcontainer.json plus a postCreateCommand gives every developer and every CI runner the same shell, the same extensions, the same versions.
  • Multi-service application with a database, queue and worker → Docker Compose, optionally inside a Dev Container via dockerComposeFile.
  • You want the dev environment to be the deployment artifact → Compose. The image you test is the image you ship.
  • You want a fully disposable machine, rebuilt in one click, for contractors or rotating contributors → Dev Containers.
  • You already have a working compose.yaml and no onboarding pain → do not migrate. Add develop.watch instead; it solves the edit-reload loop with about eight lines.

Quick Comparison

DimensionDev ContainersDocker Compose
Specificationdevcontainers/spec — 5.8k stars, frozen API since 2022docker/compose — 38.6k stars, actively developed
Reference CLIdevcontainers/cli — 3.0k stars, activedocker compose plugin
Unit of configurationdevcontainer.json per workspacecompose.yaml per stack
Editor integrationNative in VS Code, JetBrains, Codespaces, DevPodNone — you attach manually or use a plugin
Multi-service supportVia dockerComposeFile pointing at a Compose fileNative
ReproducibilityVery high — image + features + lifecycle commandsHigh — image tags and env files, but host state leaks in
OnboardingOpen folder → “Reopen in Container”Install Docker → docker compose up
Dev loopBind mount or volume, editor handles syncBind mounts, or develop.watch with sync/rebuild
CI usagedevcontainer up + devcontainer exec in CIdocker compose run --rm
Learning curveMedium — JSON schema plus lifecycle hooksLow
Best fitLanguage toolchains, disposable workspacesApplication stacks, staging-parity testing

Decision Matrix: Pick in 10 Seconds

Your situationRecommendationReason
12 developers, 3 languages, constant version driftDev ContainersOne JSON file ends the drift
Monolith + Postgres + Redis + workerCompose (optionally as a Dev Container)Compose models the stack; the container models the toolchain
CI must mirror local exactlyDev Containersdevcontainer exec runs the same image as the editor
You need GPU access for local model workEither, with explicit --gpus / runArgsBoth support it; Compose is easier to audit
Contractors on unknown laptopsDev ContainersNothing installed on the host but Docker
You want the fastest possible iteration loopCompose + develop.watchSub-second sync, no editor dependency

Dev Containers: The Toolchain as a Contract

The model is simple: your editor runs on the host, your code lives on the host (or in a volume), and every tool runs inside a container the editor attaches to. The container is described by a devcontainer.json file committed to the repository. Here is a real one from the official template starter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// .devcontainer/devcontainer.json
{
  "image": "mcr.microsoft.com/devcontainers/javascript-node:1-18-bullseye",
  "features": {
    "ghcr.io/devcontainers/features/docker-in-docker:2": {}
  },
  "customizations": {
    "vscode": {
      "extensions": [
        "mads-hartmann.bash-ide-vscode",
        "dbaeumer.vscode-eslint"
      ]
    }
  },
  "postCreateCommand": "npm install -g @devcontainers/cli"
}

Three properties make this more than a fancy Dockerfile:

  1. features are composable installers. Instead of hand-writing apt lines for Docker, Go, kubectl or AWS CLI, you reference versioned OCI artifacts. docker-in-docker:2 above is a single line that gives the container its own daemon.
  2. Lifecycle hooks are explicit. onCreateCommand runs once when the container is created, postCreateCommand after it is assigned to the workspace, postStartCommand on every start. Dependency installs belong in postCreateCommand; long builds belong in the image.
  3. The editor configuration travels with the code. Extensions, settings and forwarded ports are part of the repository, so a fresh clone behaves like an experienced developer’s machine.

Run it without an editor at all — this is how you use the same environment in CI:

1
2
3
4
npm install -g @devcontainers/cli

devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . bash -lc "npm ci && npm test"

That pair of commands is the underrated part of the story: the environment your editor uses and the environment your pipeline uses are the same artifact, not two YAML files that drift apart.

When the project needs services

Pure Dev Containers solve the toolchain, not the stack. For a database and a queue, point devcontainer.json at an existing Compose file instead of duplicating it:

1
2
3
4
5
6
7
{
  "dockerComposeFile": "../compose.yaml",
  "service": "app",
  "workspaceFolder": "/workspace",
  "forwardPorts": [3000, 5432],
  "postCreateCommand": "npm ci"
}

With dockerComposeFile, the editor starts your real stack and attaches to the app service — you get Compose’s multi-service model plus Dev Containers’ editor integration. This is the configuration most teams should end up with.

Docker Compose: The Stack You Already Have

Compose’s dev story improved decisively with develop.watch. Instead of bind-mounting your source tree and hoping inotify behaves on macOS, you declare what should happen when files change:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
services:
  app:
    build: .
    ports:
      - "3000:3000"
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: sync+restart
          path: ./config
          target: /app/config
        - action: rebuild
          path: package.json
1
docker compose up --watch

Three actions cover nearly every workflow: sync copies changed files into the container, sync+restart restarts the service after syncing (for config that is read at boot), and rebuild rebuilds the image when dependencies change. In practice this gets you most of the inner-loop speed of a native install without touching the host.

Where Compose still wins outright:

  • Parity with production. The same compose.yaml can run on the server. That is not true of devcontainer.json.
  • Multi-service default. Services, networks, volumes, health checks and startup order are first-class.
  • No editor dependency. Any terminal works; nothing needs to attach.
  • Profiles for optional services. --profile debug brings up Mailhog and a debug proxy only when you ask.

Where it quietly fails as a dev-environment contract: the host still owns the toolchain. node, python, make and the shell itself run outside the container unless you carefully wrap every command in docker compose exec. Two developers with different host versions can still get different results — the exact problem Dev Containers was built to eliminate.

Mixing Both Without Regret

The pragmatic architecture in 2026:

LayerToolWhy
Editor, shell, linters, language runtimeDev ContainerIdentical for everyone, disposable
Application services (db, cache, queue)Compose file referenced by dockerComposeFileReal topology, health checks, one source of truth
Production deploymentCompose (or an orchestrator)Same images, same env contracts
CIdevcontainer exec or compose runWhichever artifact you trust more

If you go this route, keep the Compose file free of dev-only bind mounts and put host-specific overrides in compose.override.yaml. The Dev Container then consumes the base file, and production consumes base plus a server-side override — one topology, three entry points.

For the management layer around these stacks, our Portainer vs Dockge vs Coolify comparison covers UI options, and the Compose vs podman-compose vs nerdctl comparison covers which CLI actually implements the spec. If your dev containers need to run rootless, the rootless container runtimes guide explains the UID-mapping mechanics.

Pitfalls: The Ones That Cost Days

  • File ownership drift. Containers write as root, your host user cannot edit the files, and suddenly the repo needs sudo. Fix it with remoteUser, a matching UID/GID build argument, or "updateRemoteUserUID": true in devcontainer.json.
  • node_modules on a bind mount. Thousands of tiny files over a network filesystem is the slowest possible configuration. Put dependency directories in a named volume instead.
  • Docker socket exposure. Mounting /var/run/docker.sock gives the container root-equivalent control of the host. Prefer docker-in-docker with its own daemon, or a rootless socket, and never do it on a shared machine.
  • postCreateCommand doing too much. Every rebuild of the container re-runs it. Compile-heavy steps belong in the image (build.dockerfile + features), not in the hook.
  • Compose watch syncing build output. If dist/ is inside the watched path, the sync triggers a rebuild which regenerates dist/, which triggers the sync. Exclude build directories explicitly.
  • Architecture mismatch on ARM laptops. An amd64-only base image under emulation is slow enough to feel broken. Choose multi-arch base images and test the linux/arm64 path.
  • Secrets in containerEnv. Committed .env files leak. Use the CLI’s secret support or a local, gitignored mount.

FAQ

Are Dev Containers and Docker Compose the same thing?

No. Compose describes a multi-container application; Dev Containers describe a development environment attached to an editor. You can use them together by pointing dockerComposeFile at a Compose file, which is the recommended setup for anything with a database.

Do I need VS Code to use Dev Containers?

No. The @devcontainers/cli runs the same specification headlessly — devcontainer up and devcontainer exec work in any terminal and in CI. VS Code, JetBrains IDEs, Codespaces and DevPod are clients of the same spec.

Which one is faster for day-to-day coding?

For a single-language project, a Dev Container with a warm image is as fast as native for editing and faster for onboarding. For edit-reload cycles on a multi-service stack, Compose’s develop.watch with action: sync is usually snappier than a full container restart, because it only copies changed files.

Can I use Compose watch and Dev Containers at the same time?

Yes, but pick one owner for the dev loop. If the editor attaches to the app service, let the editor handle file sync and keep develop.watch for the services the editor does not manage. Running both on the same paths causes duplicate rebuilds.

How do I keep the dev environment from drifting from production?

Build both from the same base image and the same lockfiles, and pin image digests. Treat compose.yaml as the topology contract and devcontainer.json as the toolchain contract — when either changes, both CI and production should rebuild from the same commit.


💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com