Using GNU Stow for Dotfiles in a DevContainer

GNU Stow turns a dotfiles directory into a tidy symlink farm, and its idempotence makes it a natural fit for a devcontainer's re-running bootstrap. This page uses Stow to link dotfiles into $HOME, so re-runs converge and there's nothing to duplicate.

The reason this matters inside a container is that the bootstrap script is not a one-time installer the way it is on a laptop you set up once. A devcontainer is rebuilt whenever the image changes, whenever the volume is recreated, and often several times a day as you iterate on the devcontainer.json. Any dotfiles mechanism you choose runs on every one of those creates, so it has to behave the same on the fifth run as it did on the first. A naive shell script that does ln -s ~/.dotfiles/zsh/.zshrc ~/.zshrc fails the second time because the target already exists, and guarding every link with rm -f before it turns your bootstrap into a wall of defensive boilerplate that is easy to get subtly wrong. Stow was built for exactly this convergence problem: it treats ~/.dotfiles/zsh as a package and stows its contents into $HOME, refusing to clobber real files and quietly no-oping on links it already owns.

Reach for Stow when your dotfiles are plain files you want to keep in one Git repository and link into place unchanged — no templating, no per-host secrets substitution, no rendered output. That covers the common case of a .zshrc, a .gitconfig, a .tmux.conf, and a handful of ~/.config trees that are identical across every machine and container you touch. The mental model is a symlink farm: your repo is the source of truth, $HOME holds only pointers back into it, and editing a file in the container edits the file in the repo because they are the same inode. If you instead need values that differ per environment, a templating tool like chezmoi is the better fit; Stow deliberately does one small thing, which is why its behavior under repeated runs is so easy to predict.

Prerequisites

You need a Stow-structured dotfiles repo and Stow in the container.

  • A dotfiles repo laid out as Stow packages.
  • stow installed in the image.
  • The dotfiles keys pointing at your repo.

Stow prerequisitesYou need a Stow layout, an apply step, idempotence, and a link check.Stow layoutpackages/stow applysymlink into $HOMEIdempotentre-runs convergeVerifylinks exist

The one detail people get wrong is the repository layout. Stow does not link individual files; it links the contents of a package directory as if that directory were $HOME. So a package named zsh must contain .zshrc at its top level — the path zsh/.zshrc means "when I stow zsh, put .zshrc in the target." If you flatten everything into the repo root, or nest an extra directory like zsh/dotfiles/.zshrc, Stow will either link nothing useful or reproduce your mistake as a symlink. Think of each package as a mirror of the home directory that holds only the files that package owns.

The other prerequisite worth stating plainly is where Stow runs and what its target is. By default Stow assumes the target directory is the parent of the current directory, which is why the bootstrap cds into $HOME/.dotfiles before invoking stow — from there the parent is $HOME, exactly where the links belong. The dotfiles keys in devcontainer.json are what clone your repo into the container and hand control to your install.sh; without them there is no repo on disk for Stow to act on, so confirm they point at the right remote and the right entry script before you debug anything inside the bootstrap itself.

Step-by-Step Implementation

  1. Lay out dotfiles as Stow packages.
dotfiles/zsh/.zshrc
dotfiles/git/.gitconfig

This layout is the whole trick. dotfiles/zsh and dotfiles/git are two independent packages, and the path inside each package is the path the file will occupy relative to $HOME. When you later run stow zsh, Stow reads zsh/.zshrc and creates ~/.zshrc pointing back into the repo; stow git does the same for ~/.gitconfig. Keeping one directory per tool is not cosmetic — it lets you stow or unstow a single tool without touching the others, which matters when you want a container to pick up your shell config but skip an editor package it has no use for. The failure mode this prevents is the "everything is one blob" repo, where a single conflict anywhere blocks the entire apply and you cannot reason about which file caused it.

  1. Install Stow and apply in the bootstrap.
#!/usr/bin/env bash
set -e
command -v stow >/dev/null || sudo apt-get install -y stow
cd "$HOME/.dotfiles" && stow zsh git

