Make is awesome! It’s simple, familiar, and compatible with everything. Unfortunately, editing a Makefile can be challenging because it has a very terse and cryptic syntax. In this post, we will outline how we author them to get simple, yet powerful, build systems.

For the uninitiated, check out this gist by Isaac Schlueter. That gist takes the form of a heavily-commented Makefile, which makes it a great learning tool. In fact, I would recommend checking it out regardless of your skill level before reading the remainder of this post.

readability: documentation | dry

Here at Segment, we write a lot of code. One of our philosophies is that the code we write should be beautiful, especially since we’ll be spending literally hours a day looking at it.

By beautiful, we mean that code should not be convoluted and verbose, but instead it should be expressive and concise. This philosophy is even reflected in how we write a Makefile.

We dedicate the top section of each Makefile as a place to define variables (much like normal source code). These variables will be used to reduce the amount of code used in our recipes, making them far easier to read.

In node projects, we always rely on modules that are installed locally instead of globally. This allows us to give each project it’s own dependencies, giving us the room to upgrade freely without worrying about compatibility across our many other projects.

This decision requires more typing at first:

# globally installed
$ eslint .

# locally installed
$ ./node_modules/.bin/eslint .

But it’s easily fixed by using Makefile variables:

BIN := ./node_modules/.bin
ESLINT ?= $(BIN)/eslint

  @$(ESLINT) .

We use this same pattern frequently, as it helps to shorten the code written in a recipe, making the intention far more clear. This makes understanding the recipe much easier, which leads to faster development and maintenance.

Beyond just using variables for the command name, we also put shared flags behind their own variable as well.

BIN := ./node_modules/.bin
UGLIFY ?= $(BIN)/uglify-js

UGLIFY_FLAGS ?= --screw-ie8

build/%.min.js: build/%.js
  @$(UGLIFY) $(UGLIFY_FLAGS) $< > $@

This helps keep things dry, but also gives developers a hook to change the flags themselves if needed:

clean: documentation

When writing code and interacting with developer tools, we seek to avoid noiseas much as possible. There are enough things on a programmer’s mind, so it’s best to avoid adding to that cognitive load unnecessarilly.

One example is “echoing” in Make, which basically outputs each command of your recipe as it is being executed. You may notice that we used the @ prefix on the recipes above, which actually suppresses that behavior. This is a small thing, but it is part of the larger goal.

We also run many commands in “quiet mode”, which basically suppresses all output except errors. This is one case where we definitely want to alert the developer, so they can take the necessary action to correct it.

BIN := ./node_modules/.bin
DUO ?= $(BIN)/duo

DUO_FLAGS ?= --quiet --development

default: build/index.js build/index.css

build/%: %
  @$(DUO) $(DUO_FLAGS) $<

When running make, now we only will see errors that happened with the corresponding build. If nothing is output, we can assume everything went according to plan!


There are some target names that are so commonly used, they practically become a convention. While we haven’t invented most of the targets I will mention here, the main principle here is that using names consistently throughout an organization is important to improve the experience for developers new to a specific project.


Since we have a lot of web projects, the build/ directory is often reserved as the destination for any files we are bundling to serve to the client.


This target is used to delete any transient files from the project. This generally includes:

  • the build/ directory (the generated client assets)

  • intermediary build files/caches

  • test coverage reports

Remote dependencies are not part of this process. (see clean-deps)


Depending on the size and complexity of a project, the downloaded dependencies can take a considerable amount of time to completely resolve and download. As a result, they are cleaned using a distinct target.


While Make will automatically assume the first target in a Makefile is the default one to run, we adopt the convention of putting a default target in every Makefile, just for consistency and flexibility.

For our projects, the default target is usually synonymous with build, as it is common practice to enter a project and use make to kick off the initial build.


Runs static analysis (eg: JSHint, ESLint, etc) against the source code for this project.


This starts up the web server for the given project. (in the case of web projects)


This is exclusively for running the automated tests within a project. Depending on the complexity of the project, there could also be other related targets, such as test-browser or test-server. But regardless, the test target will be the entry-point for a developer to run those tests.


All in all, Make is a powerful tool suitable for many projects regardless of size, tooling and environment. Other tools like Grunt and Gulp are great, but Make comes out on top for being even more powerful, expressive and portable. It has become a staple in practically all of our projects, and the conventions we follow have helped to create a more predictable workflow for everyone on the team.