Speeding Up VS Code Extension Installation in Containers

A cold container downloads every extension VSIX and language-server binary, adding a minute or more to attach. This page cuts that time with three levers: a named-volume cache for the extensions directory, a prebuilt server image that bakes them in, and keeping the extension set lean.

The reason this matters is that extension installation is not a one-time cost you pay when you first set up a project. Every time the container image changes, every time a teammate clones fresh, and every time CI spins up a disposable environment, VS Code re-populates ~/.vscode-server/extensions from scratch. A single heavy language extension can pull tens of megabytes of server binaries, and a project that leans on ESLint, a Python toolchain, and a database client can easily reach a dozen extensions. Multiply that across a team that rebuilds several times a week and the aggregate wait becomes a real tax on iteration speed. The goal here is to make the second attach — and every attach after it — pay almost nothing.

Reach for these techniques when attach latency has become noticeable rather than as premature optimization on a brand-new devcontainer. The mental model is a cache hierarchy: the named volume keeps already-downloaded VSIX files across rebuilds on a single machine, the prebuilt server image ships those files inside the image so even a first-ever create is warm, and a lean extension set shrinks the amount of data any of those layers has to move. Start with the cache volume because it is the cheapest change and delivers the largest single improvement; add the prebuilt server only when the remaining first-attach cost still hurts, typically in ephemeral Codespaces or CI where no local volume survives.

Prerequisites

You need your extensions declared and a place to mount a cache volume.

  • Extensions declared in customizations.vscode.extensions.
  • A named volume for the server extensions directory.
  • Optionally, a prebuilt image pipeline for the server.

Declaring extensions in customizations.vscode.extensions is the foundation everything else builds on, because it makes the extension set reproducible and machine-readable. If your extensions live only in a personal profile or were installed ad hoc after attach, there is nothing for the cache volume to reliably repopulate and nothing for a prebuild step to install ahead of time. Move the full list into devcontainer.json first, commit it, and confirm a fresh rebuild ends up with exactly those extensions and no strays.

The detail people get wrong is the mount path. The extensions directory lives under the container user's home, so it is /home/vscode/.vscode-server/extensions for the vscode user but /root/.vscode-server if the container runs as root. Point the volume at the wrong home and the cache is created, mounted, and then quietly ignored because VS Code writes its extensions somewhere else. Confirm which user your container attaches as — the remoteUser value — before you settle on the target path, and keep the volume path and remoteUser in agreement.

Speed prerequisitesA cache volume, an optional prebuilt server, and a lean extension set drive fast attach.Cache volumeextensions dirPrebuilt serverbake VSIXLean setfewer extensionsVerifyfast attach

Step-by-Step Implementation

  1. Cache the server extensions directory on a named volume.
{
  "mounts": [
    "source=devcontainer-extensions,target=/home/vscode/.vscode-server/extensions,type=volume"
  ],
  "remoteUser": "vscode"
}

The mounts entry attaches a named Docker volume called devcontainer-extensions at the exact directory where the VS Code server writes extensions. Because a named volume persists independently of the container's writable layer, tearing down and rebuilding the container leaves the volume intact, so the next attach finds the VSIX files and language-server binaries already present and skips the download entirely. Using type=volume rather than a bind mount matters here: a named volume is managed by Docker and keeps native Linux permissions, whereas a bind mount from the host can drag in host filesystem quirks and slow I/O. Pairing the mount with remoteUser: "vscode" is what keeps the target path honest — the volume lands on the same home directory the server actually uses, which is the whole point of the cache.

  1. Prebuild a server image that bakes the extensions in (or use Codespaces prebuilds).
# In a prebuild step, install extensions into the server ahead of time
RUN code-server --install-extension dbaeumer.vscode-eslint || true

Running --install-extension inside the image build moves the download cost from attach time to build time, where it happens once and is shared by everyone who pulls the image. The extensions become part of an immutable image layer, so a fresh create — even on a machine that has never seen this project and has no cache volume — starts with the extensions already on disk. The trailing || true is deliberate defensiveness: extension installs occasionally fail transiently against the marketplace, and without it a single flaky download would fail the entire image build. In a real pipeline you would list each extension you declared in customizations.vscode.extensions here so the baked-in set matches what VS Code expects, avoiding a mismatch where the image ships some extensions and the client still races to install the rest.

  1. Keep the extension set lean so there's less to install.

There is no command to run for this step because the work is editorial: audit the customizations.vscode.extensions array and remove anything a teammate installed for a one-off investigation or that duplicates functionality already provided. Every extension you drop is one fewer VSIX to download, cache, prebuild, and activate, and it also shrinks server startup because each extension runs its own activation code. A lean set compounds the other two levers — a smaller cache volume warms faster and a smaller prebuilt image pulls faster — so treating the list as something to curate rather than accumulate keeps the whole pipeline fast over time.

  1. Verify the cache is reused on rebuild.
ls ~/.vscode-server/extensions | wc -l   # populated without a fresh download

This check runs inside the attached container after a rebuild and simply counts the entries in the extensions directory. A non-zero count on the very first attach following a rebuild is the signal you want: it means the named volume repopulated the directory rather than VS Code fetching everything again. If the count comes back as zero or unexpectedly small, the cache did not take — usually the volume is pointed at the wrong home directory or a fresh volume was created because the mount source name changed. Watching this number across a couple of rebuilds is the fastest way to confirm the cache is doing its job before you invest in a prebuild pipeline.

Install time by strategyA cache volume and a prebuilt server cut extension install time to seconds.Cold download62sWarm cache volume8sPrebuilt server3sillustrative install time

Common Pitfalls

Slow installs come from a missing cache mount, a cache on the wrong path, or too many extensions.