Each line earns its place. set -e makes the script abort the moment a step fails, so a missing package or a stow conflict surfaces as a red bootstrap rather than a container that silently comes up half-configured. The command -v stow >/dev/null || sudo apt-get install -y stow guard installs Stow only when it is absent — on a base image that already ships it, or on a re-run where a previous create installed it, the guard short-circuits and you skip a slow, network-bound apt-get. The cd "$HOME/.dotfiles" is the load-bearing part: it puts you one level below $HOME so Stow's default target resolves to the home directory without you having to pass --target explicitly. Running stow zsh git in one invocation applies both packages atomically from Stow's point of view, and because Stow no-ops on links it already owns, this exact command is safe to run on the first create and every create after it.

  1. Point the dotfiles keys at the repo.
{ "dotfiles": { "repository": "https://github.com/you/dotfiles", "installCommand": "install.sh" }, "remoteUser": "vscode" }

These keys are what connect the tooling to the repo. dotfiles.repository tells the client to clone https://github.com/you/dotfiles into the container — by default into ~/dotfiles for the remoteUser — and dotfiles.installCommand names the script to run once the clone lands, here install.sh, which is where your Stow invocation from step 2 lives. Setting remoteUser to vscode matters because it decides whose $HOME gets the links; if the install command runs as one user but you open a shell as another, the symlinks point into a home directory nobody is using and your config appears to vanish. Note the clone path and the cd in the bootstrap have to agree: if the client clones to ~/dotfiles but your script cds into ~/.dotfiles, the script fails immediately, so keep the two in sync.

  1. Verify the symlinks exist.
readlink ~/.zshrc   # -> .dotfiles/zsh/.zshrc

readlink ~/.zshrc is the fastest way to confirm the apply actually did what you think. If it prints a path back into .dotfiles/zsh/.zshrc, the link exists and points at your repo, which is the state you want. If it prints nothing, ~/.zshrc is either absent or a real file rather than a symlink — the two failure modes that mean Stow never ran or hit a conflict and was aborted by set -e. Doing this check in the bootstrap itself, or at least the first time you open a rebuilt container, turns a silent misconfiguration into an obvious signal; the alternative is discovering three days later that your shell has been loading the image's default .zshrc all along. For a package with several files you can run stow -n -v zsh instead, which simulates the apply and prints which links it would create without touching the filesystem.

Stow modelStow symlinks package directories into home idempotently, so re-runs converge.Stow packagesone dir per toolstow <pkg>symlinks into $HOMEIdempotentre-apply is a no-opResulttidy, reversible dotfiles

Common Pitfalls

Stow issues are conflicts with existing files or a wrong layout.

The most common surprise in a container comes from ownership and pre-seeded files. Many base images ship a skeleton home directory copied from /etc/skel, so ~/.bashrc or ~/.profile may already exist as a real file before your bootstrap runs. Stow correctly refuses to overwrite it and reports a conflict, and because set -e is in force the whole apply aborts. The fix is to decide, once, whether the image's version or your repo's version wins: either delete the skeleton file at the top of install.sh before stowing, or use stow --adopt to pull the existing file's contents into your package and then link it. Do not paper over this with sudo, which only moves the problem — if the links end up owned by root while you work as vscode, your editor cannot write through them and every save fails. Keep the whole apply running as the remoteUser so ownership on the links matches the account that will use them.

The second recurring trap is treating an already-linked tree as a conflict on re-run. If a .config directory was stowed once and you later add a file to that package, a plain stow pkg can complain that the directory is not empty or is already a link. This is where stow -R (equivalently --restow) earns its keep: it unstows the package and re-stows it in one pass, reconciling the farm with whatever the repo now contains. Wiring stow -R zsh git into the bootstrap instead of a bare stow makes the step genuinely idempotent across content changes, not just across identical re-runs, which is exactly the property you want when the same script runs on every container create.

Stow triageA triage path from a Stow conflict to a clean, idempotent symlink farm.Does stow report a conflict?YESAdopt or remove the existing fileIs the package layout correct?YESRe-apply is idempotentClean symlinked dotfiles

