Sharing VS Code Extensions and Settings Across a Team
New hires keep formatting files differently and linting locally with different rules because each developer installs their own extensions and tweaks their own settings. The fix is to pin the editor's extensions and settings in devcontainer.json so opening the project provisions an identical toolchain for everyone, following the same devcontainer configuration standards that pin base images and features. Because the editor config travels inside the container that also runs Docker Compose orchestrated services, the linter, formatter, and language server everyone runs become a property of the repository rather than a property of each laptop.
Diagnostic
Confirm the drift before standardizing. Each developer's extension list and effective settings differ, and the first symptom is usually a pull request where half the diff is whitespace or quote-style churn nobody intended. Start by dumping the installed extension set on two machines and comparing them:
#!/usr/bin/env bash
# audit-extensions.sh — list what each dev actually has installed
set -euo pipefail
code --list-extensions --show-versions
# BAD: two developers, two different toolchains
# Dev A
[email protected]
[email protected]
# Dev B
[email protected]
# (no prettier — formats with editor default, producing diff noise)
Two problems are visible here. Dev A and Dev B run different major versions of ESLint's extension, which resolve rules differently, and Dev B has no Prettier extension at all, so their editor falls back to the built-in formatter and reflows lines on save. The second, quieter failure lives in settings rather than extensions. Workspace settings that are not committed live only in each user's profile, so editor.formatOnSave, editor.tabSize, and editor.defaultFormatter vary per machine. You can prove the settings drift by asking each developer to open the command palette, run Preferences: Open User Settings (JSON), and diff the results — they will not match. Quantifying the cost makes the case for standardizing: on a team of six, an unpinned toolchain typically produces several formatting-only diffs per week that reviewers must mentally filter out.
To make the drift concrete rather than anecdotal, collect the two signals that actually matter — the installed extension identifiers and the effective value of the formatter keys — and compare them across machines. The command below writes both to a per-developer file you can diff side by side; when the sorted extension lists or the extracted settings differ, you have proof the toolchain is not shared:
#!/usr/bin/env bash
# snapshot-toolchain.sh — capture the two signals that drive formatting drift
set -euo pipefail
who="${1:-$(whoami)}"
out="toolchain-${who}.txt"
code --list-extensions --show-versions | sort > "$out"
# extract the formatter-relevant keys from the user profile, if present
settings="${HOME}/.config/Code/User/settings.json"
if [ -f "$settings" ]; then
grep -E 'formatOnSave|defaultFormatter|files.eol|tabSize' "$settings" >> "$out" || true
fi
echo "wrote $out — diff it against a teammate's file"
Root cause
VS Code stores extensions and user settings in the per-user profile, not in the repository. Without a committed .devcontainer/devcontainer.json customizations.vscode block (or a .vscode/ folder for non-container setups), nothing forces consistency. The editor reads settings from three layers — user, remote, and workspace — and merges them, with the most specific layer winning. When only the user layer is populated, every laptop supplies its own values and there is no shared source of truth. The result is divergent linters, formatters, and editor behavior that surface as noisy diffs and inconsistent lint results.
Extensions have the same failure shape. A recommendation in .vscode/extensions.json only prompts a developer to install something; it does not guarantee the version, and it does nothing at all if the developer dismisses the prompt. That is why the durable fix lives in the devcontainer: the container image is rebuilt from a committed spec, so the extensions listed under customizations.vscode.extensions are installed into the container's editor server every time it is created, at the exact versions you pin. The repository, not the developer's memory, becomes the authority.
Resolution
- Pin extensions and settings inside
customizations.vscodeindevcontainer.json. Use exact extension version pins to prevent silent breaking updates. Thesettingsobject here becomes the remote/workspace layer that every container inherits, so values likeeditor.formatOnSaveno longer depend on anyone's personal preferences.
// .devcontainer/devcontainer.json
{
"name": "Platform Baseline",
"dockerComposeFile": ["../docker-compose.yml"],
"service": "app",
"workspaceFolder": "/app",
"customizations": {
"vscode": {
"extensions": [
"[email protected]",
"[email protected]",
"[email protected]"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" },
"files.eol": "\n"
}
}
},
"postCreateCommand": "npm ci"
}
- Pin language runtimes and CLIs as
featuresso the linters and formatters resolve the same binaries everywhere. Without this, a pinned ESLint extension can still shell out to a different Node version and produce different results, so the runtime pin and the extension pin must agree.
// .devcontainer/devcontainer.json (features excerpt)
{
"features": {
"ghcr.io/devcontainers/features/node:1": { "version": "20" },
"ghcr.io/devcontainers/features/git:1": { "version": "latest" }
}
}
- Recommend the same extensions for engineers who do not open the folder in a container by committing
.vscode/extensions.json. VS Code prompts them to install the set. This is a fallback for host-side editing, not the primary control — the container remains authoritative.
// .vscode/extensions.json
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"ms-azuretools.vscode-docker"
]
}
- Commit workspace settings in
.vscode/settings.jsonfor host-side editors, mirroring the containersettingsblock so both paths converge. Keep the two blocks in sync deliberately; if they diverge, a developer editing on the host and one editing in the container will format the same file differently again.
// .vscode/settings.json
{
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.eol": "\n"
}
- Reopen the folder in the container so VS Code installs the pinned extensions and applies the settings.
#!/usr/bin/env bash
# rebuild the devcontainer to apply pinned extensions
set -euo pipefail
devcontainer up --workspace-folder . --remove-existing-container
Settings precedence and layering
Understanding which layer wins prevents the most common surprise: a developer overrides a shared value in their user settings and then reports that the team formatter "does not work." VS Code merges settings from four sources in increasing order of specificity, and a later layer overrides an earlier one only for the keys it names. Knowing this order tells you exactly where to put a value so it holds for everyone.
The container's customizations.vscode.settings block is applied as remote settings, which sit above the user profile but below the committed workspace file. That is why a workspace .vscode/settings.json is the strongest place to enforce a non-negotiable like files.eol, and why you should keep the container block and the workspace file describing the same intent. Values a developer legitimately personalizes — color theme, font size, key bindings — should stay in the user profile and be omitted from both committed blocks, so you standardize the toolchain without dictating cosmetics.
A practical rule follows from the layering: put a key in exactly one committed place unless you have a reason to override per folder. Duplicating the same key across the container block and the workspace file is fine and intentional here because the two blocks serve different entry paths, but duplicating a key across the workspace file and a nested folder file invites the two to drift apart. When a value must be identical for the container and host paths, treat the container block as the source and generate or lint the workspace file from it, rather than editing both by hand. This keeps the precedence chain predictable: a reviewer reading a pull request can see which layer a value lives in and therefore who it applies to.
Expected output
After reopening in the container, the installed set matches the pin exactly on every machine:
[email protected]
[email protected]
[email protected]
To confirm settings applied and not just extensions, open any tracked file, make a trivial edit, and save; the file should reflow to the shared Prettier configuration and ESLint auto-fixes should apply. You can verify the effective formatter programmatically by checking that a deliberately misformatted fixture is corrected on save in a headless run. When two developers repeat this on different operating systems and produce byte-identical output for the same input, the toolchain is genuinely shared rather than coincidentally similar.
A stronger, non-interactive check runs the same formatter the editor uses from the command line and asserts the working tree is already clean. Because the container pins both the Prettier extension and the Node runtime, the CLI Prettier resolves to the same version the editor invokes, so a passing check here means an editor save would have made no change:
#!/usr/bin/env bash
# verify-formatted.sh — the tree must already match the shared formatter
set -euo pipefail
npx --no-install prettier --check "src/**/*.{ts,js,json}"
echo "all files already match the pinned formatter"
If this passes for one developer and fails for another on an unchanged tree, the two are not running the same formatter version, which points straight back to an unpinned extension or a mismatched runtime feature.
Prevention
Gate the configuration in CI so a malformed or unpinned change cannot merge. The first check rejects any extension entry that lacks a semver pin, which is where drift creeps back in:
# .github/workflows/devcontainer-lint.yml
name: devcontainer-lint
on:
pull_request:
paths: [".devcontainer/**", ".vscode/**"]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Reject floating extension pins
run: |
! grep -E '"[a-z0-9-]+\.[a-zA-Z0-9-]+"\s*[,\]]' .devcontainer/devcontainer.json \
| grep -vqE '@[0-9]+\.[0-9]+\.[0-9]+'
Add a second, local guard so problems are caught before they ever reach CI. A pre-commit hook that validates the JSON and asserts the container and workspace settings agree keeps the two blocks from silently diverging:
#!/usr/bin/env bash
# .git/hooks/pre-commit — fail fast on broken or drifted editor config
set -euo pipefail
# devcontainer.json is JSONC; strip comments before validating
sed 's://.*$::' .devcontainer/devcontainer.json | python3 -c 'import json,sys; json.load(sys.stdin)'
# assert the shared formatOnSave intent is present in both places
grep -q '"editor.formatOnSave": true' .devcontainer/devcontainer.json
grep -q '"editor.formatOnSave": true' .vscode/settings.json
echo "devcontainer + workspace editor config OK"
For deeper enforcement of the same standard across many repositories, apply the DRY devcontainer config in a monorepo approach so a single shared base defines the extension and settings set and each project extends it.
Platform caveats
macOS (Docker Desktop): extension install runs inside the Linux VM on first open; expect a one-time delay, not a recurring cost. WSL2: keep the repo on the Linux filesystem (
~/code, not/mnt/c) so the Dev Containers extension resolves${localWorkspaceFolder}to a native path. Apple Silicon (ARM64): a few extensions ship architecture-specific native binaries; verify they have arm64 builds or the language server may fail to start.
Rollback
If a pinned version regresses a workflow, revert the committed config and rebuild so the container drops back to the previous known-good toolchain. Because the state lives in the repository, rollback is an ordinary git checkout rather than a fleet-wide manual reinstall:
#!/usr/bin/env bash
set -euo pipefail
git checkout -- .devcontainer/devcontainer.json .vscode/
devcontainer up --workspace-folder . --remove-existing-container
Frequently Asked Questions
Do the pinned customizations.vscode.extensions apply when I open the folder without a container?
No. The customizations.vscode block is consumed by the Dev Containers extension when it builds or attaches to the container. Opening the folder as a plain host workspace ignores it entirely. For host-side editing, the .vscode/extensions.json recommendations and .vscode/settings.json workspace file are what apply, which is why the resolution commits both — the container path and the host path must describe the same toolchain.
Why pin an exact extension version instead of letting VS Code install the latest?
Latest means every developer who rebuilds on a different day can get a different major version, and extension major bumps change lint rules and formatter behavior. Pinning [email protected] guarantees the same rule resolution on every machine and turns an upgrade into a reviewed, single-commit change rather than an untracked drift that shows up as mysterious new lint failures.
A teammate's user settings override the shared formatter — how do I stop that?
Move the non-negotiable keys into the committed .vscode/settings.json workspace layer, which outranks the user profile for those keys. User settings only win for keys the workspace does not set. If you need a value to hold even against workspace overrides, place it in folder-level settings, the strongest layer. Leave cosmetic preferences like theme and font in the user profile so you standardize behavior without dictating appearance.
Does committing .vscode/settings.json force my personal theme on everyone?
Only if you put theme keys in it. The workspace file should contain toolchain-affecting keys — editor.formatOnSave, editor.defaultFormatter, files.eol, editor.codeActionsOnSave — and nothing cosmetic. Keys you omit fall through to each developer's user profile, so color theme, font size, and key bindings remain personal while formatting and linting stay uniform.