The subtlest failure involves ownership rather than the path itself. When Docker first creates the devcontainer-extensions volume it is owned by root, but the server writes as the vscode user, so on some setups the first attach cannot write into the mounted directory and either errors or silently falls back to a temp location. If extensions seem to install but never persist across rebuilds, check the ownership of /home/vscode/.vscode-server/extensions inside the container; it should be owned by the vscode user, not root. A postCreateCommand that runs chown on the directory, or letting the devcontainer feature manage the user, resolves the mismatch and lets the cache actually hold its contents.

The second trap is stale or version-drifting extensions inside the cache. Because the volume persists, an extension that auto-updated on one attach stays updated for everyone sharing that volume, and a cache that outlives an intended pin can quietly serve a newer build than devcontainer.json declares. If reproducibility matters, pin extension versions and periodically prune the cache so it reflects the declared set rather than an accumulation of whatever happened to be installed. The speed win from caching is only worthwhile if the cached contents still match what the project expects to run.

Speed triageA triage path from slow extension installs to a fast, cached attach.Does a rebuild reuse the extensionscache?NOMount the extensions dir on a volumeStill slow on first attach?YESPrebuild the server imageFast, reproducible attach

SymptomRoot CauseRemediation
Extensions re-download each rebuildNo cache volumeMount the server extensions dir on a volume
Cache present but unusedVolume on the wrong pathTarget the exact extensions directory
First attach still slowNo prebuilt serverBake extensions into a prebuilt image
Install time grows over releasesExtension set bloatedTrim to essential extensions

Conclusion

Three levers, biggest first: cache the extensions directory on a named volume, prebuild the server to bake extensions in, and keep the set lean. The cache alone turns a minute of cold installation into a few seconds; the prebuild removes the remaining first-attach cost, especially in Codespaces.

The strategic payoff is that fast, predictable attach lowers the friction of treating containers as disposable. When re-creating an environment costs a few seconds instead of a minute, developers stop nursing long-lived containers to avoid the rebuild penalty and start rebuilding freely — which is exactly the behavior that keeps a devcontainer honest and reproducible. The extension cache is therefore not just a speed tweak; it is what makes the throwaway-environment discipline practical day to day.

These levers also sit squarely inside the broader pin-and-cache theme that runs through reproducible devcontainers. Pinning extension versions in devcontainer.json gives the cache and the prebuild a fixed target to reproduce, the named volume and the prebuilt image are the caches that make that target cheap to reach, and pruning keeps both aligned with what the project declares. Speeding up extension installation is really the same reproducibility problem viewed through the lens of time: the more precisely you declare the extension set, the more aggressively you can cache and prebuild it without drift, and the faster every attach becomes.

Wins and upkeepCaching and prebuilding are the big wins; pinning and pruning keep them valid.Biggest winsCache volumePrebuilt serverLean extension setKeep valid viaPinned versionsRight cache pathPrune stale

FAQ

What's the single biggest speedup? A named-volume cache on the server's extensions directory. It makes a rebuild reuse the already-downloaded VSIX and language-server binaries instead of fetching them again, turning a minute-plus cold install into a few seconds. It's the first change to make. The reason it beats every other tweak is that it eliminates network round-trips entirely on the second and later attaches, and network fetches from the marketplace are the slowest part of a cold install. It also requires nothing more than a single mounts entry, so the effort-to-payoff ratio is unmatched among the three levers.

When is a prebuilt server image worth it? When first-attach latency still matters after caching — most often in Codespaces, where prebuilds bake the server and extensions into the image so a create is nearly instant. Locally, the cache volume usually suffices; add a prebuilt server only if attach speed is still a bottleneck. The distinction that matters is whether a local volume survives between environments: on a developer laptop it does, so the cache carries the load, but in ephemeral CI runners or fresh Codespaces there is no persistent volume and the prebuilt image is the only thing that keeps a first-ever create warm. Weigh the added build-pipeline complexity against how often you create truly fresh environments.

Does the number of extensions matter? Yes. Every extension is a download and a bit of server startup, so a bloated set is slower to install and to activate. Trim to the extensions the project actually needs; a lean set is faster to cache, faster to prebuild, and faster to load. Beyond raw install time, fewer extensions also mean fewer activation events firing when a workspace opens, which keeps the editor responsive right after attach rather than churning through background initialization. Treat the extension list as a curated dependency set that earns its place, not a junk drawer that only ever grows.

Why does my cache work locally but not in CI? Because named volumes are tied to a single Docker host and do not travel between machines. A CI job typically starts on a clean runner with no pre-existing devcontainer-extensions volume, so the first — and often only — attach is always cold. The fix in CI is the prebuilt server image, which carries the extensions inside the image layer that the runner pulls, rather than relying on a volume the runner never had. Reserve the volume strategy for long-lived local machines where it can actually persist.

Can I share one extensions cache across multiple projects? You can, by pointing several devcontainers at the same named volume, but it is usually a mistake if the projects declare different extension sets or versions. A shared volume accumulates the union of every project's extensions and lets one project's auto-update leak into another, undermining the per-project reproducibility that pinning is meant to guarantee. A dedicated volume per project keeps each cache aligned with its own devcontainer.json, and the disk cost of separate caches is small next to the confusion a shared one creates.

How do I know the prebuild actually baked the extensions in? Attach to a container from the prebuilt image on a machine with no cache volume mounted and run the same ls ~/.vscode-server/extensions | wc -l check. If the extensions are present without any download activity, the bake worked; if the directory is empty, the --install-extension steps either failed silently behind || true or targeted a different server path than the one VS Code uses at runtime. Verifying on a genuinely cold machine is the only reliable test, because a lingering cache volume can mask a broken prebuild.