Migrating from Makefiles
This guide shows how to move Make targets to Atmos. Find the correct shape for the Makefile below. Then follow the matching steps. For the full tutorial, see atmos.tools/migration/makefile.
This guide covers the make orchestration layer only: targets, target dependencies, variables,
and conditionals. If the Makefile also selects a Terraform environment through -var-file or
per-environment directories, also use
from-native-terraform.md. That guide covers the Terraform-specific
steps: backend generation, .tfvars files, and workspace mapping.
Find the Shape of the Makefile
| Shape | Steps |
|---|---|
Independent leaf targets (build, test, lint, clean) | Shape A |
Target chains with dependencies (deploy: build test) | Shape B |
Recursive or parallel Make ($(MAKE) -C dir, make -j) | Shape C |
Most Makefiles mix all three shapes. Treat each target on its own. Then combine the results.
Shape A: Independent Leaf Targets
Before:
<!-- editorconfig-checker-disable --><!-- editorconfig-checker-enable -->.PHONY: build test lint clean helphelp: ## Show available targets@awk 'BEGIN {FS=":.*##"} /^[a-zA-Z_-]+:.*##/ {printf " %-10s %s\n", $$1, $$2}' $(MAKEFILE_LIST)build: ## Compile the deployable artifactgo build -o bin/handler ./cmd/handlertest: build ## Run unit testsgo test ./...lint: ## Run static analysisgolangci-lint run ./...clean: ## Remove build artifacts@rm -rf bin/
Steps:
- Turn each leaf target into a custom command in
atmos.yaml. If the Makefile is large, put the commands in a separate file. See Split commands across files below. - Turn the silent-recipe
@prefix intoshow: { command: false }. Make's@only suppresses the echoed command line -- the recipe's own stdout/stderr still prints.output: noneis not the same thing: it sends the step's stdout and stderr to the void, which would hide real command output (for example,go testdiagnostics). Reserveoutput: nonefor a step whose output genuinely needs to be discarded entirely. - Delete the
helptarget. Atmos generates the same information from each command'sdescription:field. Runatmos --helporatmos <command> --helpto see it. - When one target depends on another leaf target, such as
test: build, add a step that runs the dependency's command. Use atype: atmosstep withcommand: buildto call the other custom command --type: atmospreserves step-level stack context and gives you structured output handling (captured stdout/stderr, exit code) that atype: shellstep invokingatmos builddoes not.
commands:- name: builddescription: Compile the deployable artifactsteps:- type: shellcommand: go build -o bin/handler ./cmd/handler- name: testdescription: Run unit testssteps:- type: atmoscommand: build- type: shellcommand: go test ./...- name: lintdescription: Run static analysissteps:- type: shellcommand: golangci-lint run ./...- name: cleandescription: Remove build artifactssteps:- type: shellcommand: rm -rf bin/show:command: false
Shape B: Target Chains with Dependencies
Before:
<!-- editorconfig-checker-disable --><!-- editorconfig-checker-enable -->ENV ?= devdeploy: build test ## Plan and apply the given ENV (default: dev)cd terraform && terraform apply -var-file=envs/$(ENV).tfvars
Steps:
- Turn
ENV ?= devinto a commandflags:entry withdefault: "dev". - GNU Make's own default is to build a target's prerequisites one at a time, in the order
listed, not concurrently (
-jis required for that) -- so ordered steps, notdependencies.commands, are the default-preserving match for a plain target list. Reach for command-leveldependencies.commands: [build, test]instead only when: a prerequisite is shared by more than one target (it dedups a shared dependency to a single run, the same guaranteemakealready gives for free, independent of concurrency), the prerequisites are genuinely independent, or the source target actually used-j. Here,buildis shared -- Shape A'stestalready depends on it via its ownatmos buildstep -- sodependencies.commandsis the right call fordeploy. Convert Shape A'stestfrom itsatmos buildstep todependencies.commands: [build]at the same time, so the scheduler still ordersbuildbeforetesteven though both now run through the same concurrent-by-default mechanism, instead of listingbuildandtestas a flat, unordered sibling list ondeploy. - Move the Terraform-specific line,
terraform apply -var-file=envs/$(ENV).tfvars, to from-native-terraform.md Shape B. That guide shows how the Terraform side maps to stacks. Here, the line becomes a singletype: atmosstep, becauseterraform applyis a native Atmos verb. - Turn
ifeq ($(ENV),prod)conditionals into a Go template conditional inside a custom command:{{ if eq .Flags.env "prod" }}...{{ end }}. This is the same pattern used for--verboseand other boolean flags. Inside a workflow, usewhen: !cel 'stack == "prod"'on the step instead.
commands:- name: testdescription: Run unit testsdependencies:commands: [build]steps:- type: shellcommand: go test ./...- name: deploydescription: Plan and apply the given environment (default dev)flags:- name: envshorthand: edefault: "dev"dependencies:commands: [build, test]steps:- type: atmoscommand: terraform apply infra -s {{ .Flags.env }}
build still runs exactly once for the whole atmos deploy invocation -- test's own edge on
build orders it correctly ahead of test, and deploy's own steps wait for both to finish.
infra is a placeholder Atmos component name, not the terraform verb repeated. Move the
target's Terraform code to components/terraform/infra/ (the default
components.terraform.base_path is components/terraform), then swap infra for whatever the
user actually names the component.
Shape C: Recursive or Parallel Make
Before:
<!-- editorconfig-checker-disable --><!-- editorconfig-checker-enable -->SERVICES := vpc eks rdsbuild-all:for dir in $(SERVICES); do $(MAKE) -C services/$$dir build; donebuild-parallel:$(MAKE) -j4 build-all
Steps:
build-all's recipe is a shellforloop, not multiple Make targets --make -jonly parallelizes independent targets within a singlemakeinvocation, it does not parallelize commands inside one recipe's shell script. So$(MAKE) -j4 build-allstill runsvpc,eks, andrdsone at a time, in order, exactly like plainbuild-allwould.-j4here does nothing.- Turn
$(MAKE) -C dir targetrecursion over a fixed set of directories into amatrixstep. Define aserviceaxis, and call the per-service command once for each value. Usingmax_concurrencyon the matrix step is a deliberate upgrade over the sequential source behavior, not a literal translation of-j4-- call it out to whoever is reviewing the migration, since it changes execution behavior (all services build concurrently instead of one at a time). - The per-directory
buildtarget here is a different recipe than Shape A's top-levelbuildcommand -- it lives in eachservices/<dir>/directory, not at the repo root. Give it its own custom command,build-service, with aserviceflag/argument that maps into theservices/<name>path. Do not reuse Shape A'sbuildcommand as-is; it has no way to receive a per-service value.
commands:- name: build-servicedescription: Build a single serviceflags:- name: servicerequired: truesteps:- type: shellcommand: go build -o bin/{{ .Flags.service }} ./services/{{ .Flags.service }}- name: build-alldescription: Build every servicesteps:- name: build-servicestype: matrixmatrix:service: [vpc, eks, rds]max_concurrency: 4steps:- type: atmoscommand: build-service --service {{ .matrix.service }}
Common Problems
Tabs, .PHONY, and file-timestamp caching
Atmos steps have no tab requirement. Do not confuse .PHONY with a caching feature. If a
Makefile target is not .PHONY and uses file timestamps to skip work when inputs have not
changed, turn it into step inputs.sources/artifacts.paths -- with no explicit when:, that
implicitly means when: checksum.changed, and the step is skipped when nothing has changed since
its last successful run. It does not carry over automatically; tell the user to add
inputs/artifacts to the migrated step themselves. Content hashing (the default) is a
deliberate upgrade over Make's own mtime comparison -- a fresh git clone/CI checkout resets
every file's mtime, which makes Make think everything changed even when it didn't; use
when: timestamp.changed instead for Make's exact mtime semantics. Task's sources:/generates:
feature maps to the same fields. See
from-taskfile.md for more detail.
The scope is different, too: Make's freshness check gates the target's entire recipe, but
inputs/artifacts are declared per step -- skipping one step does not stop later steps in the
same command from running. If a target's recipe has more than one command line and the freshness
decision must gate all of them together, combine them into a single shell/script step rather
than spreading inputs/artifacts across several steps.
Silent recipes and command echo
@command suppresses the echo of one command line -- it does not touch the recipe's own output.
It maps to show: { command: false } on that one step, not output: none. output: none
discards the step's stdout and stderr entirely, which is a much bigger change in behavior than
Make's @ prefix ever was; reserve it for steps whose output genuinely needs to be thrown away.
Split commands across files
When a Makefile uses include foo.mk to split its content across files, split the Atmos config
the same way. Put the extra commands in a file such as atmos.d/commands.yaml or
.atmos.d/commands.yaml. Atmos auto-discovers atmos.d//.atmos.d/ in the config directory
(and, as a lower-priority fallback, at the git/worktree root) -- no import: entry is needed for
this specific location. Use import: only when splitting across a directory Atmos does not
auto-discover. See Imports.
Complex $(eval) and $(call) macros
Do not try to convert deeply macro-driven Makefiles into flags and arguments one by one. Put
genuinely dynamic logic in a shell or script step, or in a script that the step calls. Atmos
custom commands replace task orchestration. They do not replace a general-purpose macro
language.
What Not To Do
- Do not confuse
.PHONYwith a caching feature -- it is not one. Do not drop file-timestamp caching without adding the matchinginputs/artifactsfields to the migrated step; it is a direct match, not a gap, but it does not carry over on its own. Remember the scope difference too: it gates one step, not the whole recipe -- combine multiple command lines into a single step if the freshness decision must cover all of them. - Do not describe
dependencies.commandsas matching Make's default prerequisite order --makebuilds prerequisites one at a time, in the order listed, unless-jis given.dependencies.commandsruns concurrently by default, which changes that order. Ordered steps are the default-preserving match for a plaintarget: dep1 dep2; reach fordependencies.commandsonly when a prerequisite is shared by more than one target (it dedups a shared dependency to a single run regardless of concurrency, the waymakealready does), the prerequisites are genuinely independent, or the source used-j. - Do not turn every private or helper target into its own discoverable command by default. If the
helper is called from only one recipe, put its logic in a step inside the command or workflow
that needs it. If it needs to be called from more than one recipe, or invoked directly for
debugging, make it a custom command with
internal: trueinstead -- it stays runnable but is excluded fromatmos --helplistings and completion suggestions. - Do not treat "wrap
atmoscommands in the Makefile" as the final state. It is a valid bridge during early migration. Leaf targets should become custom commands. A target chain usually stays a custom command too, usingdependencies.commandsfor its prerequisites -- do not prescribe a workflow for every dependency chain. Reserve workflows for fixed, multi-step orchestration across more than one component. - Do not invent
when:conditions that check flag values on workflow steps. Thewhen:field checks CEL context values, such asstack,ci, andlocal. Flag-based conditionals belong in the custom command's own Go templates.