Persisting zsh History Across Container Rebuilds

Every rebuild wipes your shell history because it lives in the container's ephemeral filesystem. This page moves zsh's HISTFILE onto a named volume so history survives rebuilds — the same technique works for fish and bash — turning history from a per-container annoyance into durable, searchable state.

This matters because command history is not just a convenience; it is a working record of how you actually operate inside the project. Reverse-i-search over Ctrl-R, the long invocation you got right after four attempts, the exact docker run flags that finally worked — all of that accumulates as tacit knowledge you lean on daily. When the default HISTFILE at ~/.zsh_history sits inside the container's writable layer, a Rebuild Container throws that record away along with the image layers, and you start from an empty prompt. The fix is not to change how zsh records history but to change where the history file physically lives, so the recording outlasts the container that produced it.

Reach for this technique the moment you notice yourself retyping commands you know you have run before, or the first time a rebuild leaves Ctrl-R returning nothing. The mental model is a clean separation between two lifetimes: the container is disposable and rebuilt often, while the named volume is a small piece of durable storage that Docker keeps around independently. By pointing HISTFILE at a mount backed by that volume, every write zsh makes lands on storage the rebuild never touches. The container becomes stateless with respect to history, and the volume becomes the single durable home for it — the same pin-and-cache pattern used for package caches and build artifacts, applied to the record of your own keystrokes.

Prerequisites

You need zsh as the shell and a place to mount a persistent volume.

  • zsh installed and set as the default shell.
  • A named volume you can mount into the container.
  • Ability to set HISTFILE via env or shell config.

History prerequisitesMount a volume, point HISTFILE into it, set append options, and verify persistence.History dirmount a volumeHISTFILEpoint into itappend optsshare across sessionsVerifysurvives rebuild

These three prerequisites map directly onto the three moving parts of the solution. The named volume supplies durable storage; zsh being the default shell means the login flow actually sources the config where you set HISTFILE; and the ability to set HISTFILE via env or shell config is what redirects writes onto the volume. If your image already installs zsh through a feature or Dockerfile step and sets it as the remoteUser's login shell, you have everything you need. The mount target — /commandhistory in the examples below — is an arbitrary path; the only constraint is that it lives outside $HOME's ephemeral copy and that the remote user can write to it.

The detail people most often get wrong is ownership. A named volume that Docker creates for the first time is owned by root, but a well-behaved devcontainer runs as a non-root remoteUser such as vscode. If you mount the volume and stop there, zsh will try to write /commandhistory/.zsh_history as the unprivileged user and get permission denied, so nothing persists and the whole exercise silently fails. Plan from the start to chown the mount target to the remote user in a postCreateCommand or an image layer, and treat that ownership step as part of the prerequisite rather than an afterthought you bolt on when writes start failing.

Step-by-Step Implementation

  1. Mount a named volume for command history.
{
  "mounts": [
    "source=devcontainer-zsh-history,target=/commandhistory,type=volume"
  ],
  "remoteUser": "vscode"
}

The mounts entry uses type=volume rather than type=bind, and that distinction is the whole point. A bind mount would tie history to a specific host path and drag host ownership and platform quirks into the container; a named volume named devcontainer-zsh-history is managed entirely by Docker and reattached by name on every rebuild. Because the source is a stable name rather than a path, the same storage reappears at /commandhistory no matter how many times the image underneath is rebuilt. Setting remoteUser to vscode in the same file tells the tooling which non-root identity the shell runs as, which is exactly the user that must own the mount target for writes to succeed — so the two settings are two halves of one decision, not independent knobs.

  1. Point HISTFILE into the volume in the shell config.
export HISTFILE=/commandhistory/.zsh_history
export HISTSIZE=100000 SAVEHIST=100000

