Migrating from Makefiles
A Makefile gives your team an ad-hoc way to run build, test, and deploy tasks. Atmos gives you
the same capability as a native, documented feature: custom commands and workflows. Your
Terraform code, scripts, and other tools do not need to change, and neither does your Makefile.
Atmos is the front door either way: if you want, a custom command can simply call make <target>
as its one step, making Atmos a thin wrapper around the Makefile you already have, not the other
way around. Moving a target's own recipe logic into native steps: is worthwhile, but optional.
Install the atmos-migration skill so Claude Code, Cursor, GitHub Copilot, and other AI coding
assistants can apply this guide directly to your repository:
atmos ai skill install atmos-migration
See AI Agent Skills for details.
Key Concepts at a Glance
| Makefile concept | Atmos equivalent |
|---|---|
.PHONY target, no dependencies | Custom command |
VAR ?= default, $(VAR) | Command flags: with a default: |
target: dep1 dep2 (dependency order) | Steps in order (make's own default is sequential); command dependencies.commands only when a prerequisite is shared by more than one target, or the source used -j |
Recipe shell lines (@-silenced, tab-indented) | Steps (type: shell, output: none) |
ifeq (...) conditional | Go template {{ if }} in a command, or when: in a workflow |
$(MAKE) -j (parallel targets) | parallel step |
$(MAKE) -C dir target (one invocation, one dir) | working_directory: on the step |
Recursive make looping over $(SUBDIRS) | matrix step |
Self-documenting help target (awk-parsed) | Free: atmos --help and each command's description: |
Before and After
- Before (Makefile)
- After (atmos.yaml)
.PHONY: build test lint clean deploy
ENV ?= dev
build:
go build -o bin/handler ./cmd/handler
test: build
go test ./...
lint:
golangci-lint run ./...
clean:
@rm -rf bin/
deploy: build test
cd terraform && terraform apply -var-file=envs/$(ENV).tfvars
commands:
- name: build
description: Compile the deployable artifact
steps:
- type: shell
command: go build -o bin/handler ./cmd/handler
- name: test
description: Run unit tests
dependencies:
commands: [build]
steps:
- type: shell
command: go test ./...
- name: lint
description: Run static analysis
steps:
- type: shell
command: golangci-lint run ./...
- name: clean
description: Remove build artifacts
steps:
- type: shell
command: rm -rf bin/
output: none
- name: deploy
description: Plan and apply the given environment (default dev)
flags:
- name: env
shorthand: e
default: "dev"
dependencies:
commands: [build, test]
steps:
- type: atmos
command: terraform apply infra -s {{ .Flags.env }}
infra is a placeholder Atmos component name, not the terraform verb repeated. Moving the old
terraform/ directory's .tf files to components/terraform/infra/ (the default
components.terraform.base_path is components/terraform) is one option -- swap infra for
whatever you actually name the component. Alternatively, keep the existing terraform/ directory
where it is: set components.terraform.base_path: "." and add metadata.component: terraform
on the infra stack component -- metadata.component points the stack component at the physical
directory, so no files need to move.
-s {{ .Flags.env }} only selects which stack runs; it does not, by itself, load that
environment's Terraform variables the way the source -var-file=envs/$(ENV).tfvars did. Bring
the per-environment .tfvars files in through each stack file instead, one per environment
(stacks/dev.yaml, stacks/staging.yaml, stacks/prod.yaml), each pointing at its own file. The
relative path depends on which of the two options above you picked:
See Migrating from Native Terraform for the full .tfvars/stack
mapping.
Independent Targets Become Custom Commands
Turn each .PHONY target that has no dependency into its own
custom command. In the example above, these are build, test,
lint, and clean. Give each command a description:. Then atmos --help shows the same
information as a hand-written, awk-parsed help target, so you can delete that target. Turn
VAR ?= default into a flags: entry with a matching default:.
Target Chains Become Ordered Custom-Command Steps
A type: atmos step runs the atmos binary itself, so it can call any atmos command, a native
verb such as terraform apply or another custom command such as build. To reuse a command
from another command or workflow step, prefer type: atmos with command: build over
type: shell with command: atmos build: type: atmos auto-applies -s <stack> from the
step's stack: field and renders through Atmos's structured step output.
For a chain such as deploy: build test, steps calling each prerequisite in order is the
default-preserving match — GNU Make's own default is to build a target's prerequisites one at a
time, in the order listed, not concurrently (-j is required for that). Do not reach for
dependencies.commands here just because it exists; it runs concurrently by default, which
changes the order unless you've confirmed the prerequisites are genuinely independent.
dependencies.commands earns its place for a different reason: make only builds a shared
prerequisite once per invocation, even when more than one target depends on it — plain steps
lose that property, since each caller's type: atmos/type: shell step runs it again.
dependencies.commands
dedups a dependency shared by more than one command to a single run, the same guarantee make
gives you for free, independent of whether you also want the concurrency. If order still matters
between the shared dependents (test must still finish before deploy's own steps run, and
build before test), declare that edge directly on the later command's own
dependencies.commands, rather than listing every prerequisite as a flat, sibling list on the
caller — the scheduler then waits for it, concurrency aside:
commands:
- name: build
steps:
- type: shell
command: go build ./...
- name: test
dependencies:
commands: [build]
steps:
- type: shell
command: go test ./...
- name: deploy
dependencies:
commands: [build, test]
steps:
- type: atmos
command: terraform apply infra -s dev
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.
If the Makefile's deploy target also selects a Terraform environment, for example through
-var-file=envs/$(ENV).tfvars, that part is a Terraform migration step, not a Make migration
step. See Migrating from Native Terraform for how the Terraform
side maps to stacks and .tfvars files.
Parallel and Recursive Make Become parallel and matrix
$(MAKE) -j runs independent targets at the same time. It maps to a
parallel step.
$(MAKE) -C dir target (or --directory=dir) changes the working directory for one
make invocation. It does not iterate over multiple directories, so it does not map to
matrix. Use working_directory: on the step instead:
A recursive Makefile pattern that loops $(MAKE) -C $@ over a list such as $(SUBDIRS) is the
case that actually enumerates multiple directories:
SUBDIRS := vpc eks rds
.PHONY: subdirs $(SUBDIRS)
subdirs: $(SUBDIRS)
$(SUBDIRS):
$(MAKE) -C $@
That pattern maps to a matrix step, with each $(SUBDIRS)
entry becoming a matrix value:
File-Timestamp Targets Become inputs/artifacts
Make skips a target's recipe when every prerequisite (the target's own inputs) is older than
the target file it already produced (the target's own artifacts) — comparing mtimes. The
step-level inputs.sources and
artifacts.paths fields are the direct match:
commands:
- name: build
description: Compile the deployable artifact
steps:
- type: shell
command: go build -o bin/handler ./cmd/handler
inputs:
sources: ["cmd/**/*.go"]
artifacts:
paths: ["bin/handler"]
With no explicit when:, declaring inputs/artifacts on a step is enough — it implicitly means
when: checksum.changed, and the step is skipped when the hash of the matched source files
matches the hash recorded after the last successful run. That 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 (and the mtime-based timestamp.changed fact) think everything changed even
when it didn't. Use when: timestamp.changed instead if you want Make's exact mtime semantics.
See inputs for the full field reference. It does not carry over on its
own — add inputs/artifacts to the migrated step yourself.
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.
What Stays the Same
- Your Terraform code, scripts, and CLIs. Atmos wraps them. It does not replace them.
- Environment variables you already export. Move them into a command or step
env:map. - The Makefile itself, for as long as you want. A custom command can call
make <target>directly as its one step — Atmos becomes a consistent facade over the tools you already have, not a mandatory rewrite of them. Move a target's own recipe logic into nativesteps:only when you want somethingmakedoesn't give you: richer step types, freshness checks, or interactive prompts.
What You Gain
- One command for discovery. Run
atmos --helpto list every command (recommended over the interactiveatmos help, which pages the same listing). Runatmos <command> --helpto see its flags and arguments. A Makefile has no built-in match for this. It needs a hand-written,awk-parsedhelptarget instead. - One consistent interface. Every custom command uses the same flag and argument syntax. A Makefile has no fixed convention. Each target can parse its own variables in its own way.
- More than 30 built-in step types. Atmos ships step types for shell commands, orchestration
(
parallel,matrix,wait), user prompts (confirm,choose,input), and output (table,markdown,toast). See Step Types for the full list. A plain Makefile recipe has none of this. You would write it yourself, in shell. - Interactive commands. A step such as
confirmorchoosecan pause a command and ask the user a question. A Makefile recipe cannot do this without a custom shell script. - Automatic tool installation. A command can list the tools it needs under
dependencies.tools. Atmos installs the correct version before the command runs. See Toolchain Configuration. A Makefile has no built-in match for this. The user must install each tool by hand, or write a separate setup script.
Migration Checklist
- List every Makefile target. Mark each one as independent, or part of a chain
- Turn independent targets into custom commands in
atmos.yaml - Turn
VAR ?= defaultvariables into commandflags:with defaults - Turn dependency chains into steps in order (
make's own default) -- reach fordependencies.commandsonly when a prerequisite is shared by more than one target, or the source used-j - Replace
$(MAKE) -jwith aparallelstep; replace a single$(MAKE) -C dirwithworking_directory:on the step; replace a recursive$(SUBDIRS)-style loop with amatrixstep - Delete any hand-written
helptarget.atmos --helpreplaces it - Keep the Makefile for as long as you want -- call
make <target>from a step, or migrate the logic intosteps:, whichever fits - Turn file-timestamp targets into step
inputs.sources/artifacts.paths