Shell Environment Customization: zsh, fish, bash

The interactive shell is where developers spend their day, and standardizing it in a devcontainer — the default shell, the prompt, shared aliases, and persistent history — makes every terminal feel like home while staying reproducible. This guide covers zsh, fish, and bash under the customization guide, with a bias toward the two things teams most often get wrong: history that vanishes on rebuild, and aliases scattered across personal machines.

The design split is shared-versus-personal. The team standardizes the default shell, common aliases, and the prompt in devcontainer.json; individuals layer personal plugins and tweaks through their dotfiles. That keeps the baseline consistent without flattening everyone's preferences.

Prerequisites

You need the shell binary installed at image time, the default terminal profile pointed at it, a named volume for history persistence, and a home for aliases (either devcontainer.json or dotfiles).

  • The shell (zsh, fish, or bash) installed via a Feature or the Dockerfile.
  • terminal.integrated.defaultProfile.linux set to the chosen shell.
  • A named volume mounted on the history-file directory.
  • Aliases declared in customizations.vscode.settings or in dotfiles.

Shell prerequisitesYou need the shell installed, the default profile set, a history volume, and a place for aliases.Shell binaryinstalled at imagetimeDefault profileterminal settingHistory volumenamed mountAliasesdeclared or dotfiles

The shared-versus-personal split is the organizing idea that makes the whole prerequisite list coherent, so it is worth stating precisely before the mechanics. The team owns a small set of decisions that should be identical for everyone — which shell opens by default, the aliases that encode shared workflows, and a prompt that shows the same information — because divergence there creates friction: a teammate pairing on your screen should recognize the environment, and a shared alias should mean the same thing in every terminal. Individuals own everything that is genuinely a matter of taste — extra plugins, private shortcuts, color tweaks — because standardizing those flattens preferences for no benefit. The prerequisites map onto that split: the shell binary and default profile are shared (they belong in the image and config), while the alias home can be either depending on whether an alias is team-wide or personal.

The history-persistence prerequisite is the one most setups omit, and it is worth understanding why it is separate from the others. Everything else in the shell setup is defined at image-build time and is therefore automatically reproducible — the shell is installed, the profile is set, the config is baked. History is different because it is state that accumulates at runtime, so it lives in the container's writable layer, which is discarded on rebuild. That is why persisting it requires a deliberate named volume rather than falling out of the image build: you are asking for a specific piece of runtime state to outlive the container that produced it. Recognizing history as runtime state rather than build-time config is what explains why it needs its own mechanism and why forgetting it is the single most common shell complaint.

The alias-home prerequisite carries a subtle decision that shapes reviewability. An alias declared in devcontainer.json (or a checked-in shell file the config sources) is versioned, reviewed, and identical for the team — appropriate for aliases that encode shared workflows like a standard build or test shortcut. An alias in personal dotfiles is invisible to teammates and travels with the individual across projects — appropriate for personal muscle-memory shortcuts. Deciding, for each alias, which home it belongs in is not a formality: putting a team-critical alias only in one person's dotfiles is exactly how "it works when you drive but not when I do" bugs appear. The prerequisite is not just "aliases have a home" but "each alias has the right home for its scope."

Architecture & Configuration Deep Dive

The three shells differ in mechanics but share the same integration points. zsh reads .zshrc and pairs with oh-my-zsh; fish uses config.fish and fisher; bash reads .bashrc and can use bash-it. All three work well with a cross-shell prompt like starship, and all three persist history the same way — by putting the history file on a named volume.

Shell comparisonHow zsh, fish and bash differ across config file, plugin manager, prompt, and history persistence.zshfishbashConfig.zshrcconfig.fish.bashrcPluginsoh-my-zshfisherbash-itPromptp10k / starshipstarshipstarshipPersist historyvolumevolumevolume

The default shell is chosen with terminal.integrated.defaultProfile.linux under customizations.vscode.settings, and the shell binary is installed at image time so it is guaranteed present. Whichever shell you pick, keep the shared configuration in the devcontainer and personal flair in dotfiles — the boundary that keeps a team consistent without being uniform.

There is a subtlety in the interaction between the login shell and the terminal profile worth knowing, because it explains a class of confusing "my config isn't loading" reports. The defaultProfile.linux setting controls which shell VS Code's integrated terminal launches, but whether that shell reads .zshrc/.bashrc (interactive config) versus .zprofile/.bash_profile (login config) depends on how the profile invokes it. A shell started as a non-login interactive shell reads the interactive rc file, which is where aliases and prompt setup usually belong; putting that config in the login file instead means it silently does not load in the terminal. When a setting seems ignored, checking which init file the shell actually sources — interactive versus login — is usually the resolution, and keeping aliases and prompt config in the interactive rc file is the reliable default.

