Skip to main content

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.

Using an AI Coding Assistant?

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 conceptAtmos equivalent
.PHONY target, no dependenciesCustom 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 (...) conditionalGo 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

.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

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:

stacks/dev.yaml (moved to components/terraform/infra/)
components:
terraform:
infra:
vars: !include ../components/terraform/infra/envs/dev.tfvars
stacks/dev.yaml (no-move, terraform/ stays put)
components:
terraform:
infra:
metadata:
component: terraform # points at the existing `terraform/` directory
vars: !include ../terraform/envs/dev.tfvars

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:.

atmos.yaml
commands:
- name: build
description: Compile the deployable artifact
steps:
- type: shell
command: go build -o bin/handler ./cmd/handler
atmos build

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:

atmos.yaml
commands:
- name: build-vpc
description: Build the vpc component
steps:
- type: shell
command: make build
working_directory: components/terraform/vpc

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:

atmos.yaml
commands:
- name: build-all
description: Build every service
steps:
- name: build-services
type: matrix
matrix:
service: [vpc, eks, rds]
max_concurrency: 4
steps:
- type: shell
command: make build
working_directory: components/terraform/{{ .matrix.service }}

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 native steps: only when you want something make doesn't give you: richer step types, freshness checks, or interactive prompts.

What You Gain

  • One command for discovery. Run atmos --help to list every command (recommended over the interactive atmos help, which pages the same listing). Run atmos <command> --help to see its flags and arguments. A Makefile has no built-in match for this. It needs a hand-written, awk-parsed help target 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 confirm or choose can 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 ?= default variables into command flags: with defaults
  • Turn dependency chains into steps in order (make's own default) -- reach for dependencies.commands only when a prerequisite is shared by more than one target, or the source used -j
  • Replace $(MAKE) -j with a parallel step; replace a single $(MAKE) -C dir with working_directory: on the step; replace a recursive $(SUBDIRS)-style loop with a matrix step
  • Delete any hand-written help target. atmos --help replaces it
  • Keep the Makefile for as long as you want -- call make <target> from a step, or migrate the logic into steps:, whichever fits
  • Turn file-timestamp targets into step inputs.sources/artifacts.paths

Common Questions