Introduction

Many developers may recall the first day they started working on a new project. After cloning the main repository, there comes a point when you have to enter a lot of commands with certain flags and in a specific order. In most cases, it’s hard to grasp what is going on without a description of the commands. For example:

# Bash
touch ~/.bash_history
ufw allow 3035/tcp || echo 'cant configure ufw'
ufw allow http || echo 'cant configure ufw'
docker run \
  -v /root/:/root/ \
  -v /etc:/etc \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /var/tmp:/var/tmp \
  -v /tmp:/tmp \
  -v $PWD:/app \
  --network host \
  -w /app \
  --env-file .env \
  ansible ansible-playbook ansible/development.yml -i ansible/development --limit=localhost -vv
grep -qxF 'fs.inotify.max_user_watches=524288' /etc/sysctl.conf || echo fs.inotify.max_user_watches=524288 | tee -a /etc/sysctl.conf || echo 'cant set max_user_watches' && sysctl -p
sudo systemctl daemon-reload && sudo systemctl restart docker

These commands are just a small portion of the project deployment process. The commands themselves are extensive and contain several flags, as shown in the example above, making them not only difficult to learn but also difficult to enter manually. Constantly maintaining documentation becomes more challenging as the project grows; it inevitably becomes outdated, and the entry barrier for newcomers increases since no one can remember all of the project’s details. Some of these commands must be used on a daily basis, if not multiple times per day.

Over time, it became clear that we desperately needed a tool that could maintain such commands, and provide convenient shortcuts and self-documentation of the project. This is precisely what Makefile and the make utility have turned into. In this guide, I’ll show you how to reduce the deployment to a few short and straightforward commands using these tools:

# Bash
make setup
make start
make test

What is make and Makefile

Makefile is a file that is stored in the repository alongside the code. It is usually placed at the project’s root. It acts both as documentation and as executable code. The Makefile hides the implementation details and manages the commands, and the make utility runs them from the Makefile in the current directory.

make was originally designed to automate the building process of executable programs and libraries from source code. Most *nix distributions have it by default, which has contributed to its extensive use. Later, it turned out that this tool is convenient to use in other development projects because the process is essentially the same in most cases - automation and building applications.

The make has become a standard for many developers, especially for those working on large projects. Examples of makefile can be found in projects such as Kubernetes, Babel, Ansible, and, of course, everywhere on Hexlet.

Makefile syntax

make runs targets from a Makefile that contains the following commands:

# Makefile
target1: # arget name, you can also use kebab-case or snake_case
	command1 # it's very important to use tabs to indent
	command2 # the commands will be executed sequentially and only if the previous one was successful

However, it’s not enough to just start using a Makefile in a project. To make its implementation more efficient, build a target-oriented command structure and give the targets semantically relevant names. At first, moving commands to a Makefile may result in all commands being merged into a single one with a vague name:

# Makefile
up: # deploying and launching
	cp -n .env.example .env
	touch database/database.sqlite
	composer install
	npm install
	php artisan key:generate
	php artisan migrate --seed
	heroku local -f Procfile.dev # project launch

Several actions take place here at once: creating a file with environment variables, preparing the database, generating keys, installing dependencies, and launching the project. Since this is impossible to understand from the comments and target name, it’s best to separate these commands into different independent targets:

# Makefile
env-prepare: # create .env file for secrets
	cp -n .env.example .env

sqlite-prepare: # prepare a local database
	touch database/database.sqlite

install: # install dependencies
	composer install
	npm install

key: # generate keys
	php artisan key:generate

db-prepare: # upload data to the database
	php artisan migrate --seed

start: # run the app
	heroku local -f Procfile.dev

Now that the commands are divided into targets, you can individually install dependencies with the make install command or run your app via make start. But the remaining targets are only required during the project’s initial deployment and must be performed in a specific sequence. In the Makefile world, the target has the following prerequisites:

# Makefile
target1: target2 # here established the command dependency, target1 depends on target2
	command2 # target2 will be executed only if target2 command was successful

target2:
	command1