The insight that the three shells "differ in mechanics but share the same integration points" is what lets a team treat shell choice as a low-stakes decision rather than a lock-in. Each shell has its own config file and its own plugin ecosystem, but the four things a devcontainer actually needs to standardize — installing the binary, setting it as the default profile, persisting history, and loading aliases — work the same way regardless of which shell you pick. The history file is just a file on a volume whether it is .zsh_history or fish's history database; the default profile is one setting whether it names zsh or bash. Because the integration surface is identical, you can standardize on the shell your team prefers culturally without worrying that the choice complicates the devcontainer mechanics.

A cross-shell prompt like starship is the clearest illustration of that shared surface, and it is why it appears in all three columns of the comparison. Rather than learning each shell's native prompt system — powerlevel10k for zsh, fish's own prompt functions, bash's PS1 escapes — a single starship binary reads one config file and renders the same prompt in every shell. For a team that has not fully standardized on one shell, or for a developer who switches between them, this collapses three prompt configurations into one and guarantees the prompt shows identical information everywhere. It is a concrete example of the guide's theme: standardize the shared surface (the prompt) in one place, and let the underlying shell remain a personal or cultural choice.

The deeper reason to keep shared config lean sits at this architectural layer, not just the performance one. Every line the team bakes into the shared shell config is a line every developer inherits whether they want it or not, so the shared layer should contain only what genuinely benefits from being uniform — the default shell, workflow aliases, the prompt. Everything else pushed into the shared layer is both a startup cost and an imposition of taste. The dotfiles mechanism exists precisely so individuals can layer richness on top without bloating the baseline, which means the architecture already has a home for personal preference; using it keeps the shared config a minimal, fast, uncontroversial foundation rather than a battleground over whose plugins everyone must load.

Step-by-Step Implementation

Four steps: install the shell, make it the default, persist its history on a volume, and load aliases. The history mount is the step most setups omit.

Shell setup stepsInstall the shell, set it as default, persist history on a volume, then load aliases.Install shellFeature or apt in the imageSet default profileterminal.integrated.defaultProfile.linuxMount historynamed volume on the histfile dirLoad aliasesfrom devcontainer.json or dotfiles

{
  "name": "Custom Shell",
  "image": "mcr.microsoft.com/devcontainers/base:ubuntu@sha256:PINNED",
  "features": { "ghcr.io/devcontainers/features/common-utils:2": { "installZsh": true } },
  "customizations": {
    "vscode": {
      "settings": { "terminal.integrated.defaultProfile.linux": "zsh" }
    }
  },
  "mounts": [
    "source=devcontainer-shell-history,target=/commandhistory,type=volume"
  ],
  "remoteUser": "vscode"
}

Persisting history in detail — including pointing HISTFILE at the mounted directory — is in persisting zsh history across container rebuilds. Declaring shared aliases in the config, rather than personal dotfiles, is covered in setting up custom shell aliases in devcontainer.json.

The history mount is the step that rewards the most care, because its correct implementation has two parts that must agree. The first is mounting a named volume on a directory — the config example uses /commandhistory — so that directory survives rebuilds. The second, which the linked how-to details, is pointing the shell's HISTFILE at a file inside that mounted directory, because mounting the volume alone does nothing if the shell still writes its history to the default location in ephemeral storage. Both halves are required: the volume provides durable storage, and the HISTFILE redirect is what makes the shell actually use it. Setups that do only the first — mount a volume but never redirect HISTFILE — appear configured but still lose history on every rebuild, which is exactly the confusing half-working state the how-to exists to prevent.

Loading aliases is the step where the shared-versus-personal decision becomes concrete in the config. The cleanest pattern for team aliases is a checked-in shell fragment that the container's shell config sources on startup, so the aliases are versioned and identical for everyone, and a postCreateCommand or a .bashrc/.zshrc line wires the sourcing. Personal aliases, by contrast, arrive through the dotfiles mechanism and never touch the shared config at all. Keeping these two paths distinct in the implementation — a committed file for team aliases, dotfiles for personal ones — is what makes the split enforceable rather than aspirational, so a new teammate automatically gets every workflow alias without inheriting anyone's personal shortcuts.

Whichever shell you choose, installing it via a Feature rather than hand-rolling the install in the Dockerfile is usually the more maintainable path. The common-utils Feature, for instance, installs zsh and a sensible baseline in one declarative line, handles the non-root user's ownership correctly, and stays updated with the ecosystem. Hand-installing gives you more control but more surface to maintain — you own the apt invocation, the user setup, and any plugin bootstrap. For most teams the Feature is the right default because it makes the shell install a reviewed, versioned, one-line dependency rather than a bespoke sequence of build steps that each developer's config has to keep in sync.

Performance & Resource Optimization

