Preinstalling Extensions in a Prebuilt DevContainer Image

Even with a cache volume, the first attach downloads extensions. This page bakes them into a prebuilt server image, so a fresh container attaches with extensions already present — the fastest possible editor startup, especially for Codespaces prebuilds.

The distinction matters because a cache volume and a prebuilt image solve different halves of the same problem. A cache volume holds the extension VSIX payloads that VS Code has already fetched, so the second container on a machine that shares the volume reuses them instead of downloading again. But that first machine, or any freshly provisioned CI runner or Codespace, has an empty ~/.vscode-server/extensions directory and must pull every extension over the network before the editor is usable. Baking the extensions into the image moves that work from attach time to build time: the VSIX files are unpacked into the server directory once, during the prebuild, and then travel inside the image layer wherever the image goes. The cost is paid by whoever runs the CI job, not by the developer waiting on a spinner.

Reach for this pattern when first-attach latency is the thing people actually complain about — large teams onboarding onto Codespaces, ephemeral CI environments that never survive long enough to warm a cache, or any workflow where a container is thrown away after a single task. The mental model is straightforward: the prebuilt image is a snapshot of a server that has already installed everything, so attaching is closer to opening a folder than to bootstrapping an environment. That snapshot is only as trustworthy as the pins that produced it, which is why every step below treats the extension list as versioned, immutable input rather than something resolved live at attach.

Prerequisites

You need a prebuild pipeline and the extension list to bake.

  • A way to build and publish a prebuilt image (CI or Codespaces prebuild).
  • The pinned extension IDs to preinstall.
  • The VS Code server available in the build.

The prerequisite people most often underestimate is having the VS Code server binary present inside the build environment rather than assuming it will be fetched at attach. Preinstalling an extension writes it into a specific server's extensions directory, and that directory is keyed to the exact server commit that ships with a given VS Code version. If the prebuild installs into one server build and the developer attaches with a client that provisions a different server commit, the baked extensions can be ignored and re-downloaded, defeating the whole exercise. The pinned extension IDs are the second half of that contract: the list you bake must be the same list, at the same versions, that devcontainer.json declares, or the editor will reconcile the difference on attach by fetching whatever is missing. Treat the extension list as a single source of truth that both the prebuild step and the runtime config read from, and the image stays honest.

Prebuild prerequisitesYou need the pinned list, a prebuild install step, a published image, and a fast attach.Extension listpinned IDsPrebuild stepinstall into serverPublish imagewith extensionsAttachinstant

Step-by-Step Implementation

  1. Declare pinned extensions.
{ "customizations": { "vscode": { "extensions": ["dbaeumer.vscode-eslint@3.0.10"] } }, "remoteUser": "vscode" }

The @3.0.10 suffix on dbaeumer.vscode-eslint is the load-bearing detail here. Without it, VS Code resolves "the latest compatible version" every time it evaluates the config, which means the extension baked into the image at build time and the extension the client wants at attach time can silently diverge the moment the publisher ships an update. Pinning the version freezes both ends of the pipeline to the same VSIX, so the prebuild and the runtime agree byte-for-byte. The remoteUser: "vscode" line is equally deliberate: extensions are installed into a per-user server directory, so the user that runs the install in the prebuild must be the same user that attaches later. If the prebuild installs as root but the container attaches as vscode, the baked extensions sit in the wrong home directory and the editor treats them as absent.

  1. Preinstall them in the prebuild (Codespaces prebuilds do this automatically from the config).
# In a prebuild image, install extensions into the server ahead of time
RUN code-server --install-extension dbaeumer.vscode-eslint || true

This RUN step is what physically materializes the extension into the image layer. The --install-extension flag downloads and unpacks the VSIX into the server's extensions directory during the build, so the resulting layer already contains the unpacked extension folder. The trailing || true is a pragmatic guard: extension installs occasionally fail transiently — a marketplace hiccup, a rate limit, a network blip in CI — and without the guard a single flaky download would abort the entire image build. Swallowing the non-zero exit keeps the build moving, but it is a trade-off you make consciously, because it also masks a genuinely broken extension ID. For a hardened pipeline, prefer installing each extension in its own step and asserting the extension folder exists afterward, so a real failure surfaces instead of shipping an image that quietly lacks the extension it promised. Whether you use the standalone code-server binary shown here or the devcontainer CLI, the principle is identical: the install happens once, at build time, against the same server the developer will later attach to.

  1. Publish the prebuilt image and reference it.

  2. Attach and confirm extensions are already present.

ls ~/.vscode-server/extensions | grep eslint

Publishing the prebuilt image and referencing it is the step that turns a local optimization into a team-wide one. Once the image carrying the baked extensions is pushed to a registry and named in devcontainer.json (or produced by a Codespaces prebuild), every container created from it inherits the pre-populated server directory for free. The verification command closes the loop: listing ~/.vscode-server/extensions and grepping for eslint confirms the extension folder actually landed inside the image rather than being downloaded live on your first attach. Run it from a fresh container that has never touched a cache volume — that is the only environment that proves the extension came from the image and not from a warm cache you forgot to clear. If the folder is present before VS Code has finished connecting, the bake worked; if it appears only after a few seconds of network activity, the extension is still being fetched at attach and something in the pin-and-publish chain broke.

Attach time by strategyBaking extensions into a prebuilt image gives the fastest attach.Cold download62sCache volume8sPrebuilt image2sillustrative attach time

Common Pitfalls

Prebuild issues are unpinned extensions or a prebuild that doesn't cover the config.

