# Migrating Tasks from Makefiles

Use Atmos as a task runner for the build, test, lint, release, and maintenance tasks your team
currently runs with Make. This guide maps Make targets, variables, and prerequisites to
Atmos custom commands and workflows, keeping the scripts and tools behind those tasks.

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

## Start with Your Existing Tasks

Task-runner adoption starts with an `atmos.yaml` file in your project. Stacks, components,
Terraform, and cloud credentials are optional; none are required for the tasks in this guide.
The examples use a Go application, but the same mapping works for other languages, documentation
builds, release scripts, and local development tools.

You can migrate one task at a time. To start, expose an existing task through Atmos:

**File:** `atmos.yaml`

```yaml
commands:
  - name: build
    description: Build the application
    steps:
      - type: shell
        command: make build
```

Run `atmos build` from the project directory. When you are ready, replace `make build` with
the task's own commands. The examples below show that next step. They assume your existing
application, scripts, and required tools are available.

## 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)    | Shell steps; `show.command: false` suppresses command echo                        |
| `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:
	go vet ./...

clean:
	@rm -rf bin/

deploy: build test
	./scripts/deploy.sh "$(ENV)"
```

### 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: go vet ./...

  - name: clean
    description: Remove build artifacts
    steps:
      - type: shell
        command: rm -rf bin/
        show:
          command: false

  - name: deploy
    description: Deploy the application to the selected environment (default dev)
    flags:
      - name: env
        shorthand: e
        default: "dev"
    dependencies:
      commands: [build, test]
    steps:
      - type: shell
        command: ./scripts/deploy.sh "{{ .Flags.env }}"
```

The deployment step calls your existing `scripts/deploy.sh` script and passes the selected
application environment as its first argument. `--env` is a custom command flag; it does not
select an Atmos stack. Replace the script with the command your project already uses.

## 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`,
`lint`, and `clean`; `test` also has a build prerequisite. 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 another custom command
such as `build` or `test`. 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: shell
        command: ./scripts/deploy.sh 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.

## Parallel and Recursive Make Become `parallel` and `matrix`

`$(MAKE) -j` runs independent targets at the same time. It maps to a
[`parallel`](/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-api
    description: Build the API service
    steps:
      - type: shell
        command: make build
        working_directory: services/api
```

A _recursive_ Makefile pattern that loops `$(MAKE) -C $@` over a list such as `$(SUBDIRS)` is the
case that actually enumerates multiple directories:

```makefile
SUBDIRS := api worker web

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

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

That pattern maps to a [`matrix`](/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: [api, worker, web]
        max_concurrency: 4
        steps:
          - type: shell
            command: make build
            working_directory: services/{{ .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`](/steps/inputs) and
[`artifacts.paths`](/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](/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 application 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 to use Atmos step types, tool dependencies, or interactive prompts.

## What You Gain

- **Discoverable commands.** Add descriptions, typed flags, defaults, and per-command help for
  your team's tasks with `atmos --help` and `atmos <command> --help`.
- **Typed steps.** Combine shell commands with HTTP requests, containers, parallel execution,
  matrices, and other [step types](/steps/type).
- **Interactive workflows.** Add prompts, confirmations, and structured output where your
  tasks need them.
- **Tool dependencies.** Declare required CLI versions with `dependencies.tools` so Atmos
  installs them before running the command. See [Toolchain Configuration](/cli/configuration/toolchain).

## 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
- \[ ] Verify descriptions, flags, and arguments with `atmos --help` and command-level help
- \[ ] 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 these tasks require Terraform or Atmos stacks?

No. Custom commands can run application builds, tests, scripts, and other tools from
`atmos.yaml` alone. If you later choose to adopt Atmos's Terraform orchestration, follow
[Migrating from Native Terraform](/migration/native-terraform) for that separate change.

### 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 to use
Atmos step types, tool dependencies, 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.
