Pinning VS Code Extension Versions in devcontainer.json
An extension that auto-updates can silently change formatting or linting for everyone, and invalidate your extension cache. This page pins exact extension versions in customizations.vscode.extensions so the editor surface is as reproducible as the rest of the environment, and upgrades are deliberate.
The reason this matters is that most of a dev container is already pinned by habit. You pin the base image tag, you pin Feature versions, you often pin the language runtime and the package lockfile. The editor is the one layer that quietly opts out of that discipline: by default the VS Code server resolves each ID in customizations.vscode.extensions to whatever the Marketplace currently serves as latest, and it will keep the installed copy current across rebuilds. That means the tools that decide how your source is formatted and which lint rules fire — dbaeumer.vscode-eslint, esbenp.prettier-vscode, and anything else in the list — can shift version without a single line of your own config changing. Pinning closes that gap so the editor is governed by the same reproducibility rules as everything else.
Reach for this when a formatter or linter changes behaviour for the whole team with no corresponding commit, when a rebuilt container installs a different extension build than the last one, or when you simply want the editor surface to be an auditable, reviewed part of the repository rather than a moving target. The mental model is straightforward: an extension ID without a version is a floating pointer, and id@version turns it into a fixed one. Once every entry is fixed, the set of bytes the server installs is deterministic, the cache volume that stores those bytes stays valid across rebuilds, and any change to the editor toolchain has to arrive as an explicit edit to devcontainer.json that a reviewer can see.
Prerequisites
You need the extension IDs and the exact versions you want to standardize on.
- The extension IDs you depend on.
- The specific versions you've validated.
customizations.vscode.extensionsyou can edit.
The one prerequisite people underestimate is getting the exact version string right. The value you pin has to be a real, published version of that specific extension, not a Marketplace display label or a semver range — id@version in customizations.vscode.extensions expects a concrete build like 3.0.10, and there is no ^ or ~ range syntax to fall back on. Find the version you actually validated from the extension's page in the Extensions view, or from the directory names already on disk under ~/.vscode-server/extensions, where each installed extension lives in a folder named publisher.name-version. Pin the version you tested against, not the newest one you can see; the whole point is that the pinned build is one you have confirmed behaves the way the team expects.
It also helps to know upfront which extensions genuinely need pinning. The ones that shape shared output — formatters, linters, language servers whose diagnostics land in code review — are the ones where a silent version change causes real churn, so those are the entries to fix first. Purely cosmetic or personal-preference extensions matter far less, and pinning every last one can turn routine housekeeping into a chore. Decide the list deliberately, then treat it as part of the repository's contract.
Step-by-Step Implementation
- List extensions by exact version using the
id@versionform.
{
"customizations": {
"vscode": {
"extensions": [
"dbaeumer.vscode-eslint@3.0.10",
"esbenp.prettier-vscode@11.0.0"
]
}
},
"remoteUser": "vscode"
}
Each string in the extensions array carries both the identity and the exact build: dbaeumer.vscode-eslint@3.0.10 tells the server to install ESLint at 3.0.10 specifically, and esbenp.prettier-vscode@11.0.0 fixes Prettier at 11.0.0. Without the @version suffix the server would resolve each ID to the current latest and keep it updated, which is precisely the drift you are eliminating. The remoteUser: "vscode" line is included deliberately because it determines whose home directory the extensions land in — /home/vscode/.vscode-server/extensions — which becomes important the moment you attach a cache volume in the next step. Keeping the pinned list and the remote user in the same file means one review captures both the exact versions and the account that owns them.
- Pair pinning with a cache volume so the pinned versions persist.
{
"mounts": [
"source=devcontainer-extensions,target=/home/vscode/.vscode-server/extensions,type=volume"
]
}
Pinning and caching are two halves of the same idea, which is why this mount matters. The named volume devcontainer-extensions is mounted at /home/vscode/.vscode-server/extensions — the exact path the pinned extensions install into for the vscode remote user — so the extension bytes survive between rebuilds instead of being downloaded fresh every time. The connection to pinning is direct: a cache is only useful when its contents stop moving. If the versions floated, the server would keep replacing the cached copy with whatever it decided was latest, and the volume would save you nothing. Because the versions are fixed, the cached publisher.name-version folders stay correct build after build, so a rebuild reuses them rather than re-fetching from the Marketplace. Match the target path to the remoteUser from the previous step; a volume mounted at the wrong home directory silently caches nothing.
- Bump versions deliberately as reviewed changes, not silently.
A version bump should look like any other dependency change: an edit to the @version suffix that goes through review, not something that happens on its own while people are working. Changing esbenp.prettier-vscode@11.0.0 to a newer build is a commit with a diff, a reviewer, and a clear before-and-after, exactly the way you would treat bumping a base image tag or a Feature version. This is what converts an upgrade from a surprise that reformats everyone's code overnight into a decision the team made on purpose. It also gives you a rollback path: if a new Prettier build changes output in a way you dislike, reverting the commit restores the previous pinned version, and the cache repopulates the known-good folder on the next build.
- Verify the pinned version is what's installed.
ls ~/.vscode-server/extensions | grep -E 'eslint|prettier'
The verification step closes the loop between what you declared and what actually landed on disk. Listing ~/.vscode-server/extensions and filtering for eslint|prettier prints the installed folder names, and because each folder is named publisher.name-version, the version you pinned should appear literally in the output — you expect to see dbaeumer.vscode-eslint-3.0.10 and esbenp.prettier-vscode-11.0.0. If the suffix on disk does not match the @version in your config, something resolved differently: a typo in the version string, a stale cache from before you pinned, or a volume mounted at a path the server never used. Running this check inside the rebuilt container turns "I think it's pinned" into a fact you can see, and it is the fastest way to catch a mismatch before it reaches a teammate's machine.
Common Pitfalls
Pinning problems are an unpinned extension or a bump that wasn't reviewed.
The most common failure sits at the boundary between the pin and the cache volume: ownership and paths. The devcontainer-extensions volume is mounted at /home/vscode/.vscode-server/extensions, and that directory has to be writable by the remoteUser the server runs as. If a volume was first populated while the container ran as root, or the target path points at a home directory that does not belong to vscode, the server cannot write the pinned builds into it, and it either falls back to re-downloading every rebuild or fails to persist anything at all. The symptom looks like pinning "not working" — versions seem to reset — when the real cause is that the cache never took ownership of the folder. Keep the remoteUser, the mount target, and the account that owns the volume aligned, and the pinned extensions land where the cache expects them.
The other pitfall is subtler: a partial pin. If only some entries in customizations.vscode.extensions carry an @version and the rest are bare IDs, you get the illusion of reproducibility while the unpinned extensions keep floating. A team can spend an afternoon chasing a formatting change that came from the one extension nobody thought to pin. Audit the whole array, not just the linter and formatter you first worried about — every ID that shapes shared output needs a version, or the surface is only partly fixed. It is also worth remembering that a pin freezes the extension but not the settings it reads; if a version bump changes a default, pinning alone will not shield you, which is why bumps still belong in review.
| Symptom | Root Cause | Remediation |
|---|---|---|
| Formatting changed for everyone | Extension auto-updated | Pin the version with id@version |
| Cache invalidated unexpectedly | Unpinned version moved | Pin so the cache stays valid |
| Team on different extension versions | Versions not pinned | Pin exact versions in the config |
| Upgrade broke a workflow | Unreviewed bump | Treat version bumps as reviewed changes |
Conclusion
Pin extensions the same way you pin base images and Features: by exact version, so behaviour can't drift under an auto-update and the cache stays valid. Bumps become deliberate, reviewable changes rather than surprises that reformat everyone's code overnight.
The strategic payoff is that the editor stops being the odd layer out. A dev container earns its reproducibility from a chain of fixed points — the image tag, the Feature versions, the lockfile — and an unpinned extension list is a break in that chain, the one place where the tooling can change without a commit. Adding @version to each entry in customizations.vscode.extensions extends the same pin-and-cache discipline you already apply everywhere else to the tools that format and lint your code, so the environment is reproducible all the way up to the diagnostics a developer sees in their editor. That is what makes "it works on my machine" a meaningful claim rather than a hopeful one: two people rebuilding from the same devcontainer.json get byte-identical extension builds, not merely similar ones.
It also compounds with the caching work around it. Because the pinned versions no longer move, the devcontainer-extensions volume stays warm and rebuilds skip the Marketplace round-trip entirely, so pinning pays for itself in build time as well as in consistency. Treat the extension list as a small, reviewed manifest: fix the versions, cache the result, and bump on purpose. The linter and formatter then behave the same for everyone until the team decides otherwise, and the decision lives in the repository's history where anyone can find it.
FAQ
Can I pin an exact extension version in devcontainer.json?
Yes — use the publisher.name@version form in customizations.vscode.extensions. The server then installs that exact version rather than the latest, so linting and formatting behaviour is fixed for the whole team until you deliberately bump it. The version has to be a concrete published build such as 3.0.10; there is no range syntax, so you name one exact version per ID. That single change is what turns a floating pointer into a fixed one, and it is the foundation the cache volume relies on.
Why does pinning matter if the extension 'just updates'? Because a silent update can change formatter output or lint rules for everyone at once, producing churn and broken workflows with no code change. Pinning makes upgrades intentional, reviewable events, and it keeps your extension cache valid since the cached version no longer moves underneath it. The cost of an unmanaged update is rarely the update itself — it is the afternoon a team spends discovering that a reformatted file came from an extension nobody edited. Fixing the version removes that entire class of surprise.
How do I upgrade a pinned extension?
Bump the @version in the config as a normal, reviewed change — ideally validating the new version first. Because the cache keys on the pinned version, it repopulates with the new one on the next build, and the team moves together rather than drifting individually. If the new build misbehaves, reverting the commit restores the previous pin and the cache serves the known-good folder again, so you always have a clean rollback. This is exactly how you would treat bumping a base image tag or a Feature version, which is the point: the editor becomes just another pinned dependency.
Does pinning extensions slow down container builds?
No — if anything it speeds them up once a cache volume is attached. A pinned version is a stable cache key, so the devcontainer-extensions volume mounted at /home/vscode/.vscode-server/extensions stays warm and the server reuses the existing publisher.name-version folders instead of re-downloading from the Marketplace on every rebuild. Unpinned extensions defeat that caching because the server keeps replacing the folder with whatever it decides is latest. Pinning is what makes the cache worth having.
What happens if I pin a version that doesn't exist?
The server cannot resolve id@version to a real build, so the extension fails to install and you will not see its folder when you list ~/.vscode-server/extensions. This is usually a typo in the version string or a version that was never published for that specific extension. The fix is to check the exact build number from the extension's page or from an already-installed folder name, then correct the @version suffix — the verification ls step catches this quickly because the expected folder simply will not appear.
Should I pin every extension in the list?
Pin the ones that shape shared output first — formatters, linters, and language servers whose diagnostics land in code review — because those are where a silent change causes real churn. Personal-preference or cosmetic extensions matter far less, and pinning every last one turns routine housekeeping into a chore. That said, beware the partial pin: if an extension influences what teammates see, leaving it unpinned reopens the drift you were trying to close, so audit the whole extensions array rather than just the two or three you first worried about.
Related
- Up to Managing VS Code Extension Caches — the parent guide on extension caching.
- Speeding Up VS Code Extension Installation in Containers — caching the pinned extensions.
- VS Code DevContainer Extension Deep Dive — how the server installs them.