Commands will only be executed in the specified order and only if the previous command proves to be successful. Therefore, you can add a setup target to combine all the necessary actions:

# Makefile
setup: env-prepare sqlite-prepare install key db-prepare # you may refer to the targets described below

env-prepare:
	cp -n .env.example .env

sqlite-prepare:
	touch database/database.sqlite

install:
	composer install
	npm install

key:
	php artisan key:generate

db-prepare:
	php artisan migrate --seed

start:
	heroku local -f Procfile.dev

Now it is enough to deploy and launch the project with two commands:

# Bash
make setup # will run sequentially: env-prepare sqlite-prepare install key db-prepare
make start

The project commands and flags are combined into a Makefile as a result of the Makefile’s work. It ensures the correct execution order, regardless of the languages or technologies involved.

Advanced usage

Fake target

Using make in a project may one day lead to the error make: <target name> is up to date., although everything is written correctly. This is frequently related to the existence of a directory or file that matches the target name. For example:

# Makefile
test: # the target in the makefile
	php artisan test
# Bash
$ ls
Makefile
test # the file system contains a directory with the name of the target in the makefile

$ make test # an attempt to run tests
make: `test` is up to date.

As stated previously, make was designed to build programs from source code. Therefore, it searches for a directory or file with the given name and attempts to create a project from it. To alter this behavior, you need to add a .PHONY pointer to the target at the end of the Makefile:

# Makefile
test:
	php artisan test

.PHONY: test
# Bash
$ make test
✓ All tests passed!

Running commands consecutively and ignoring errors

You can run commands one at a time: make setup, make start, make test or all at once, space-separated: make setup start test. The latter method works as a dependency between commands, although it is not documented in the Makefile. Difficulties may arise if one of the commands produces an error that must be ignored. In the previous examples, such a command was to create an .env-file when deploying the project:

# Makefile
env-prepare:
	cp -n .env.example .env # if the file has already been created, using this command again will result in an error

The easiest (but not the only) way to “cover up” an error is to use a logical OR in the Makefile:

# Makefile
env-prepare:
	cp -n .env.example .env || true # any result of command execution is now considered successful

Be cautious applying such hacks so that you don’t shoot yourself in the foot in more complex scenarios.

Variables

Configuration parameters, path indicators, and environment variables are often substituted into commands, and make enables you to handle this as well. Variables can be written directly in the command within the makefile and passed when called:

# Makefile
say:
	echo "Hello, $(HELLO)!"
# Bash
$ make say HELLO=World
echo "Hello, World!"
Hello, World!

$ make say HELLO=Kitty
echo "Hello, Kitty!"
Hello, Kitty!

Variables can be optional and have a default value. They are commonly declared at the beginning of the Makefile.

# Makefile
HELLO?=World # the question mark indicates that the variable is optional. The value after assignment can be omitted

say:
	echo "Hello, $(HELLO)!"
# Bash
$ make say
echo "Hello, World!"
Hello, World!

$ make say HELLO=Kitty
echo "Hello, Kitty!"
Hello, Kitty!

Some variables in the Makefile have names other than the system ones. For example, $PWD is referred to as $CURDIR in the Makefile:

# Makefile
project-env-generate:
	docker run --rm -e RUNNER_PLAYBOOK=ansible/development.yml \
		-v $(CURDIR)/ansible/development:/runner/inventory \ # $(CURDIR) is the same as $PWD in the terminal
		-v $(CURDIR):/runner/project \
		ansible/ansible-runner

Conclusion

In this guide, we covered the main features of Makefile and the make utility. A deeper understanding of this tool will reveal many of its other useful features, such as conditions, cycles, and importing files. Makefile will be a great help in standardizing generic instructions in companies where multiple projects are written by different teams at different times: setup start test deploy ....

Because the Makefile can describe multi-line commands consecutively, it may be used as a “universal glue” between language managers and other utilities. The widespread use of this tool and its overall simplicity allows you to quickly implement it into your project without making any changes. However, Makefile can be extremely large and complicated, as shown by the following real-world applications:

Additional materials

Makefile examples from this guide were taken from: