cat ~/posts/managing-dotfiles.md
Managing dotfiles with Stow and Nix flakes
If you spend enough time in the terminal, you'll eventually want your configuration to follow you from machine to machine. A git-tracked dotfiles repo is the standard way to solve that.
First, choose a home for your dotfiles.
It can live anywhere. Many people use $HOME/.dotfiles. I prefer $HOME/.config/dotfiles because it keeps my home directory clean.
mkdir -p $HOME/.config/dotfiles
cd $HOME/.config/dotfiles
Inside it, create a home directory for the actual configuration files.
mkdir -p home
Add your essential files to the home directory.
touch home/.bash_profile
touch home/.bashrc
touch home/.gitconfig
mkdir -p home/.config && touch home/.config/starship.toml
mkdir -p home/.config/tmux && touch home/.config/tmux/tmux.conf
Then initialize a Git repository.
git init
git add .
git commit -m "chore: initialized dotfiles"
To symlink these files into your home directory, use Stow. It maps directories to symlinks without custom scripts.
stow --dir="$HOME/.config/dotfiles/home" --target="$HOME" --adopt --restow .
This command creates symlinks in $HOME that point back to your dotfiles repository.
--adoptoverwrites the repo copy with whatever is already on the host.--restowremoves existing symlinks and recreates them, so the command works whether you're running it for the first time or the tenth.
$ ls -la ~
.bash_profile -> .config/dotfiles/home/.bash_profile
.bashrc -> .config/dotfiles/home/.bashrc
.gitconfig -> .config/dotfiles/home/.gitconfig
.config/starship.toml -> .config/dotfiles/home/.config/starship.toml
.config/tmux -> .config/dotfiles/home/.config/tmux
That handles config files. It does not handle packages.
On a new machine, you can wrap that setup in a scripts/bootstrap.sh script.
#!/usr/bin/env bash
set -euo pipefail
DOTFILES_URL="https://github.com/username/dotfiles.git"
DOTFILES_BRANCH="master"
DOTFILES_PATH="$HOME/.config/dotfiles"
TARGET_PATH="$HOME"
if [ ! -d "$DOTFILES_PATH" ]; then
echo "Cloning dotfiles..."
git clone "$DOTFILES_URL" "$DOTFILES_PATH"
else
echo "Pulling latest changes..."
git -C "$DOTFILES_PATH" pull origin "$DOTFILES_BRANCH"
fi
echo "Stowing dotfiles..."
stow --dir="$DOTFILES_PATH/home" --target="$TARGET_PATH" --adopt --restow .
Assuming git, stow, and curl are available, a fresh system only needs:
bash <(curl -s https://raw.githubusercontent.com/username/dotfiles/master/scripts/bootstrap.sh)
This setup handles configuration well enough, but packages are still missing.
Config files are useless without the packages they configure.
Your tmux.conf does nothing if tmux isn't installed.
Now we have three options:
- Go outside and touch grass
- Manually install packages on every new machine
- Automate it
We're choosing option 3, of course.
Shell-scripted package installation scales poorly.
You end up with a mess of if statements checking for apt, pacman, or brew, and you still have to handle different package names across distributions.
We need a package manager that ignores the host OS.
Why Nix
Nix is a purely functional package manager and language that works on Linux, macOS, and WSL2. You write declarative expressions that describe what you want installed, and Nix builds it: same packages, same versions, across host distros.
When introducing Nix to a dotfiles setup, it is tempting to go all-in with tools like Home Manager. Home Manager generates both packages and config files from Nix code. The downside is you have to translate your existing configs into Nix syntax, which creates heavy lock-in.
Instead, let's take a hybrid approach: Stow for symlinks, Nix for packages.
Your config files stay as normal dotfiles. Nix just makes sure the binaries are present.
Creating a Dotfiles Flake
Nix flakes are the modern way to package Nix expressions.
A flake.nix file at the root of your project declares its dependencies and outputs.
Here is a flake.nix that defines the packages we need and an app to run our bootstrap script. flake-parts is there only to keep the multi-system boilerplate small.
{
description = "dotfiles flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs@{ flake-parts, nixpkgs, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
perSystem = { pkgs, ... }: {
apps = {
bootstrap = {
type = "app";
program = "${./scripts/bootstrap.sh}";
meta.description = "Bootstrap dotfiles on the system";
};
};
packages = {
default = pkgs.buildEnv {
name = "dotfiles";
paths = with pkgs; [
bash-completion
starship
direnv
nix-direnv
git
gh
lazygit
delta
fd
ripgrep
fzf
bat
eza
jq
curl
neovim
tmux
nodejs
];
};
};
};
};
}
packages.default uses pkgs.buildEnv to bundle the tools we want, and apps.bootstrap points to the bootstrap script.
This can be as minimal or as opinionated as you want: shell tools, Git clients, search utilities, editors, and runtimes like nodejs can all live in the same flake.
Now that we have Nix, we can use a Nix shebang to declare every dependency the script needs.
#!/usr/bin/env -S nix shell nixpkgs#bash nixpkgs#git nixpkgs#stow -c bash
set -euo pipefail
DOTFILES_URL="https://github.com/username/dotfiles.git"
DOTFILES_BRANCH="master"
DOTFILES_PATH="$HOME/.config/dotfiles"
TARGET_PATH="$HOME"
if [ ! -d "$DOTFILES_PATH" ]; then
echo "Cloning dotfiles..."
git clone "$DOTFILES_URL" "$DOTFILES_PATH"
else
echo "Pulling latest changes..."
git -C "$DOTFILES_PATH" pull origin "$DOTFILES_BRANCH"
fi
echo "Stowing dotfiles..."
stow --dir="$DOTFILES_PATH/home" --target="$TARGET_PATH" --adopt .
echo "Installing packages with Nix..."
nix profile install "$DOTFILES_PATH"
nix profile upgrade dotfiles
nix profile wipe-history --older-than 7d
- The shebang
#!/usr/bin/env -S nix shell ...uses Nix to providebash,git, andstowif they aren't already on the systemPATH. nix profile installadds the flake'spackages.defaultto your user profile. If the entry already exists, it's a no-op.nix profile upgrade dotfilesre-evaluates the flake against the currentflake.lockon disk. This is what actually picks up new package versions after agit pullbrings in an updated lock file.nix profile wipe-history --older-than 7dremoves old profile generations to reclaim disk space.
This single file declares the packages you want and how to bootstrap them.
Nixpkgs has over 120,000 packages and gets thousands of commits per week, so most tools you need are already there. Sometimes, though, you may want to bundle plugins or wrap a binary with extra flags.
At this point, the setup already works. Overlays are an optional refinement if you want to customize packages too.
Optional: Customizing Packages with Overlays
Nix overlays let you apply customizations on top of nixpkgs.
An overlay is a function with two arguments: final (the package set after all overlays) and prev (the set before this overlay).
It returns the packages you want to override.
Tmux
For example, you can create an overlay that bundles tmux plugins directly into the tmux package.
Create a file at nix/overlays/tmux/default.nix:
final: prev:
let
inherit (prev) makeWrapper tmuxPlugins writeText;
config = writeText "tmux.conf" ''
source-file ~/.config/tmux/tmux.conf
run-shell ${tmuxPlugins.sensible.rtp}
run-shell ${tmuxPlugins.yank.rtp}
'';
in
{
tmux = prev.symlinkJoin {
name = "tmux-wrapped";
paths = [ prev.tmux ];
nativeBuildInputs = [ makeWrapper ];
postBuild = ''
wrapProgram $out/bin/tmux \
--add-flags "-f ${config}"
'';
};
}
This overlay overrides the default tmux package to include plugins like sensible and yank.
It wraps the tmux binary so it sources those plugins on startup, right after it loads your normal ~/.config/tmux/tmux.conf.
Neovim
For Neovim, bundling everything into a single derivation actually makes sense. The Lua config depends directly on which plugins and language servers are present, so keeping config, plugins, and runtime dependencies together avoids breakage. I'll go deeper on this in a future post, but here is a simple example.
Create a file at nix/overlays/neovim/default.nix:
final: prev:
let
inherit (prev)
lib
wrapNeovimUnstable
neovimUtils
neovim-unwrapped
vimPlugins
lua-language-server
typescript-language-server
tailwindcss-language-server
;
plugins = [
vimPlugins.nvim-treesitter.withAllGrammars
vimPlugins.nvim-lspconfig
vimPlugins.blink-cmp
vimPlugins.conform-nvim
# ...
];
runtimeDeps = [
lua-language-server
typescript-language-server
tailwindcss-language-server
# ...
];
neovimConfig =
neovimUtils.makeNeovimConfig {
withNodeJs = false;
withPython3 = false;
withRuby = false;
wrapRc = true;
customLuaRC = /* lua */ ''
vim.g.mapleader = " "
vim.g.maplocalleader = "\\"
vim.opt.number = true
vim.opt.relativenumber = true
vim.opt.tabstop = 2
vim.opt.shiftwidth = 2
vim.opt.smartcase = true
vim.opt.ignorecase = true
vim.opt.splitright = true
vim.opt.splitbelow = true
'';
inherit plugins;
}
// {
wrapperArgs = ["--prefix" "PATH" ":" (lib.makeBinPath runtimeDeps)];
};
in
{
neovim = wrapNeovimUnstable neovim-unwrapped neovimConfig;
}
To use these overlays, update your flake.nix to apply them.
Instead of using the pkgs provided by flake-parts, we import nixpkgs ourselves so we can pass in the overlays.
{
description = "My dotfiles";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-parts.url = "github:hercules-ci/flake-parts";
};
outputs = inputs@{ flake-parts, nixpkgs, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
perSystem = { system, ... }:
let
pkgs = import nixpkgs {
inherit system;
overlays = [
(import ./nix/overlays/tmux)
(import ./nix/overlays/neovim)
];
};
in
{
apps = {
bootstrap = {
type = "app";
program = "${./scripts/bootstrap.sh}";
meta.description = "Bootstrap dotfiles on the system";
};
};
packages = {
default = pkgs.buildEnv {
name = "dotfiles";
paths = with pkgs; [
bash-completion
starship
direnv
nix-direnv
git
gh
lazygit
delta
fd
ripgrep
fzf
bat
eza
jq
curl
neovim
tmux
nodejs
];
};
};
};
};
}
Since we import nixpkgs with our overlays, the tmux and neovim entries in packages.default are now our wrapped versions.
Putting It Together
On a fresh system, you only need to:
- Install Nix with Determinate Systems' installer
Note
You can also install Nix with the official installer, but I am using Determinate Systems' installer here because it enables the modern nix command and flakes out of the box.
curl -fsSL https://install.determinate.systems/nix | sh -s -- install
- Run the bootstrap script
nix run github:username/dotfiles#bootstrap
Keeping Packages Up to Date
Your flake points to a specific commit of nixpkgs.
To get new package versions, you need to update your flake inputs.
You can automate that with a GitHub Actions workflow using actions from Determinate Systems. That keeps CI aligned with the local install path: nix-installer-action installs Nix with flakes enabled out of the box, and update-flake-lock updates your lock file and opens a PR.
Create a file at .github/workflows/update-flake-lock.yml:
name: "Update flake lock"
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * *"
jobs:
update-lock-file:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Update flake.lock
uses: DeterminateSystems/update-flake-lock@main
with:
pr-labels: |
dependencies
automated
automerge
commit-msg: "chore(deps): update dependencies"
token: ${{ secrets.GH_REPO_PAT }}
This workflow runs on a schedule, updates flake.lock when new versions are available, and opens a pull request with the changes.
That triggers a separate pull request workflow that runs nix flake check on the systems you care about — Linux and macOS in my case.
If all packages resolve on both systems, the pull request is auto-merged.
name: "Pull request"
on:
pull_request:
jobs:
check:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- run: nix flake check
automerge:
if: contains(github.event.pull_request.labels.*.name, 'automerge')
needs: [check]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
- run: gh pr merge --rebase --auto
env:
GH_TOKEN: ${{ secrets.GH_REPO_PAT }}
With this in place, your dotfiles stay up to date without manual work.
If anything breaks, run nix profile rollback and wait for fixes upstream.
You can also revert to an older "chore(deps): update dependencies" commit.
For convenience, I add this to my .bashrc:
export DOTFILES_PATH="$HOME/.config/dotfiles"
if [ -n "$DOTFILES_PATH" ]; then
alias update-dotfiles='(cd $DOTFILES_PATH && git pull origin master && nix run .#bootstrap)'
fi
Then, when you want to update your dotfiles:
update-dotfiles
The final repository structure looks like this:
dotfiles/
├── .github/
│ └── workflows/
│ ├── pull-request.yml
│ └── update-flake-lock.yml
├── home/
│ ├── .bash_profile
│ ├── .bashrc
│ ├── .gitconfig
│ └── .config/
│ ├── starship.toml
│ └── tmux/
│ └── tmux.conf
├── nix/
│ └── overlays/
│ ├── neovim/
│ │ └── default.nix
│ └── tmux/
│ └── default.nix
├── scripts/
│ └── bootstrap.sh
├── flake.lock
└── flake.nix
On a fresh machine, a single nix run gives you every tool and config you need.
Updates land automatically through CI, and if something breaks, nix profile rollback gets you back in seconds.