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

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

```shell
atmos ai skill install atmos-migration
```

See [AI Agent Skills](/ai/skills) for details.
:::

## Key Concepts at a Glance

| Makefile concept                                | Atmos equivalent                                         |
|--------------------------------------------------|------------------------------------------------------------|
| `.PHONY` target, no dependencies                  | [Custom command](/cli/configuration/commands)               |
| `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`](/cli/configuration/commands/dependencies#named-command-and-workflow-dependencies) 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)

```makefile
.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
```

### After (atmos.yaml)

```yaml
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:

**File:** `stacks/dev.yaml (moved to components/terraform/infra/)`

```
components:
  terraform:
    infra:
      vars: !include ../components/terraform/infra/envs/dev.tfvars
```

**File:** `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](/migration/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](/cli/configuration/commands). 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:`.

**File:** `atmos.yaml`

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

```shell
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`](/cli/configuration/commands/dependencies#named-command-and-workflow-dependencies)
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:

```yaml
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](/migration/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`](/workflows/steps/type/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:

**File:** `atmos.yaml`

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

```makefile
SUBDIRS := vpc eks rds

.PHONY: subdirs $(SUBDIRS)
subdirs: $(SUBDIRS)

$(SUBDIRS):
	$(MAKE) -C $@
```

That pattern maps to a [`matrix`](/workflows/steps/type/matrix) step, with each `$(SUBDIRS)`
entry becoming a matrix value:

**File:** `atmos.yaml`

```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`](/workflows/steps/inputs) and
[`artifacts.paths`](/workflows/steps/artifacts) fields are the direct match:

```yaml
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](/workflows/steps/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](/workflows/steps/type) 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](/cli/configuration/toolchain). 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

### Do I have to delete my Makefile?

No, and not every target has to move into Atmos either. A custom command can call `make <target>`
as its one step (`command: make build`), which gives you `atmos build`'s consistent flags,
`atmos --help` discovery, and tool-dependency management as a facade over the Makefile you already
have — the point of migrating is a consistent interface over every tool, not necessarily rewriting
every tool. Move a target's logic into native `steps:` when you want something `make` doesn't
have: richer step types, freshness checks, or interactive prompts. Neither is required, and there
is no fixed deadline for either.

### What happens to targets that skip work based on file timestamps?

Turn them into step `inputs.sources`/`artifacts.paths` -- with no explicit `when:`, that
implicitly skips the step when nothing has changed since its last successful run. It does not
carry over automatically; you add it to the migrated step yourself. See
[File-Timestamp Targets Become inputs/artifacts](#file-timestamp-targets-become-inputsartifacts).

### Can one custom command call another?

Yes, through either step type: `type: atmos` with `command: <other-command-name>`, or
`type: shell` with `command: atmos <other-command-name>`. Prefer `type: atmos` — it auto-applies
`-s <stack>` and gets Atmos's structured step output, which `type: shell` does not. This preserves
`make`'s own sequential default. Reach for `dependencies.commands` instead only when the same
prerequisite is shared by more than one target — it dedups a shared dependency to a single run,
the way `make` already does — or when the source target actually used `-j`.

### What if I use Just or Task instead of Make?

See [Migrating from Justfiles](/migration/justfile) or
[Migrating from Taskfile.yml](/migration/taskfile).

### What if my Makefile also selects a Terraform environment?

See [Migrating from Native Terraform](/migration/native-terraform) for that part of the
migration. This guide covers only the `make` orchestration layer.
