Customization & Developer Toolchain Integration

This guide defines how to inject, configure, and synchronize a developer toolchain inside a containerized environment without sacrificing reproducibility. It is for engineers who want their shell, dotfiles, linters, git hooks, and editor extensions to arrive fully configured on first attach — identically for every teammate and every CI runner — instead of being reassembled by hand in each new container. The guarantee is the same one that governs the rest of this site: everything is declared, version-controlled, and rebuilt, so there is no hand-tuned state to drift.

Customization is where reproducibility most often quietly breaks, because it is tempting to apt-get install one more tool or tweak one more setting in a running container. That mutation vanishes on the next rebuild, and the environment silently diverges. The discipline here is to push every customization into one of three owned layers — the image, the devcontainer.json configuration, or a cache volume — and never into ephemeral container state.

There is a tension at the heart of toolchain customization that this guide exists to resolve. Developers rightly want their own shell, their own aliases, their own editor niceties — the accumulated ergonomics that make them productive. Teams rightly want a consistent, reproducible baseline that every member and every CI runner shares. Treated carelessly, these goals collide: either personal preferences leak into the shared image and impose one person's taste on everyone, or the shared baseline is so rigid that developers work around it locally and drift reappears. The resolution is a clean separation between shared customization (declared in the image and config, reviewed like code) and personal customization (carried in dotfiles that layer on top). Get that boundary right and both goals are satisfied at once.

Toolchain injection pointsCustomization flows from the shell layer through dotfiles, linters and hooks, to editor extensions.Shellzsh/fish defaultprofileDotfileschezmoi / bare repoLint + Hookseslint · prettier ·pre-commitExtensionspinned + cachedEvery layer is declared in devcontainer.json, never hand-edited in a live container

Specification & Configuration Surface

The spec exposes a well-defined surface for customization, and knowing which key owns which concern is half the battle. Durable, shared tooling — shell binaries, system linters, globally-installed CLIs — belongs in the image, installed via the Dockerfile or a Feature. Per-developer and IDE customization belongs in devcontainer.json: customizations.vscode for extensions and settings, the dotfiles properties for personal configuration, and postCreateCommand for project dependency installation. Environment variables split between containerEnv (baked, shared) and remoteEnv (per-attach), and the precedence between them is documented in the property reference.

