Devcontainers vs Bare Docker Compose for Team Onboarding
You need a new engineer productive on day one, and you are deciding whether to ship a .devcontainer/devcontainer.json that wraps the stack inside the editor or a plain docker-compose.yml they run from the terminal. Both build on the same image and orchestration layer covered in the parent topic Devcontainer Configuration Standards and in Multi-Service Orchestration with Compose; the choice is about where the developer's tools live, not whether you use containers at all.
The mistake teams make is treating this as a religious question. It is not. It is a trade between two costs you can actually measure: the minutes a first-time clone takes to reach a running app, and the hours per quarter you lose to "works on my machine" tickets caused by drifting host toolchains. This page gives you the criteria, a repeatable procedure to score your own repo, and the exact files for either outcome.
The two options in one sentence each
Bare Docker Compose runs your services in containers, but the developer's editor, language server, linters, and debugger run on the host and connect to exposed ports. Devcontainers put the editor's backend (extensions, language servers, terminals) inside a container too, so the toolchain itself is version-controlled and identical for everyone.
A devcontainer almost always consumes a Compose file via dockerComposeFile. So this is not "Compose or devcontainers" at the infrastructure layer — it is "do we also containerize the IDE backend." That framing matters because it means adopting devcontainers later is additive: you keep the Compose file you already trust and layer editor provisioning on top of it, rather than rewriting your orchestration.
The distinction shows up the first time a linter disagrees between two laptops. With bare Compose the linter is whatever version each engineer happened to brew install, so the same file can pass on one machine and fail CI. With a devcontainer the linter version is pinned in the image and pulled by everyone, so the disagreement moves from "your machine" to "the config" — a class of problem you can fix once.
Decision criteria
| Criterion | Bare Docker Compose | Devcontainers |
|---|---|---|
| First-run startup cost | Low — docker compose up; host tools already installed |
Higher — pulls/builds a dev image, installs features and extensions on first open |
| IDE integration | Manual — host editor connects to forwarded ports; each dev installs extensions themselves | Automatic — customizations.vscode.extensions and settings provision the editor for everyone |
| Reproducibility of the toolchain | Partial — services reproducible, but host Node/Python/linters drift per machine | High — language runtimes, CLIs, and extensions pinned in the container |
| CI parity | Good for service behavior; build steps may differ from host tooling | Strong — the same image can back local dev and CI runners |
| Learning curve | Lowest — engineers already know docker compose |
Moderate — devcontainer.json lifecycle, features, and the CLI are new to most |
| Editor lock-in | None — any editor works | Best in VS Code; JetBrains and devcontainer CLI support exists but is less polished |
| Offline / air-gapped friendliness | Easier — fewer registries to reach | Harder — features and extensions pull from ghcr.io and the marketplace |
Read the table as two axes, not seven independent rows. The left three rows (startup cost, IDE integration, toolchain reproducibility) trade against each other along a single line: you buy reproducibility with first-run minutes. The right rows (CI parity, learning curve, lock-in, air-gap) are constraints that can veto a choice regardless of where you land on the first axis. If your team must support Vim and JetBrains, editor lock-in vetoes a devcontainer-only mandate no matter how much you value reproducibility.
Choose bare Docker Compose when
- Your team uses mixed editors (Vim, Emacs, JetBrains, VS Code) and you cannot mandate one.
- Onboarding speed on a known machine matters more than toolchain reproducibility — engineers already have Node/Python/Go installed and consistent.
- You run in constrained or air-gapped networks where pulling features and marketplace extensions is unreliable.
- The stack is small (1–3 services) and the toolchain rarely drifts.
The defining property of the bare-Compose path is that it makes no claim over the editor. It reproduces the services and leaves the tools to the host. For a team of experienced engineers with self-managed environments that is often the right split: you get deterministic databases and message brokers without imposing an editor workflow on people who already have one. The cost is invisible until it bites — a formatter that reflows a file differently on one laptop, or a language server pinned to a runtime two minors behind CI.
A minimal Compose-first setup looks like this:
# docker-compose.yml
services:
app:
build:
context: .
target: dev
ports:
- "${APP_PORT:-3000}:3000"
volumes:
- ./src:/app/src:cached
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 5s
retries: 5
Wrap the first run in a script so a new hire never has to remember flags. The --wait flag blocks until every service with a healthcheck reports healthy, which turns "it started but the DB was not ready" into a deterministic gate:
#!/usr/bin/env bash
# bin/up.sh — bare Compose onboarding
set -euo pipefail
cp -n .env.example .env || true
docker compose up -d --wait
echo "Stack ready on http://localhost:${APP_PORT:-3000}"
To keep host drift from silently breaking builds, publish a minimum-versions check that runs on first clone and in CI. It does not pin the toolchain the way a devcontainer would, but it converts an invisible drift into a loud, actionable error:
#!/usr/bin/env bash
# bin/check-host-tools.sh — fail fast on drifted host tooling
set -euo pipefail
need() { command -v "$1" >/dev/null || { echo "missing: $1"; exit 1; }; }
need docker
need node
required_node=20
have_node=$(node -p 'process.versions.node.split(".")[0]')
if [ "$have_node" -lt "$required_node" ]; then
echo "node $required_node+ required, found $have_node"; exit 1
fi
echo "host toolchain OK"
Choose devcontainers when
- "Works on my machine" failures trace back to host tool versions, not service config.
- Most of the team is on VS Code and you want extensions, formatters, and settings provisioned automatically.
- You want one image to back both local development and CI, maximizing the kind of parity described in automating runtime parity checks between local and staging.
- New hires should not need to install a single language runtime locally.
The devcontainer path makes a stronger claim: the editor backend is part of the reproducible unit. Extensions, formatter versions, language-server settings, and the runtime all ship in the image, so the thing that opens the code is as pinned as the thing that runs it. That is what collapses the "works on my machine" class of ticket — there is no "my machine" left in the loop, only the image. The price is paid up front, on the first open, when the image builds and features and extensions install.
The devcontainer wraps the same Compose file and adds editor provisioning:
// .devcontainer/devcontainer.json
{
"name": "Platform Baseline",
"dockerComposeFile": ["../docker-compose.yml"],
"service": "app",
"workspaceFolder": "/app",
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "20" }
},
"customizations": {
"vscode": {
"extensions": ["[email protected]", "esbenp.prettier-vscode"],
"settings": { "editor.formatOnSave": true }
}
},
"postCreateCommand": "npm ci"
}
Note that dockerComposeFile points at the same docker-compose.yml from the bare-Compose section. The devcontainer adds the features, customizations, and postCreateCommand keys around it — it does not replace it. That is the single most important structural fact on this page: the two options share one orchestration file, so the decision is reversible and the migration is never a rewrite.
A repeatable way to decide
Rather than argue from preference, score your own repository. Run these steps once, write down the numbers, and let them make the call:
- Measure the bare-Compose baseline. On a machine with a clean clone, run
bin/up.shand record clone-to-ready seconds. This is your floor. - Count host-drift tickets. Grep the last quarter of your issue tracker or support channel for "works on my machine", version mismatch, and formatter/linter disagreement reports. This is the recurring cost devcontainers remove.
- Enumerate editors in use. If more than a third of the team is off VS Code and unwilling to switch, editor lock-in vetoes a devcontainer mandate — stop here and standardize Compose plus the host-tools check.
- Check network posture. If build agents or laptops cannot reach
ghcr.ioand the extension marketplace reliably, devcontainers add fragility; prefer Compose with a vendored base image. - Estimate the payback. Divide the extra first-run minutes a devcontainer adds by the per-quarter hours lost to drift tickets. If drift dominates, containerize the editor backend; if first-run speed dominates and drift is near zero, keep it bare.
What the numbers actually look like
The trade is easiest to see as clock time. A bare-Compose first run reuses everything already on the host, so it is dominated by the service pull and healthcheck wait. A devcontainer first open adds an image build plus feature and extension installation — often two to four minutes on a cold cache — but that cost is paid once per image change, not once per engineer per incident. Meanwhile every avoided drift ticket saves a context-switch measured in tens of minutes. The bars below are representative figures from a three-service Node stack; measure your own with the timing script that follows.
Don't pick once and freeze it
A pragmatic path is to keep docker-compose.yml as the source of truth and layer a devcontainer.json on top. Terminal-only engineers run docker compose up; VS Code users open the folder in the container. Both consume the same services, so you avoid maintaining two divergent stacks. Keep extensions and settings shareable as described in sharing VS Code extensions and settings across a team.
This "both, sharing one Compose file" arrangement is not a fence-sit — it is the lowest-total-cost configuration for most teams past a handful of engineers. The Compose file stays authoritative for what runs; the devcontainer is an optional editor overlay that anyone can opt into without touching the services. When a new hire on VS Code hits a drift ticket, you move them into the container; when a Vim user joins, they never notice the devcontainer exists. You maintain one stack and offer two front doors to it.
Validating the decision
Whichever you pick, measure it. Time a clean clone-to-running-app on a fresh machine and track the first-run success rate, the same metric onboarding teams use in how to measure developer onboarding time in distributed teams.
#!/usr/bin/env bash
# bin/time-onboarding.sh — clone-to-ready timing
set -euo pipefail
start=$(date +%s)
docker compose up -d --wait
end=$(date +%s)
echo "Clone-to-ready: $((end - start))s"
Run this on a machine that mirrors a real new hire's laptop, not a warm CI runner, or the number will flatter you. Record it alongside the success rate — the fraction of fresh clones that reach a running app without a human intervening. A format that is thirty seconds faster but fails one clone in five is worse than a slower one that never fails, because every failure pulls a senior engineer into a debugging session. Track both numbers per quarter and let the trend, not a one-time benchmark, guide whether you keep or change formats.
Platform caveats
macOS (Docker Desktop): devcontainer first-open is slower because image build and bind-mount warmup run inside the Linux VM; use
:cachedmounts to soften it. WSL2: keep the repo on the Linux filesystem (~/code, not/mnt/c) for both options, or file-watch and devcontainer attach degrade. Apple Silicon (ARM64): pinplatform: linux/amd64only for images lacking arm64 manifests; otherwise prefer native arm64 to avoid emulation in both setups.
Rollback
Reverting a devcontainer to bare Compose is a delete, not a migration — the services never depended on it:
#!/usr/bin/env bash
set -euo pipefail
git rm -r .devcontainer
git commit -m "revert to bare Compose onboarding"
# engineers reopen the folder locally; docker compose up is unchanged
Frequently Asked Questions
Does a devcontainer replace my docker-compose.yml?
No. A devcontainer references your Compose file through dockerComposeFile and adds editor provisioning around it. The services, networks, and volumes are still defined in docker-compose.yml, so terminal-only engineers can run docker compose up against the exact same stack the devcontainer uses.
Can I support both VS Code devcontainer users and terminal-only engineers from one repo?
Yes, and it is the recommended setup. Keep docker-compose.yml as the source of truth and add a .devcontainer/devcontainer.json that points at it. VS Code users open the folder in the container; everyone else runs docker compose up. Both consume the same services, so you maintain one stack with two entry points.
Why is my first devcontainer open so much slower than docker compose up?
The first open builds the dev image and installs features and extensions from ghcr.io and the marketplace, which bare Compose skips because your host tools already exist. This cost is paid once per image change, not per engineer. Once the layer cache is warm, reopening is close to Compose startup time.
Do devcontainers work outside VS Code?
Partly. The @devcontainers/cli can build and run a devcontainer headlessly, and JetBrains has support, but extension and settings provisioning is most complete in VS Code. If a third or more of your team uses other editors and will not switch, that lock-in usually vetoes a devcontainer-only mandate — offer it as an optional overlay instead.