Shell startup latency is felt on every new terminal, and it is dominated by plugin count. A maximalist oh-my-zsh with a dozen plugins can add most of a second to each terminal; a lean zsh with a single fast prompt like starship stays imperceptible.

Shell startup latencyPlugin count dominates shell startup; a lean setup keeps new terminals snappy.Heavy oh-my-zsh (many plugins)850msLean zsh + starship180msbash + starship90msillustrative new-terminal startup

Keep the shared configuration lean and let individuals opt into heavier setups via dotfiles, so the team default stays fast. If you must load plugins, prefer lazy-loading and a compiled prompt over interpreted prompt frameworks. The startup cost is a real ergonomics tax when developers open terminals constantly.

It is worth measuring rather than guessing, because shell startup cost is easy to accumulate invisibly. Most shells offer a way to profile startup — timing how long each sourced file and plugin takes — and running it once on the shared config often reveals a single slow culprit responsible for most of the delay: a completion system that scans the filesystem, a version manager that probes for installed runtimes, or a prompt framework that spawns subprocesses. Fixing or lazy-loading that one item usually recovers most of the lost time. Because the shared default is inherited by everyone, a few minutes spent profiling it pays off multiplied across the whole team and every terminal they open, which makes it one of the higher-leverage small optimizations available in a devcontainer.

The plugin-count relationship deserves a mental model rather than a single number, because it explains why the shared default should be minimal. Most shell startup cost is not the shell itself but the plugins and frameworks it sources on each new interactive session — completion systems, syntax highlighters, prompt frameworks that shell out to subprocesses. Because this cost is paid every time a terminal opens, and developers open terminals dozens of times a day, a heavy default is a tax levied continuously on everyone. A lean shared config with a single compiled prompt keeps that tax near zero, and individuals who want a richer setup pay for it themselves through their dotfiles rather than imposing it on the team. The performance argument and the shared-versus-personal argument converge on the same conclusion: keep the baseline light.

When you do need speed from a heavier setup, the technique that helps most is lazy-loading — deferring the initialization of expensive plugins until they are first used rather than on every shell start. A version-manager plugin that scans for installed runtimes, for instance, can add hundreds of milliseconds to startup even in sessions where you never invoke it; lazy-loading pushes that cost to first use, so the common case of opening a terminal to run one command stays instant. Preferring a compiled prompt (a single fast binary) over an interpreted prompt framework (which runs shell code to render each prompt) is the same principle applied to the prompt itself. Both techniques let a developer keep the features they want without paying for them on every terminal, which is the reconciliation between a rich personal setup and a snappy one.

Validation & Testing

Confirm the default terminal opens the chosen shell, that history survives a rebuild, and that shared aliases are present for everyone. The history check is the one that catches the most common regression.

Shell validationConfirm the default shell opens, history persists, and shared aliases load.Does the default terminal open thechosen shell?NOFix defaultProfile / installDoes history survive a rebuild?YESAliases load for everyoneConsistent, persistent shell

# After a rebuild, history should be non-empty and the alias should exist
wc -l < ${HISTFILE:-~/.zsh_history}   # > 0 after prior use
type gs                                 # shared alias resolves

The history-survival check is the validation that catches the most common regression, and doing it deliberately is worth the minute it takes. Use the shell, generate some history, rebuild the container from scratch, and confirm the history file is still non-empty and holds the commands from before the rebuild. If it comes back empty, either the volume is not mounted on the history directory or HISTFILE is not pointing inside that directory — the two failure modes described earlier — and you have caught it in a controlled test rather than discovering it the first time you reach for a command you ran yesterday. Because history loss is silent (nothing errors, the shell just forgets), an explicit rebuild-and-check is the only reliable way to know the persistence actually works.

The alias-consistency check protects against the "works for me" divergence that the shared-versus-personal split is meant to prevent. After a rebuild, confirm that a team alias resolves (type gs or the equivalent) in a fresh terminal, ideally on a machine other than the one where the alias was authored. If the alias exists for the author but not a teammate, it lives in personal dotfiles rather than the shared config, and the fix is to move it into the committed alias file so everyone inherits it. Validating aliases from a second developer's perspective — not just the author's — is what surfaces this, because the author's own dotfiles mask the problem on their machine while everyone else is missing the alias.

Common Pitfalls

Most shell complaints reduce to lost history or the wrong default shell. The triage below routes you.

Shell pitfall triageA triage path from a resetting or wrong-shell terminal to a persistent, correct one.Is history empty after a rebuild?YESMount histfile on a volumeIs the default terminal the wrong shell?YESSet defaultProfile.linuxPersistent, correct shell