HISTFILE is the single line that redirects every future write off the ephemeral layer and onto the volume; putting it in .zshrc (or .zshenv for non-interactive shells) means it takes effect for every login inside the rebuilt container. The two size variables are easy to overlook but do distinct jobs: HISTSIZE caps how many entries zsh keeps in memory for the current session, while SAVEHIST caps how many are actually written out to HISTFILE. If SAVEHIST is left at its small default, the volume faithfully persists a truncated file and you lose older commands even though the storage itself is durable — the failure looks like a persistence bug but is really a truncation limit. Setting both to 100000 gives you a deep, durable history that the volume can hold across many rebuilds without trimming the very entries you reach for weeks later.

  1. Enable incremental append so concurrent terminals share history.
setopt INC_APPEND_HISTORY SHARE_HISTORY

Without these options zsh only flushes its in-memory history to HISTFILE when the shell exits, which is a problem inside a container where you frequently run several integrated terminals at once and rebuild without cleanly closing them. INC_APPEND_HISTORY makes zsh append each command to the file the moment it runs, so a command is durable even if that terminal is killed by the rebuild before it ever exits. SHARE_HISTORY goes further and has every session re-read the file, so two terminals writing to the same volume-backed HISTFILE see each other's commands live instead of the last one to exit clobbering the file and erasing the others' entries. Together they turn a single shared file on the volume into safe concurrent state rather than a race between sessions.

  1. Verify history survives a rebuild.
history | tail -n 3    # non-empty after a rebuild

The verification is deliberately blunt: run a few recognizable commands, trigger a full Rebuild Container, and check that history | tail -n 3 still shows them. A non-empty result proves the entire chain end to end — the volume reattached at /commandhistory, HISTFILE resolved to the file on it, the remote user could write it, and the append options flushed entries before the old container went away. If the tail comes back empty, confirm the mount is present with mount | grep commandhistory, that echo $HISTFILE points into it, and that ls -l /commandhistory shows the file owned by your remote user rather than root.

History persistence anatomyA volume-backed HISTFILE with append options keeps history across rebuilds and sessions.Named volumeoutlives the containerHISTFILE -> volumehistory written to durable pathINC_APPEND / SHAREsessions share historyResulthistory survives rebuilds

Common Pitfalls

History loss comes from an ephemeral HISTFILE or wrong ownership on the volume.

The ownership trap deserves its own attention because it is the failure that looks the most like something else. When Docker creates devcontainer-zsh-history for the first time, the volume root is owned by root:root; a container running as vscode then cannot create /commandhistory/.zsh_history, and zsh silently falls back to keeping history only in memory. Nothing errors loudly, so you assume the volume mount is broken when the real problem is a permission denied on the very first write. The durable fix is to chown -R vscode:vscode /commandhistory from a postCreateCommand or an image layer that runs as root, once, so the non-root user owns the directory before the first history write. Because the volume persists, that ownership sticks across rebuilds and you only pay the cost once.

The second pitfall is subtler and specific to how history is redirected: setting HISTFILE in a file the shell never sources. If you export it in .bash_profile, or in a .zshrc that an interactive-but-non-login container shell skips, the variable stays at its default and zsh keeps writing to ~/.zsh_history on the ephemeral layer while the volume sits mounted and empty. The symptom is maddening — the mount is present, ownership is correct, yet history still vanishes — because the config that points at the volume never ran. Confirm with echo $HISTFILE inside a fresh terminal that the value actually resolves to /commandhistory/.zsh_history, and put the export somewhere every session reads, such as .zshenv, rather than assuming a given rc file is on the login path.

History triageA triage path from vanishing history to durable, shared history.Is HISTFILE on a named volume?NOMove it onto a volumeCan the remoteUser write the volume?YESEnable append optionsDurable, shared history

SymptomRoot CauseRemediation
History empty after rebuildHISTFILE in ephemeral storagePoint HISTFILE at a named volume
Permission denied writing historyVolume owned by rootchown the volume dir to remoteUser
Terminals overwrite each otherNo incremental appendSet INC_APPEND_HISTORY / SHARE_HISTORY
Only recent history keptHISTSIZE/SAVEHIST too smallRaise both limits

Conclusion

History persists when its file lives somewhere the container doesn't discard. Mount a named volume, point HISTFILE at it, and enable incremental append so concurrent terminals share it. The volume outlives rebuilds, so your searchable history becomes durable state instead of a casualty of every rebuild.

The strategic payoff is larger than convenience. Once you have internalized that the container is disposable and named volumes are the durable layer, command history becomes just one more thing you deliberately pin outside the rebuild boundary — alongside package caches, language toolchain downloads, and build artifacts. Each of those follows the identical shape: identify state that is expensive to recreate, move it onto a named volume, and let the ephemeral container reattach to it by name. Persisting HISTFILE this way is a low-stakes place to practice the pattern, because getting it wrong costs you only a Ctrl-R result rather than a broken build, and getting it right makes the volume-as-durable-storage mental model concrete.

It also reinforces reproducibility rather than undermining it. A common worry is that persisted state makes an environment less reproducible, but history is read-only with respect to the build: nothing about your recorded commands changes how the image is produced, so the environment stays clean-rebuildable while the human keeps continuity. Delete the devcontainer-zsh-history volume and you are back to a pristine first run; keep it, and every rebuild feels like resuming rather than restarting.

Persist and shareA volume-backed HISTFILE persists history; append options share it across sessions.Persist viaNamed volumeHISTFILE on volumeLarge HISTSIZEShare viaINC_APPEND_HISTORYSHARE_HISTORYCorrect ownership

FAQ

Why does my shell history disappear on rebuild? Because the history file lives in the container's writable layer, which is discarded when the container is rebuilt. Move it onto a named volume by mounting a volume and pointing HISTFILE at a path inside it; the volume survives rebuilds, so the history does too. The key realization is that a rebuild replaces the container's filesystem wholesale, layers and writable overlay alike, so anything at the default ~/.zsh_history is gone by design. Nothing you can set inside the container's own filesystem survives that, which is why the fix has to move the file onto storage Docker manages separately from the image.

Does this work for fish and bash as well? Yes — the technique is shell-agnostic. Mount a volume and point the shell's history file at it: HISTFILE for bash, and fish's fish_history location. The persistence comes from the volume, not the shell, so any shell benefits from the same mount. In practice you can even share a single /commandhistory volume across shells and give each its own file inside it, for example /commandhistory/.zsh_history and /commandhistory/.bash_history, so a developer who switches shells keeps both histories durable. Only the redirection mechanism and the append-behavior settings differ per shell; the durable storage underneath is identical.

Why can't the container write to the history volume? Usually a permissions mismatch: the volume directory is owned by root but your remoteUser isn't root. chown the history directory to the remote user (in a hook or the Dockerfile) so the non-root user can write it, keeping history persistence and correct ownership together. Run the chown from a step that executes as root — a postCreateCommand or an image layer before the user is dropped — because the unprivileged remoteUser cannot fix ownership on a directory it does not already own. Since the volume is durable, you do this once and the corrected ownership persists across every subsequent rebuild.

Will a large HISTSIZE slow down my shell? Not noticeably at the 100000 values shown here. zsh reads the history file once at startup and appends incrementally afterward, so the cost is a single file read of a plain-text file that stays small even at a hundred thousand short lines. If you ever push the limits into the millions and notice startup lag, the file read is the thing to profile, but for ordinary interactive use a deep history buys you far more than it costs. The append options keep per-command overhead to a single write regardless of how large the file grows.

Should I commit the history volume or share it between projects? No — keep it out of version control and scoped per project. History is personal working memory, often containing project-specific paths, hostnames, or one-off commands you would not want to publish, so it belongs on a local named volume rather than in the repository. A per-project volume like devcontainer-zsh-history also keeps each project's commands separate and easy to reset independently, and naming the volume after the project nudges you toward that cleaner boundary.

How do I reset or clear the persisted history? Because the state now lives on a named volume, clearing it is a volume operation rather than a file edit inside the container. Remove the volume with docker volume rm devcontainer-zsh-history while no container has it mounted, then rebuild, and the next start recreates it empty. If you only want to trim rather than wipe, truncate the file directly with : > /commandhistory/.zsh_history from inside the container.