The customizations namespace is itself a small but important piece of spec design worth understanding, because it is what keeps the configuration editor-agnostic. Rather than putting editor settings at the top level of the config, the spec quarantines them under customizations.<tool>customizations.vscode for VS Code, other keys for other tools — so that a JetBrains IDE or the headless CLI simply ignores the parts it does not understand while still honouring the shared image, Features, and hooks. This is why the same .devcontainer/ can serve a mixed-editor team: the universal parts of the customization live outside the tool namespace, and only the genuinely editor-specific parts live inside it. When you author a config, that boundary tells you exactly what is portable (the toolchain) and what is not (one editor's extension list).

Where customization livesDurable, shared tooling belongs in the image; per-developer and IDE customization belongs in devcontainer.json.Image-time (Dockerfile / Features)Shell binaries (zsh, fish)System linters + gitGlobal npm/pip toolsLocale + timezoneAttach-time (devcontainer.json)customizations.vscodedotfiles bootstrappostCreateCommand installsremoteEnv + shell profile

Drawing this line deliberately is what keeps a team's environment coherent. If personal dotfiles leak into the shared image, every rebuild carries one developer's aliases; if shared tooling hides in a personal bootstrap script, teammates who don't run it get a different toolchain. Keep durable and shared on the left, personal and per-attach on the right, and the boundary stays legible.

The environment-variable surface deserves particular care, because it is the customization channel most prone to precedence surprises. containerEnv values are baked into the container at creation and shared by every process and every developer — the right home for stable, non-secret configuration like a NODE_ENV or a cache directory path. remoteEnv values are applied per remote session and can reference host state through ${localEnv:…}, which makes them the correct channel for per-developer or machine-specific values, and the only safe channel for secrets, which must be injected at runtime rather than baked into a layer. When the same variable is set in more than one of these places, the spec's documented precedence decides the winner; the practical rule is to declare each value in exactly one place and confirm the resolved result with read-configuration rather than reasoning about the merge in your head.

Dotfiles & Baseline Parity

Dotfiles establish a developer's baseline — prompt, aliases, editor config, git settings — and the spec supports them natively through dotfiles.repository, dotfiles.installCommand, and dotfiles.targetPath. When a container is created, the Dev Containers tooling clones the named repository and runs its install command, so a developer's personal setup follows them into every environment. The end-to-end pattern is covered in automating dotfiles sync across containers, with a chezmoi-based approach in bootstrapping dotfiles with chezmoi in a devcontainer.

The mechanism is deliberately lightweight: the tooling clones the repository named in dotfiles.repository to dotfiles.targetPath, then runs dotfiles.installCommand. Everything that makes dotfiles useful — how the files are applied, how per-host differences are handled, how conflicts are resolved — lives inside that install command, which is why the choice of tool matters more than the spec keys. A templating tool like chezmoi can render host-specific values (a different git email per machine, an OS-conditional path) from one repository, while a symlink farm like GNU Stow keeps the mapping between repository and home directory explicit and reversible. Either is a better foundation than a hand-rolled script, because both are idempotent by construction.

Dotfiles bootstrap sequenceDotfiles are cloned, bootstrapped, linked into the home directory, then verified on shell reload.Clonedotfiles repoBootstrapinstall.sh / chezmoiapplyLinksymlink into $HOMEVerifyshell reloads cleanBootstrap must be idempotent — it re-runs on every rebuild

The one rule that makes dotfiles safe in a reproducible environment is idempotency: the bootstrap runs on every create and rebuild, so it must converge to the same state whether it runs once or ten times. Symlink managers like chezmoi and GNU Stow are built around this property; a hand-rolled install.sh must guard every ln -s and every append to a config file. Get this right and dotfiles are pure benefit; get it wrong and a doubled PATH export or a duplicated alias block appears after the second rebuild. Multi-container parity — the same dotfiles across many projects — is walked through in how to sync dotfiles across multiple devcontainers.

For dotfiles to work across many projects, they must be strictly $HOME-relative and free of any project-specific path, because the same repository is applied into every container regardless of what the project's own layout looks like. Set the dotfiles once at the editor's user level rather than per-repository, and every devcontainer a developer opens clones and applies the same baseline automatically. Anything genuinely specific to one project — a project-local alias, a repo-specific tool configuration — belongs in that project's own committed config, not in personal dotfiles. Keeping that separation is what lets one dotfiles repository be a single source of truth for an individual's environment across an entire portfolio of projects.

The second rule is that dotfiles must never carry secrets. It is tempting to keep an API token or an SSH key in a dotfiles repository so it follows you into every environment, but a dotfiles repo is version-controlled and cloned into the container, so a committed secret ends up in git history and in image state — recoverable long after you delete it. Keep credentials in the host or Codespaces secret store and reference them at runtime, and keep the dotfiles repo to non-sensitive configuration. This is the same runtime-injection discipline the security guide applies site-wide, and dotfiles are one of its most common violation points precisely because they feel personal and low-stakes.

Shell Environment Layer

The interactive shell is the surface developers spend the most time on, and the spec lets you standardize it while still allowing personal flavour. The default shell is set through terminal.integrated.defaultProfile.linux in customizations.vscode.settings, and the shell binary itself is installed at image time. Whether the team standardizes on zsh with oh-my-zsh, fish with its structured config, or plain bash, the deep dive lives in shell environment customization: zsh, fish, bash.

The choice of shell is largely cultural rather than technical — zsh for its plugin ecosystem, fish for its friendliness out of the box, bash for universality — and the important thing is that whichever the team picks is installed at image time and set as the default profile, so every terminal is consistent. What is not optional is where the shell's mutable state lives. A shell accumulates two kinds of state that developers expect to persist: command history and, for some workflows, a cache of completions or plugin data. Both live inside the container by default and both vanish on rebuild unless deliberately mounted onto a named volume. Treating history as durable state to be persisted — rather than an incidental file — is the difference between a shell that feels like a real workstation and one that resets its memory every time the container is rebuilt.

Shell option comparisonHow zsh, fish and bash differ across config file, plugin manager, history persistence and startup cost.zshfishbashConfig file.zshrcconfig.fish.bashrcPlugin manageroh-my-zshfisherbash-itHistory persistnamed volumenamed volumenamed volumeStartup costmediumlowlow

Two shell concerns deserve special attention because they break reproducibility in subtle ways. First, history persistence: a shell's history file lives inside the container and vanishes on rebuild unless you mount it on a named volume — the technique in persisting zsh history across container rebuilds. Second, aliases and functions: defining them in devcontainer.json rather than personal dotfiles makes them shared and reviewable, as shown in setting up custom shell aliases in devcontainer.json. Startup cost matters too — an over-plugged zsh can add noticeable latency to every new terminal.

Shell startup latency is worth taking seriously precisely because it is paid so often. A developer opens dozens of terminals a day, and a prompt framework that loads a dozen plugins can add most of a second to each one — a small number that compounds into a real, felt drag on the workflow. The remedy is to keep the shared shell configuration lean, favouring a compiled cross-shell prompt like Starship over a heavyweight interpreted framework, and to let individuals opt into richer setups through their own dotfiles. That way the team default stays instantaneous while personal preference remains unconstrained — the shared-versus-personal split applied to a dimension, performance, that teams usually forget to measure until terminals feel sluggish.

Linting & Formatting Integration

Consistent linting and formatting is a team-wide contract, and containerizing it removes the last excuse for "it passes locally." Install ESLint and Prettier as project dev dependencies pinned in the lockfile, wire editor.formatOnSave and the default formatter through customizations.vscode.settings, and enforce the same versions in CI so the container, the editor, and the pipeline all agree. The full integration is in integrating ESLint & Prettier in devcontainers.

The single most important decision here is to make the project the source of the formatter, never a global install. A developer with a globally-installed Prettier that is even a minor version ahead of the project's will reformat files differently, and because formatters touch whitespace across whole files, the resulting diff drowns the real change in unrelated churn. When the formatter is a pinned dev dependency and the editor is configured to use the project's copy, that entire failure mode disappears: everyone formats byte-identically because everyone runs the same binary. The devcontainer reinforces this by guaranteeing the pinned tools are present and resolved consistently, which is why containerized linting finally makes "it formats the same for everyone" true rather than aspirational.

Linter integration pathLinters are installed as dev dependencies, version-pinned, wired to the editor, then enforced in CI.Installeslint + prettier asdevDepsPinexact versions inlockfileWireeditor formatOnSaveEnforcepre-commit + CI check

The failure this prevents is version skew: a developer whose global Prettier is a minor version ahead reformats a file, and the diff explodes with unrelated whitespace churn. Pinning the formatter in the project — never relying on a globally-installed one — makes formatting deterministic. In a monorepo the challenge shifts to sharing one config across many packages without duplication, which is exactly the subject of sharing ESLint/Prettier config across a monorepo.

Monorepos are where linting configuration most often drifts, because the temptation to copy a .eslintrc into each package is strong and the consequence — subtly divergent rules per package — is slow to surface. The reproducible answer mirrors the rest of this guide: centralize the rules in one shared internal config package that each workspace extends, so a single edit updates every package and there is nothing to keep manually in sync. The devcontainer makes this reliable by pinning one toolchain version for the whole repository, so the shared config resolves identically everywhere; without that pinned baseline, a package installed with a slightly different linter version could interpret the shared rules differently, which is exactly the drift the shared config was meant to prevent.

Enforcement is what turns a formatting convention into a formatting guarantee, and it works best in layers. The editor's format-on-save gives instant feedback as code is written; a pre-commit hook running the same pinned formatter blocks a mis-formatted change from ever being committed; and a CI check running --check on the identical versions is the backstop that catches anything the first two layers missed or that a contributor bypassed. The crucial property across all three layers is that they resolve the same pinned tools from the project, so their verdicts never disagree. A setup where the editor formats one way, the hook another, and CI a third is worse than no automation at all, because it produces churn and contradictory signals; one source of truth for the formatter version is what makes the layered enforcement coherent.

Hook Orchestration for Toolchain Setup

Toolchain setup is spread across the lifecycle hooks, each chosen by when its work must run relative to the workspace mount. Shell installation and global tooling that should be baked into a Codespaces prebuild go in onCreateCommand, which runs before the source is mounted. Project dependency installation — npm ci, poetry install, go mod download — goes in postCreateCommand, after the mount exists. Long-running watchers and service seeding go in postStartCommand, and one-time attach messaging in postAttachCommand.

There is a performance dimension to this placement that is easy to overlook. Because onCreateCommand runs before the source mount and its results are cacheable, work placed there can be baked into a prebuilt image and reused, whereas work in postCreateCommand runs fresh on every create. The optimization, then, is to push as much source-independent toolchain setup as possible up into onCreateCommand — installing the shell, global CLIs, and any tooling that does not depend on your specific commit — so a Codespaces prebuild or a CI-warmed image captures it once for everyone. Only the genuinely source-dependent step, installing dependencies from this commit's lockfile, needs to remain in postCreateCommand. Splitting the toolchain setup along the cacheable/source-dependent line is what lets a prebuild do the heavy lifting while keeping the per-developer create fast.

Toolchain hook orchestrationToolchain setup is spread across lifecycle hooks by when each step must run relative to the mount.onCreateshell + globaltoolsbaked into prebuildpostCreatenpm ci / poetryinstallafter mountpostStartstart watchersevery startpostAttachprint tipseach attach

Git hooks are the toolchain step engineers most often get wrong in a container. A framework like Husky or pre-commit installs its hook scripts into .git/hooks, but that directory is only present after the workspace is mounted — so the install must run in postCreateCommand, never onCreateCommand. The correct pattern is documented in pre-commit hook configuration for containerized workflows, with a Husky-specific walkthrough in running pre-commit hooks on postCreateCommand with husky and a polyglot setup in configuring pre-commit in a multi-language repo.

Mapping each setup step to the right hook is a matter of asking when it must run relative to two events: the workspace mount, and container restarts. Anything that depends on the mounted source — installing dependencies from the lockfile, wiring git hooks, generating code from committed schemas — must run at postCreateCommand or later, because earlier hooks execute before the source exists. Anything that should run on every start rather than only at creation — seeding a development database, starting a file watcher, warming a cache — belongs in postStartCommand. And anything that is genuinely per-session, such as printing a "getting started" message or opening a browser tab, belongs in postAttachCommand. The failures here are almost always temporal: the right command in the wrong hook, running too early or too often. When a toolchain step misbehaves, check its timing before its logic.

Extension & Cache Management

Editor extensions and package caches are the heaviest, slowest part of container startup, so they are the highest-value target for optimization. The rule is a two-part policy: cache the heavy, reproducible-from-source downloads on named volumes, and pin the identities and versions that must stay constant. Extensions are declared by exact ID in customizations.vscode.extensions, and their VSIX downloads plus the language-server binaries they pull can be cached so a rebuild doesn't re-download hundreds of megabytes.

Startup latency is not a cosmetic concern; it is the number that decides whether developers embrace a devcontainer or resent it. An environment that takes a minute to rebuild because it re-downloads every extension and re-installs every dependency will be avoided — people keep containers running for days, skip rebuilds that would pick up a config change, and generally treat the container as precious rather than disposable, which quietly reintroduces drift. An environment that rebuilds in a few seconds because its caches are warm is one people rebuild freely, keeping it disposable and therefore reproducible. Caching, in other words, is not just a performance nicety; it is what makes the disposable-container discipline sustainable, and that discipline is the whole point. The heaviest three caches to get right are the extension store, the package-manager store, and — for compiled languages — the build-artifact directory.

A well-configured cache also has to stay valid, which is where pinning re-enters the picture. A cache keyed loosely — say, one that is reused regardless of which dependency versions the project resolves — can silently serve stale artifacts and mask a change that should have triggered a reinstall. The discipline is to key each dependency cache on the thing that determines its contents: the lockfile hash for a package store, the exact version for an extension, the digest for a base layer. With the key tied to the resolved input, a warm cache is only ever reused when the inputs are genuinely unchanged, so it can never smuggle in something stale. Cache-and-pin, then, is really cache-keyed-on-pin: the pin is what makes the cache trustworthy, not merely fast.

Extension cache + pin policyCache heavy downloads on named volumes; pin identities and versions so caches stay reproducible.Cache theseExtension VSIX downloadsLanguage server binariesnpm / pip / go module cachesPrebuilt tool binariesPin theseExtension IDs + versionsFeature major versionsBase image digestLinter/formatter versions

The full cache strategy — which volumes to mount where, and how to keep them from going stale — is in managing VS Code extension caches, with concrete techniques in speeding up VS Code extension installation in containers. Pinning is the other half: pinning VS Code extension versions in devcontainer.json keeps an editor auto-update from silently changing behaviour under the team's feet. Cache without pinning drifts; pinning without caching is slow. Do both.

The cache-and-pin principle is not specific to editor extensions; it is the general shape of every performance optimization in a devcontainer, and recognizing that unifies a lot of otherwise-scattered advice. Package-manager stores, language-server binaries, compiled build artifacts, and extension VSIX downloads are all heavy, reproducible-from-source, and therefore cacheable on named volumes; the identities and versions that must stay constant — the extension IDs, the Feature versions, the base-image digest — are pinnable. Caching without pinning gives you speed that drifts, because the cache can quietly repopulate with a newer version; pinning without caching gives you determinism that is slow, because every rebuild refetches. Applied together across every heavy artifact, cache-and-pin is what makes a fully reproducible environment also a fast one, which is the combination that makes developers actually adopt it.

There is a further tier of optimization for teams that feel first-attach latency even with warm caches: baking the extensions and language servers into a prebuilt server image. A cache volume speeds rebuilds by reusing what was downloaded before, but the very first attach on a fresh machine still fetches everything; a prebuilt image installs the extensions ahead of time so even that first attach is near-instant. This is the mechanism behind Codespaces prebuilds, and it can be applied locally too. It is the extension-layer expression of the same prebuild economics that governs the lifecycle hooks: move expensive, source-independent work out of the per-developer path and into a shared, cached artifact built once for the whole team.

Canonical Configuration

The reference below shows customization split correctly: durable tooling in the Dockerfile, personal and IDE configuration in devcontainer.json, and cache mounts declared as named volumes. Note remoteUser is present, and the dotfiles bootstrap runs at attach time.

Customization ownershipThe Dockerfile owns durable shell and tooling; devcontainer.json owns dotfiles, IDE config and cache mounts.Dockerfile ownszsh/fish installGlobal CLI toolsNon-root userLocale setupdevcontainer.json ownsdotfiles.* bootstrapcustomizations.vscodepostCreateCommandmounts for caches

Trace each customization to its owning layer and the design becomes self-explanatory. The Dockerfile installs the durable, shared tooling — the shells and system utilities every rebuild needs — as a non-root user so ownership stays clean. The customizations.vscode block declares the extensions and editor settings that give every teammate an identical editor surface. The mounts array puts the two heavy, cacheable things — the extension store and the shell history — on named volumes so they survive rebuilds. And postCreateCommand runs the source-dependent setup (npm ci) and the mount-dependent hook install (npx husky install) at the one moment both the source and the .git directory exist. Nothing about a developer's personal configuration appears here at all; that arrives through the dotfiles mechanism, layered on top, so the shared config stays reviewable and the personal config stays individual.

{
  "name": "Customized Toolchain",
  "build": { "dockerfile": "Dockerfile" },
  "features": { "ghcr.io/devcontainers/features/node:1": { "version": "20" } },
  "customizations": {
    "vscode": {
      "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"],
      "settings": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "esbenp.prettier-vscode",
        "terminal.integrated.defaultProfile.linux": "zsh"
      }
    }
  },
  "mounts": [
    "source=devcontainer-extensions,target=/home/node/.vscode-server/extensions,type=volume",
    "source=devcontainer-zsh-history,target=/commandhistory,type=volume"
  ],
  "postCreateCommand": "npm ci && npx husky install",
  "remoteUser": "node"
}
FROM mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED
RUN apt-get update && apt-get install -y --no-install-recommends \
    zsh fish git \
    && rm -rf /var/lib/apt/lists/*
# oh-my-zsh installs to the non-root user's home; keep it idempotent
USER node
RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended

Common Pitfalls

The table catalogs the customization failures that most often break parity, and the triage path beside it locates the owning layer: persistence first, version pinning second, hook idempotency third.

Every entry in the table shares a signature: something that felt like a harmless local convenience turned out to depend on ephemeral container state or a globally-installed tool. Empty history, duplicated aliases, churning formatting diffs, a failed hook install, re-downloaded extensions — none is a mysterious bug, and each maps to a specific violation of the owned-layers discipline. That is the reassuring flip side of taking customization seriously: because there are only three owned layers and a small number of ways to misuse each, the failure modes are enumerable and the fixes are mechanical. When customization misbehaves, the question is never "what went wrong?" but "which owned layer does this belong in, and did I put it there?".

Customization drift triageA triage path for toolchain drift: persistence, version pinning, then hook idempotency.Does the shell load the same on everyrebuild?NOHistory or plugins not on a named volumeAre linters and extensionsversion-pinned?YESHooks idempotent, caches mountedEvery teammate gets an identicaltoolchain

SymptomRoot CauseRemediation
Shell history empty after rebuildHistory file lives in ephemeral container storageMount the history path on a named volume
Aliases duplicated after second rebuildNon-idempotent dotfiles bootstrap appends every runGuard appends, or use chezmoi/Stow which are idempotent
Formatting diffs churn unrelated linesGlobally-installed formatter version differs per machinePin the formatter in the project lockfile, not globally
husky install fails on createRan in onCreateCommand before .git was mountedMove hook install to postCreateCommand
Extensions re-download on every rebuildNo cache volume for the VS Code server extensions dirMount the extensions directory on a named volume

Conclusion

Customization stays reproducible when it lives in owned layers instead of live container state. Durable tooling belongs in the image, per-developer and IDE configuration belongs in devcontainer.json, and heavy downloads belong on named cache volumes. When every alias, extension, and hook has a declared home, a new teammate's environment is a git clone and a rebuild away from identical — and no customization ever silently drifts.

The deeper lesson is that customization and reproducibility are not in tension once you stop treating "customization" as a single thing. It is really two things wearing one name: shared customization, which is team policy and belongs in reviewed, version-controlled configuration, and personal customization, which is individual ergonomics and belongs in dotfiles that layer on top. Conflating them is what creates the false choice between a rigid environment nobody enjoys and a flexible one that drifts. Separate them cleanly, and a team gets both a consistent, reproducible baseline and the freedom for each engineer to make the environment their own — the outcome that makes a shared devcontainer something developers actively want rather than merely tolerate.

Two invariants carry most of the weight in practice, so they are worth committing to memory. First, idempotency: anything that runs on every create — a dotfiles bootstrap, a hook install, a setup script — must converge to the same state on the tenth run as on the first, because rebuilds will run it repeatedly. Second, timing: source-dependent and mount-dependent work belongs in postCreateCommand or later, never in the pre-mount onCreateCommand. Almost every customization failure in this guide reduces to a violation of one of those two rules. Hold them, keep secrets out of the image, and cache-and-pin the heavy artifacts, and the whole toolchain arrives fully configured, identically, on every first attach.

Customization separation of concernsDurable tooling, per-developer configuration and caches occupy three distinct, independently rebuildable layers.Image layerdurable shells + global tooling, shared by allConfiguration layerdotfiles, IDE settings, lifecycle hooksCache layernamed volumes for extensions + package managers

FAQ

Should team-wide tools go in the image or in dotfiles? Anything every teammate needs — the shell binary, shared linters, required CLIs — belongs in the image so it is guaranteed present and version-consistent. Dotfiles are for personal preferences (prompt, aliases, editor niceties) that follow an individual across projects. If a tool's absence would break a teammate's workflow, it is not a dotfile; put it in the Dockerfile or a Feature. The test is simple: ask whether a teammate who has not configured their personal dotfiles would still be able to do their job in the environment. If the answer is no, the tool is a shared dependency and belongs in the reviewed configuration, not in one person's personal setup that others may never have applied.

Why do my git hooks fail to install during container creation? Because hook frameworks write into .git/hooks, and the .git directory only exists after your workspace is bind-mounted. onCreateCommand runs before the mount, so a hook install there has nothing to write to. Move husky install or pre-commit install into postCreateCommand, which runs after the mount is present. This is the single most common timing bug in containerized toolchains, and it is worth internalizing the general rule behind it: any setup that touches your bind-mounted source — the .git directory, the lockfile, generated code — must wait for postCreateCommand, because everything before that hook runs against a filesystem that does not yet contain your workspace.

How do I stop VS Code extensions from re-downloading on every rebuild? Mount the container's VS Code server extensions directory on a named volume so the VSIX downloads and language-server binaries survive rebuilds. Combine that with pinning exact extension versions in customizations.vscode.extensions, so the cache stays valid and an editor auto-update can't change behaviour underneath you. For teams that still feel first-attach latency, bake the extensions into a prebuilt server image so even a fresh container attaches with them already present.

Can I let each developer keep their own shell, aliases, and editor tweaks? Yes — that is exactly what the shared-versus-personal split is for. Standardize the baseline (the default shell, team aliases, the required extensions and settings) in the image and devcontainer.json so everyone starts from the same place, and let individuals layer their own preferences on top through the dotfiles mechanism. The dotfiles clone and apply on every container create, so a developer's personal setup follows them into every environment without imposing itself on anyone else or leaking into the shared, reviewed configuration.