SymptomRoot CauseRemediation
stow: conflict on existing fileA real file blocks the symlinkRemove/adopt it, then re-stow
Nothing gets linkedWrong package directory layoutStructure as package/relative-path
Re-run duplicates nothing but errorsAlready-linked treated as conflictUse --restow to refresh
stow not foundNot installed in the imageInstall stow in the bootstrap or image

Conclusion

GNU Stow makes dotfiles a reversible symlink farm: each package directory maps into $HOME, and re-applying is idempotent, so a devcontainer's re-running bootstrap converges cleanly. Structure your repo as Stow packages and the bootstrap stays trivially correct.

The strategic payoff is that Stow moves the hard part of dotfiles management out of your shell script and into a directory layout. Once the repo is structured as packages, the bootstrap collapses to a single stow -R zsh git that is safe to run any number of times, and the thing you version and review is data — the tree of files — rather than imperative link-and-guard logic that drifts over time. That separation is what makes the setup auditable: a teammate can look at the repo and know exactly which files land where, without tracing through conditionals. It is the same discipline as pinning a base image or caching a dependency layer: you push variability out of the runtime path and into a declared, reproducible source of truth, so the outcome no longer depends on which run you are on or what state the previous run left behind.

Seen that way, Stow is one instance of the broader reproducibility theme this section keeps returning to. A devcontainer's whole promise is that the environment is rebuildable from a spec rather than accreted by hand, and dotfiles are the last mile of that promise — the personal layer that usually resists being captured. By expressing your configuration as a package layout that stows deterministically into $HOME, you fold that last mile into the same reproducible pipeline as the image and the features. When you outgrow plain files and need per-host values or secrets, a templating tool such as chezmoi is the natural next step, but the mental model carries over.

Stow summaryStow gives idempotent, reversible dotfiles given a package layout.Stow givesIdempotent linkingReversible setupClear structureNeedsPackage layoutStow installedConflict-free $HOME

FAQ

Why use Stow over a hand-written symlink script? Stow is idempotent and reversible by design: re-applying converges, and stow -D cleanly removes links. A devcontainer re-runs the bootstrap on every create, so Stow's convergence means no duplicated links or accumulated state, without hand-guarding every ln. A hand-written script has to answer questions Stow already answers for you — does the target exist, is it a link, does it point where I expect — and every one of those branches is a place for the script to rot. Stow encodes those answers once and refuses to clobber real files, so the same command is safe on a fresh container and on the hundredth rebuild.

How should I lay out a Stow dotfiles repo? One package directory per tool, mirroring the path relative to $HOME. For example zsh/.zshrc and git/.gitconfig; running stow zsh git links ~/.zshrc and ~/.gitconfig. The structure makes what-links-where obvious. For files that live deeper, such as ~/.config/nvim/init.lua, replicate the full relative path inside the package as nvim/.config/nvim/init.lua, and Stow will create the intermediate directories and link the leaf. Keeping one package per tool also means you can stow a subset in a lightweight container and the full set on your workstation from the very same repo.

What if a real file already exists where Stow wants a link? Stow reports a conflict rather than clobbering it. Remove or adopt the existing file (Stow's --adopt can absorb it into the package), then re-stow. This safety is why Stow is predictable in a shared bootstrap. Be deliberate about --adopt, though: it moves the existing file's contents into your repo and replaces the original with a link, so run it against a clean working tree and diff the result before committing, or you may quietly overwrite the repo's version with an image's skeleton file.

How do I remove or refresh links Stow created? Use stow -D zsh to unstow a package, which removes only the symlinks Stow owns and leaves any real files untouched. To reconcile links with a package whose contents changed, stow -R zsh (restow) unstows and re-stows in a single pass, keeping the bootstrap idempotent even after you add or rename files in a package.

Where does Stow think the target directory is? By default Stow targets the parent of the current working directory, which is why the bootstrap cds into $HOME/.dotfiles so the parent resolves to $HOME. If your repo lives somewhere else, pass it explicitly with stow --target="$HOME" --dir=/path/to/dotfiles zsh rather than relying on the cd, which makes the intent obvious and removes a class of "linked into the wrong directory" bugs.