---
title: "How Makefiles work"
description: "The few ideas Make is built on, the Makefile contract every Alis Build service follows and why each convention exists, four patterns described in words, and the traps by symptom."
url: "https://docs.alisx.com/explanation/makefiles/"
---
# How Makefiles work

Every Alis Build service carries a Makefile, and its `run` target is the one command that starts the service locally. The TUI's ++m++ action and `alis run` both run exactly that target, so what you start from your terminal and what a colleague starts from the TUI are the same thing. This page explains the ideas Make is built on, the conventions an Alis Makefile follows and why each one exists, a few patterns worth copying, and the traps that catch people. The conventions as a checklist, and the steps to run a service, are in [Run a service locally](https://docs.alisx.com/how-to/alis-build/run-a-service-locally.md).

## Make in seven ideas

None of this is specific to AlisX; it holds for any Makefile.

**Target.** The name you ask Make for: `make run` asks for the target called `run`. A target is followed by a colon, then the commands that produce it.

```make
run:
	go run .
```

**Prerequisite.** Whatever must be done first, listed after the colon. Make finishes every prerequisite before it runs the target's own commands, which is how you order work without writing an `if`.

**Recipe.** The indented commands under a target. Make hands each line to a fresh shell, so a variable set or a `cd` on one line is gone by the next unless the lines are joined with a trailing backslash.

**Variable.** `:=` is evaluated once, when Make reads the file; `=` is evaluated again on every use; `?=` sets a value only if one is not already set. Prefer `:=` for anything that runs a command, so the command runs once rather than on every reference.

**Phony target.** Make assumes a target is a file to create. `run` and `help` never produce a file of that name, so declaring them `.PHONY` makes Make run them every time, and stops a stray file called `run` from quietly disabling the target.

**Default goal.** What a bare `make` does. Without `.DEFAULT_GOAL`, Make runs the first target in the file, which changes the moment someone adds a target above it.

**Parallel jobs.** `-j` runs independent targets at the same time. That is what lets one Makefile start a backend, a frontend and a browser together: servers never exit, so running them one after another would hang on the first.

## The Alis Makefile contract

Pressing ++m++ in the TUI on a service with no Makefile writes a starter one with all of this in place. Each convention exists because something on the platform depends on it.

**`run` is the one command.** The TUI's ++m++ action runs `make run` in the service folder, in a real terminal you can watch, stop with ++ctrl+c++ and run again. Nothing is hidden, and "how do I start this?" has one answer that works the same from any terminal.

**A bare `make` runs it.** `.DEFAULT_GOAL := run` sits above the targets, so typing `make` does the useful thing, and keeps doing it after someone adds a target near the top.

**`## ` is what makes a target visible.** A trailing `## comment` on a target line documents it. The `help` target lists exactly the lines with that marker, and the TUI's ++shift+m++ action runs `make help` rather than reading the file itself, so it shows precisely what a terminal shows. A target without a `## ` comment still works; nothing lists it.

**The file documents itself.** The `help` target reads `$(MAKEFILE_LIST)` rather than a fixed file name, so it keeps working when the file is renamed or included elsewhere.

**`$(LOAD_ENV)` brings in the environment.** Recipes that need configuration start with `$(LOAD_ENV)`, which loads the product's `.alis/.env`, the file the TUI's **Set active deployment environment** writes:

```make
ENV_FILE := $(shell git rev-parse --show-toplevel)/.alis/.env
LOAD_ENV := set -a; [ -f "$(ENV_FILE)" ] && . "$(ENV_FILE)"; set +a;
```

The file is read by the shell with `set -a`, not by Make's own `include`, because the shell keeps quoted and multi-word values intact where Make would mangle them. And it is written in the file for anyone to read: there is no hidden step between your terminal and the service.

**Dependencies start directly, never recursively.** A service that needs another one gets a `dep-<service>` target that starts it directly, on its own port. Calling the other service's `make run` instead would inherit its ports and its assumptions, and break the day that service changes. Starting it directly keeps the dependency explicit and its port yours to set.