The most common failure is an ownership mismatch on the baked extensions directory. Because the install writes into a per-user path like ~/.vscode-server/extensions, the UID that owns those files in the image must match the UID that attaches at runtime. When a prebuild runs its RUN steps as root and the container later attaches as vscode, the extension folders are owned by root and the attaching user cannot read or update them; VS Code responds by re-installing into its own home directory, and the network round-trip you tried to eliminate comes right back. The fix is to run the install step as the same remoteUser you declared — either by switching users before the RUN, or by chown-ing the server directory to that user afterward — so the baked files are owned by whoever opens the editor.

The second recurring pitfall is drift between the declared list and the baked list. A prebuild is a point-in-time snapshot: if a teammate adds an extension to devcontainer.json but nothing re-triggers the prebuild, the published image still carries yesterday's extension set, and every attach silently downloads the newcomer while everyone assumes prebuilding "handles it." This is insidious because the environment still works — it is just slower than the prebuild promised, and the regression hides behind a green build. Treat any change to the extension list as a change that must invalidate and rebuild the image, and wire that trigger into CI rather than relying on someone to remember. Pinning versions helps here too, because a pinned list makes it obvious in review when the baked set and the declared set have fallen out of sync.

Prebuild triageA triage path from slow attach to a prebuilt image with baked extensions.Are extensions in the prebuilt image?NOInstall them in the prebuildAre versions pinned?YESPrebuild rebuilds on config changeInstant, reproducible attach

SymptomRoot CauseRemediation
First attach still downloads extensionsNot baked into the imagePreinstall in the prebuild
Prebuilt extensions driftUnpinned versionsPin exact extension versions
Prebuild stale after adding an extensionNot rebuilt on config changeTrigger prebuild on config change
Image bloatedToo many extensions bakedBake only the essentials

Conclusion

A prebuilt image with extensions baked in is the fastest editor startup there is: a fresh container attaches with everything already installed. Pin the versions and rebuild the prebuild when the extension list changes, and even the first attach is near-instant.

The strategic payoff is that you convert a per-attach variable cost into a per-build fixed cost. Every developer, every CI runner, and every Codespace that consumes the image inherits the same warm server directory, so the time spent installing extensions is amortized across the whole team instead of being re-paid on each cold start. That shift compounds at scale: a fleet of a hundred ephemeral environments that each saved a minute of extension downloads is more than an hour and a half of aggregate wait reclaimed per round of provisioning, and none of it depends on a shared cache surviving between runs.

This ties directly into the broader pin-and-cache and reproducibility themes that run through the rest of this guide. Pinning the extension versions is what makes the baked image deterministic — the same config produces the same image produces the same editor, with no live resolution that could drift under you. Caching, prebuilding, and pinning are three layers of the same idea: push work earlier in the pipeline and freeze its inputs so the result is repeatable. Preinstalling into a prebuilt image is the most upstream of those layers, the point where a running editor stops being something each environment assembles on demand and becomes something you build, version, and publish exactly like any other artifact.

Prebuild fitBaking extensions suits Codespaces prebuilds and large teams needing fast first attach.Prebuilt imageBaked extensionsBaked serverPinned versionsBest forCodespaces prebuildsLarge teamsFirst-attach speed

FAQ

How is this different from a cache volume? A cache volume speeds rebuilds by reusing downloaded extensions, but the very first attach on a fresh machine still downloads them. A prebuilt image bakes the extensions into the image itself, so even the first attach has them present — the extra step beyond caching. Put another way, a cache is populated lazily by the first environment that pays the download cost, whereas a prebuilt image is populated eagerly at build time and carries the extensions inside its own layers. The cache helps when environments are long-lived and share storage; the prebuilt image helps precisely when they do not, because there is nothing to warm and nothing to share — the image is the warm state.

Do Codespaces prebuilds handle this automatically? Yes — a Codespaces prebuild builds from your config and bakes the declared extensions into the prebuilt image, so creating a Codespace from a prebuilt branch attaches with extensions ready. Locally, you bake them into a prebuilt image yourself. The important caveat is that a Codespaces prebuild only stays current if it is configured to rebuild when the config changes; GitHub can trigger prebuilds on push to the tracked branches, but a prebuild pointed at a stale branch will keep serving the old extension set. Treat the prebuild configuration as part of the same pinned contract as the extension list, and confirm in the Codespaces settings that the branches you actually work from are the branches being prebuilt.

Should I still pin extension versions when prebuilding? Absolutely. Pinning keeps the prebuilt image reproducible and prevents an auto-update from changing behaviour. Rebuild the prebuild when you bump a version, so the baked extensions stay in lockstep with your pinned config. Skipping the pin does not just risk a cosmetic difference — an unpinned extension can resolve to a newer major version whose settings schema or language server behaviour differs, which turns your "reproducible" image into one that quietly shifts under everyone the day the publisher ships. The pin is what lets you tie a specific image digest to a specific, reviewed extension set and roll back cleanly if a bump misbehaves.

Will baking too many extensions bloat the image? It can, and this is a real trade-off rather than a reason to avoid the pattern. Each preinstalled extension adds its unpacked VSIX to an image layer, so a sprawling list inflates pull time and storage for every consumer of the image. The remedy is discipline about what actually belongs in the shared base: bake the extensions that every developer on the project genuinely needs — linters, formatters, the language server for your stack — and leave personal-preference extensions to each developer's own settings sync. That keeps the image lean while still eliminating the download cost for the extensions that matter.

How do I verify the baked extensions survived attach? Attach to a fresh container built from the published image, open the Extensions view, and confirm each pinned extension shows as installed without a "installing" progress indicator, or run the ls ~/.vscode-server/extensions check from the terminal. The presence of the extension folder before any network activity is the proof that it came from the image. If VS Code shows the extension being installed on connect, the baked copy was not picked up — usually an ownership or server-commit mismatch — and you should revisit the remoteUser and install-user alignment before assuming the prebuild is doing its job.