Dotfile management in a 15 line Makefile

This article is targeted (as are most things I write) at a ~year younger me. I’ve been searching for a dotfile management system that really clicked with me for some time – I’ve fussed with a variety of shell scripts and experimented around, spending no small amount of time on

I’ve finally created a system using Make that I’m pretty happy with, and I’ll walk through it in this article. (If you want to skip to the punch line, the Makefile in question is here)

The Problem

I have a bunch of dotfiles (you probably do too). On some machines / servers I use bash, other times I use zsh. I usually use iTerm2 as an emulator, but sometimes I’m on Windows have to use something else. I usually use Vim, except when I … you get the point. My habits are fairly consistent, but there’s always exceptions. I wanted a simple solution that allowed me to easily manage my dotfiles between machines, regardless of my setup. This appears to be a very common problem with a number of excellent solutions (see GNU Stow and many many others) – I’m sure there are plenty of improvements on mine.

The Solution

Git is a pretty obvious choice for version control of dotfiles, and GitHub to easily store them on a remote server. I could clone them locally directly to $HOME, but having a git repository in your top level directory is pretty annoying.

Let’s do automatic symlinks using a Makefile instead. I could manually set the symlinks myself, but that’s annoying. I’m essentially using Make as a way to hold a collection of shell scripts – this could be done almost as easily using a simple file. However, Make is ubiquitous and has some benefits (as we’ll see).

At a high level, the goal is to be able to run make and have all my symlinks automatically set up (along with anything else that’s convenient when setting up dotfiles). This is basically the equivalent of manually running:

~/dotfiles $ ln -s ~/dotfiles/vimrc ~/.vimrc
~/dotfiles $ ln -s ~/dotfiles/bashrc ~/.bashrc
# etc etc

One note about ln: running

~/dotfiles $ ln -s ./vimrc ~/.vimrc

(note the relative path) will not work. For a symlink to be created, absolute paths must be used (~ is allowable).

Here’s how I did that using Make:

  • Create a list of files I want to symlink
  • Use that list as a Makefile target
  • For that target, run the appropriate ln command.

This is fairly straightforward, with a few speedbumps that I’ll go through. First up, the actual Makefile:

SHELL := /bin/bash
DOTFILES := $(filter-out $(EXCLUDED_FILES), $(wildcard *))
MAKEFILE_PATH := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))

dots: $(DOTFILES)

$(DOTFILES): # for each dotfile, symlink it to the home directory
    @ln -svfn $(MAKEFILE_PATH)/$@ ~/.$@

.PHONY: dots $(DOTFILES) clean

Note: this is really quite simple, there’s not much going on here. This honestly might not even be worth a post, but for the amount of iterations of dotfile management I’ve gone through, and how simple and effective this is, I figured I’d share it.

DOTFILES is that list of files I mentioned – it’s just every file in the current directory except the Makefile itself, and my README. Obviously, I don’t want to symlink my Makefile or README to my home directory.

The target / dependency link is a bit tricky if you’re not familiar with Make – when you run make dots (or just make, as dots is the default target), Make will evaluate the dependencies of dots, which in this case is the list of dotfiles. So, dots: $(DOTFILES) is the same as dots: gitconfig vimrc bashrc [...etc]

For each file in the makefile list, it then runs the @ln command. Prefixing commands with @ in Makefiles prevents the command itself from being printed – instead, I’m using the -v flag to verbosely print what’s going on.

$@ is the argument – in this case, it’s each of the items in the dotfiles list, one at a time. This uses Make’s built in functionality instead of writing a for file in files loop. I wanted to lean as heavily as possible on Git and Make here, instead of reinventing the wheel a bunch of times.


A quick note about .PHONY and the purpose of Make – Make is a build automation tool, and I’m abusing it a bit here. It has a lot of features for big (often C/C++) software projects (example: checking if source files have changed and therefore must be recompiled), and I’m using it essentially as a collection of shell commands. The $(DOTFILES) target is a bit weird, because all of those files already exist, which is kind of the opposite of what Make expects. As such, I’m adding everything to .PHONY, which prevents Make from actually checking if files exist – it just runs the command every time blindly.

Okay, so we’ve created a simple way to symlink a bunch of stuff to my home directory, and saved it in a Makefile so I never have to remember any of the commands. What else is valuable?

make clean

It’d be nice to be able to easily remove all the symlinks (or delete existing files before adding the symlinks), so let’s add that:

clean: # interactively remove all dotfiles in the home directory
    @rm -i $(addprefix ~/., $(DOTFILES))

addprefix is another Makefile helper, it’ll add ~/. to the beginning of every item in the $(DOTFILES) list.

Machine Specific Functionality

The last feature I wanted was a machine_specific file – a place to put any arbitrary PATH setting or anything else I didn’t want to commit. I wanted the file stub to be saved in version control so it’s present when I clone it, but not to have any changes tracked by git. After some googling, here’s what I came up with:

dots: $(DOTFILES)
    git update-index --skip-worktree machine_specific # ignore changes forever

Now when I run make dots, Make will first run all the dependencies (in this case, creating a bunch of symlinks), and then run git update-index --skip-worktree machine_specific, which will forever ignore changes I make to the machine_specific file. Both bashrc and zshrc source this file, so I can add anything I want to it and be confident that

  1. It’ll always be sourced when I need it
  2. Changes will never get pushed to GitHub

The obvious issue is that as I add snippets to machine_specific, there’s the potential to lose code if a machine goes down. However, my solution to that is “don’t ever add stuff to machine_specific that you wouldn’t be okay with losing tomorrow”.

That’s about it! This solution covers my needs nicely, while staying very simple and lightweight.

  1. Easily deployable to a new machine, no dependencies besides Make
  2. Automatically symlinks dotfiles with one command
  3. A machine_specific file that’s nicely handled by Git, so I can make sure my PATH is right on each system
  4. No long winded brittle shell scripts