**A target that needs a value asks for it.** A target that takes values declares each with `NAME ?=`, names them in its `## ` comment (for example `(PROJECT HOST ACCOUNT)`, so `make help` shows them) and asks for any the command line left out. The generated Makefile carries an `ASK` macro, and a commented example target, for this. The reason is who is on the other end. The TUI and `alis run` start a target in a real terminal, so a person answers the prompt. A script or a coding agent has no terminal, so the same macro fails at once, naming the missing variable, instead of waiting on a prompt nobody sees. Copy each value into a shell variable first, so one given on the command line is never asked for; check values before the first thing that changes anything; and keep prompts free of commas, because `$(call)` splits on them.

## Four patterns

These come from Makefiles the platform's own services use.

**A full-stack service in one command.** A service with a Go backend and a web frontend needs both servers, the environment and a browser tab. Its `run` target frees the backend's port as a prerequisite, then starts the backend, the frontend and an `open-app` target as parallel jobs. `open-app` waits until both servers actually answer before it opens the browser, because the backend proxies the frontend locally: opening any earlier greets you with a proxy error. Press ++m++, or type `make`, and the whole stack comes up with a browser pointed at it.

**Start a server, wait for it, then start its client.** A command-line tool developed against a local backend picks a free port, starts the backend in the background, polls until the port accepts connections, and only then starts the client against it. A `trap` stops the background job on exit, so ++ctrl+c++ leaves nothing running. Because each recipe line is its own shell, the whole sequence is one line joined with backslashes. One command replaces a two-terminal ritual, and the ordering is solved once, in the file.

**Operational targets that are safe to rerun.** Not every target starts a server. A service can keep its one-off provisioning and data migrations next to `run`, each documented so ++shift+m++ lists it, each safe to run twice, and each a dry run until `APPLY=1` is passed. Such a target checks its required values before doing any work. The `$(if ...)` idiom passes a variable on only when it is set, so the recipe never overwrites a value already coming from `.alis/.env`. The knowledge of how to operate the service lives with its code rather than in someone's shell history.

**Ask, then hand over.** One service can offer a single entry point for work another service owns. Its target asks for whatever was left off the command line (which service, which project, which hostname), checks the answer with `case`, and only then calls the owning service's own Makefile with `$(MAKE) -C` and every value spelled out. In a terminal, the operator answers a few questions; without one, the same command fails on the first missing value. Calling another Makefile this way is fine for a task like this: only `run` must never recurse.

## When it surprises you

**`missing separator. Stop.`** A recipe line must be indented with a real tab, not spaces. An editor set to expand tabs is the usual cause.

**A variable or a `cd` from one line has no effect on the next.** Each recipe line runs in its own shell. Join the lines with a trailing backslash, or chain them with `&&`.

**A shell variable comes out empty, or Make complains about an undefined variable.** A `$` belongs to Make first. Write `$$VAR` for the shell's variable and `$(VAR)` for Make's.

**`make run` says `'run' is up to date` and does nothing.** A file or folder called `run` now exists, so Make thinks the target is already built. Declare it in `.PHONY`.

**A target hangs with no output under a script or an agent, or fails after doing half its work.** It is waiting on a prompt nobody can see, or it checked its inputs too late. Ask only when there is a terminal (`[ -t 0 ]`) and check every value before the first change. The `ASK` macro does both.

**A prompt or message is cut off at its first comma.** Make splits `$(call)` arguments on commas before the shell sees them. Use dashes or parentheses instead.

**The output is buried in echoed commands.** Make prints each command before running it. Prefix a line with `@` to hide it, and keep the echo on the lines you want to see: seeing the command is usually the point.

## Related

- [Run a service locally](https://docs.alisx.com/how-to/alis-build/run-a-service-locally.md)
- [alis run](https://docs.alisx.com/reference/cli/run.md)
- [Manage environment variables](https://docs.alisx.com/how-to/alis-build/manage-environment-variables.md)
- [Agents, skills and the CLI](https://docs.alisx.com/explanation/agents-skills-cli.md)