SymptomRoot CauseRemediation
History empty after every rebuildHistory file in ephemeral storageMount its directory on a named volume
Terminal opens bash, not zshdefaultProfile.linux not setSet it under customizations.vscode.settings
Shared alias missing for a teammateAlias only in one person's dotfilesDeclare team aliases in devcontainer.json
New terminals feel slowToo many shell plugins loadingTrim plugins; use a compiled prompt
Shell not found on createBinary not installed in the imageInstall it via a Feature or the Dockerfile

The lost-history pitfall deserves expansion because it is both the most common and the most quietly demoralizing, since shell history is muscle memory developers rely on without thinking about it. The root cause is always the same: the history file sits in the container's ephemeral storage and the rebuild discards it. But the fix has the two-part subtlety noted earlier — mounting a volume is necessary but not sufficient, because the shell must also be told to write its history into the mounted location via HISTFILE. Setups that mount /commandhistory but leave HISTFILE at its default keep losing history and leave developers baffled, because the volume is clearly there. The complete fix is a volume plus a HISTFILE (and the directory's ownership matching the non-root user, so the shell can actually write it).

The wrong-default-shell pitfall is more visible but has its own trap: setting defaultProfile.linux only takes effect if the named shell is actually installed and registered as a profile. A config that sets the default to zsh without installing zsh, or installs zsh but the profile name does not match, falls back to bash with no obvious error — the terminal simply opens the wrong shell. The fix pairs the two prerequisites: install the shell binary at image time and set the default profile to match, verifying both rather than assuming the setting alone is enough. The uniting lesson across both pitfalls is that shell configuration spans build time (the binary) and runtime (history, the active profile), and a correct setup has to satisfy both halves — omitting either leaves a setup that looks configured but behaves wrong.

Conclusion

Standardize the shell where it should be shared and personalize it where it should be personal. Install the shell at image time, set it as the default, and — the step teams forget — persist its history on a named volume so it survives rebuilds. Keep team aliases and the prompt in the config, personal tweaks in dotfiles, and the plugin set lean so every terminal opens instantly.

Shared vs personal shell configStandardize the shell, team aliases and prompt in config; leave personal tweaks to dotfiles.Shared (in config)Default shellTeam aliasesPrompt themePersonal (in dotfiles)Extra pluginsPrivate aliasesPrompt tweaks

FAQ

Why does my shell history reset on every rebuild? Because the history file lives in the container's ephemeral filesystem, which is discarded on rebuild. Mount the directory holding the history file on a named volume and point HISTFILE at it, so the history persists across rebuilds like any other cached state. Both parts are required: the volume gives the history durable storage, and the HISTFILE redirect is what makes the shell actually write there instead of to the default ephemeral location. A common half-fix mounts the volume but forgets the redirect, so history still vanishes despite the volume being present — which is why the symptom persists even after "adding the mount."

Should aliases go in devcontainer.json or my dotfiles? Team-wide aliases belong in devcontainer.json (or a checked-in shell file the config sources) so every teammate gets them and they are reviewable. Personal aliases belong in your dotfiles. The split keeps shared behaviour consistent while letting individuals customize freely. The test for which home an alias belongs in is whether a teammate would be confused if it were missing: a workflow alias everyone relies on (a standard build, test, or deploy shortcut) is shared and should be committed, while a personal shortcut that only makes sense to you is dotfiles. Putting a team-critical alias only in one person's dotfiles is precisely how "it works when you drive but not when I do" bugs appear.

Which shell should a team standardize on? Any of the three works; the decision is cultural, not technical. zsh with a lean prompt is a common default for its plugin ecosystem and interactivity; fish is friendlier out of the box; bash is the most universal. Whatever you choose, install it at image time and set it as the default profile so every terminal is consistent. Because the devcontainer integration points are identical across all three, the choice does not complicate the mechanics — pick the shell your team is happiest in and standardize that one.

Can different developers use different shells in the same devcontainer? Yes, within limits. The config sets a default profile, but VS Code lets an individual open a non-default profile, and a developer's dotfiles can install and configure their preferred shell on top of the shared baseline. The shared config guarantees a consistent default and the team aliases; personal preference layers over it. The one thing to keep shared is where those aliases and workflow shortcuts live, so they work regardless of which shell a given developer happens to be in — otherwise a team alias defined only for zsh vanishes when a teammate opens fish. A cross-shell prompt like starship helps here too, since it renders identically no matter which shell each developer prefers, keeping the visual baseline consistent across a mixed-shell team.

Does persisting history leak secrets between developers? It can if the history volume is shared across users, so scope it per developer. A named volume tied to a single developer's environment keeps their history private to them, which is what you want — history often contains tokens typed on a command line or sensitive paths. Avoid sharing one history volume across multiple people, and treat shell history with the same care as any other place credentials might land. If a secret was ever typed into a shared history, rotate it, because the history file preserves it durably on the volume.