Skip to main content
  1. Articles/

Taskfile: the modern replacement for Makefile

For decades, Make has been the reference tool for automating build and development tasks. But it has to be said that its cryptic syntax and its limitations make it a sometimes painful tool day to day. Taskfile (also called Task) offers a modern, readable and cross-platform alternative. Here’s why it deserves your attention.

A bit of history #

Make, the timeless classic #

Make was born in 1977 at Bell Labs, created by Stuart Feldman to replace the shell scripts used to compile Unix. Its fundamental principle: a Makefile defines rules based on targets, prerequisites, and commands to run. Make compares file modification dates to only rebuild what has changed.

.PHONY: build
build:
    go build -o bin/myapp ./cmd/myapp

For nearly 50 years, Make has been the ubiquitous tool for C/C++ projects, then for any project requiring automation. It’s installed by default on almost all Unix/Linux/macOS distributions.

Make’s limits #

Make was designed for a world different from ours:

  • Opaque syntax: tabs vs spaces, variables with $() or ${}, % pattern rules — everything is a source of confusion.
  • No native JSON/YAML support: Makefiles are raw text with no data structure.
  • Limited ecosystem: no task registry, no automatically resolved dependencies between tasks.
  • Shell dependency: commands run in a subshell, which can vary by OS.
  • Generated Makefiles: many projects end up generating their Makefile via tools (cmake, automake, etc.), adding a layer of indirection.

The emergence of alternatives #

The community gradually developed alternatives:

  • Ant (2000) — XML, verbose, popularized by Java
  • Rake (2004) — Ruby DSL, elegant but Ruby-required
  • Gradle (2008) — Groovy/Kotlin, the Java/Kotlin standard
  • Jake (2010) — JavaScript, gone
  • just (2016) — Simplicity, recipes inspired by Make but modernized
  • Task (2017) — YAML, cross-platform, inspired by Go

What is Taskfile? #

Task (github.com/go-task/task) is a task runner written in Go, available under the MIT license. It uses Taskfile.yml files in YAML to define tasks, which makes it accessible to anyone who already knows Kubernetes, GitHub Actions, or Ansible.

Installation #

# macOS
brew install go-task/tap/go-task

# Linux (official script)
sh -c "$(curl -sL https://taskfile.dev/install.sh)"

# Windows (Scoop, Chocolatey, or via GitHub releases)
scoop install task

# Via Go
go install github.com/go-task/task/v3/cmd/task@latest

A first example #

version: '3'

tasks:
  default:
    deps: [build]
    cmds:
      - ./bin/myapp --help

  build:
    desc: Build the application
    dir: ./cmd/myapp
    cmds:
      - go build -o ../../bin/myapp .

  test:
    desc: Run tests
    cmds:
      - go test -v ./...

  lint:
    desc: Lint code
    cmds:
      - golangci-lint run

  clean:
    desc: Clean build artifacts
    cmds:
      - rm -rf bin/
# List available tasks
task --list

# Run the default task
task

# Run a specific task
task build

# Run with a variable
task deploy ENV=production

The features that make the difference #

Dependencies between tasks #

tasks:
  build:
    deps: [deps, lint, test]
    cmds:
      - go build ./...

  deps:
    cmds:
      - go mod download

  lint:
    cmds:
      - golangci-lint run

  test:
    cmds:
      - go test ./...

Dependencies run in parallel by default. For sequential execution, use depends:

tasks:
  setup:
    depends: [create-db, migrate-db]
    cmds:
      - echo "Setup complete"

Environment variables and interpolation #

version: '3'

env:
  APP_NAME: myapp
  VERSION: '1.0.0'

vars:
  BINARY_NAME: "{{.APP_NAME}}-{{.OS}}-{{.ARCH}}"

tasks:
  build:
    vars:
      OUTPUT: "./dist/{{.BINARY_NAME}}"
    cmds:
      - go build -ldflags="-X main.version={{.VERSION}}" -o {{.OUTPUT}} ./cmd/myapp

Task exposes variables:

  • .OS — operating system (linux, darwin, windows)
  • .ARCH — architecture (amd64, arm64, etc.)
  • .TASK — name of the current task
  • .CLI_ARGS — arguments passed on the command line

Watching and builds #

version: '3'

tasks:
  dev:
    desc: Run development server with hot reload
    cmds:
      - task: watch-src
        watch: true
      - go run ./cmd/server

  watch-src:
    desc: Watch source files and rebuild
    cmds:
      - while true; do
          inotifywait -q -e modify src/*.go;
          go build ./cmd/server;
        done
    status:
      - test -f ./server

Or using the native watch directive (v3.26+):

tasks:
  build:
    cmds:
      - go build ./...
    watch: true
    sources:
      - src/**/*.go
    generate:
      task: build

Templates and includes #

# Main Taskfile.yml
version: '3'

includes:
  docker:
    taskfile: ./Taskfile.docker.yml
    vars:
      TAG: latest
  .shared: ./Taskfile.shared.yml
# Taskfile.shared.yml
version: '3'

tasks:
  log:
    cmds:
      - echo "Version: {{.VERSION}}"

Interactive prompts #

tasks:
  deploy:
    prompt: "Are you sure you want to deploy to {{.ENV}}? [y/N]"
    confirm: true
    cmd: echo "Deploying..."

Makefile vs Taskfile: the comparison #

MakefileTaskfile
SyntaxMake DSLYAML
Readable⭐⭐⭐⭐⭐⭐⭐
Cross-platform⚠️ (msys, gmake)✅ Native
Variables${VAR} or $(shell ...){{.VAR}}
DependenciesManualAutomatic with deps
Parallel executionGlobal -j flagBy default in deps
Config filesNoYes (JSON/YAML includes)
Interactive promptsNoYes
Watch modeVia external toolsNative
Task registryNoNo (DIY)
InstallabilityMake installedbinary or brew

Make vs Task example #

Makefile:

.PHONY: build test lint clean

APP_NAME := myapp
VERSION := $(shell git describe --tags --always)
BIN := bin/$(APP_NAME)
SRC := $(wildcard cmd/**/*.go)

build: $(BIN)

$(BIN): $(SRC)
	go build -ldflags="-X main.version=$(VERSION)" -o $(BIN) ./cmd/myapp

test:
	go test -v ./...

lint:
	golangci-lint run

clean:
	rm -rf bin/

install: build
	install -Dm755 $(BIN) /usr/local/bin/$(APP_NAME)

Taskfile.yml:

version: '3'

vars:
  APP_NAME: myapp
  VERSION:
    sh: git describe --tags --always
  BIN: bin/{{.APP_NAME}}

tasks:
  default:
    deps: [lint, test, build]

  build:
    desc: Build the application
    cmds:
      - go build -ldflags="-X main.version={{.VERSION}}" -o {{.BIN}} ./cmd/myapp

  test:
    desc: Run tests
    cmds:
      - go test -v ./...

  lint:
    desc: Lint code
    cmds:
      - golangci-lint run

  clean:
    desc: Clean artifacts
    cmds:
      - rm -rf bin/

  install:
    desc: Install binary
    deps: [build]
    cmds:
      - install -Dm755 {{.BIN}} /usr/local/bin/{{.APP_NAME}}

The Taskfile is more explicit (desc, vars, structure), the Makefile more concise but cryptic.

Just vs Taskfile #

Just (github.com/casey/just) is another serious competitor, written in Rust. Its recipe syntax looks like Make but better:

# .justfile
APP_NAME := "myapp"
VERSION := `git describe --tags --always`

build:
    go build -ldflags="-X main.version={{VERSION}}" -o bin/{{APP_NAME}} ./cmd/myapp

test:
    go test -v ./...

default: build test
JustTask
SyntaxCustom DSL (recipes)YAML
Files.justfileTaskfile.yml
Readable⭐⭐⭐⭐⭐⭐⭐⭐
Learning curveLow (Make-like syntax)Low (familiar YAML)
VariablesMake-likeTemplate {{.VAR}}
IncludesYesYes
Watch modePluginNative
Parallel executionVia recipe dependenciesAutomatic in deps
PromptsNoYes

Just is excellent if you prefer a recipe-style syntax. Task excels in projects where YAML is already the standard (Kubernetes, CI/CD).

Use cases #

Go project #

version: '3'

tasks:
  default:
    deps: [test, build]

  dev:
    desc: Run with hot reload
    cmds:
      - air

  test:
    cmds:
      - go test -race -v ./...

  lint:
    cmds:
      - golangci-lint run

  build:
    cmds:
      - go build -ldflags="-s -w" -o bin/myapp ./cmd/myapp

  container:
    deps: [build]
    cmds:
      - docker build -t myapp:{{.VERSION}} .

  release:
    deps: [test, lint]
    cmds:
      - goreleaser release --clean

Node.js project #

version: '3'

tasks:
  default:
    deps: [lint, test]

  install:
    cmds:
      - npm install

  test:
    deps: [install]
    cmds:
      - npm run test

  lint:
    deps: [install]
    cmds:
      - npm run lint

  build:
    deps: [install]
    cmds:
      - npm run build

  docker:build:
    cmds:
      - docker build -t myapp:{{.TAG}} .

  docker:push:
    deps: [docker:build]
    cmds:
      - docker push myapp:{{.TAG}}

Multi-service Docker Compose #

version: '3'

tasks:
  up:
    cmds:
      - docker compose up -d

  down:
    cmds:
      - docker compose down

  logs:
    cmds:
      - docker compose logs -f {{.SERVICE}}

  restart:
    deps: [down, up]

  db:migrate:
    cmds:
      - docker compose exec api npm run db:migrate

  db:seed:
    cmds:
      - docker compose exec api npm run db:seed

The state of the ecosystem in 2026 #

Task is a mature project:

  • 15,000+ GitHub stars
  • 1 million+ downloads per month
  • Native support in several IDEs (VS Code extension)
  • Integration in CI/CD tools
  • An active team with regular releases (v3.49.x in March 2026)

The project remains community-maintained, without major corporate backing, which can be an advantage (independence) or a risk (dependence on contributors).

Conclusion #

Taskfile fills Make’s gaps while keeping its philosophy: a simple tool to run common tasks. YAML makes it immediately accessible, the automatic deps simplify the dependency graph, and the cross-platform support eliminates surprises.

Personally, I mainly use it to bootstrap the base components in a Kubernetes cluster, defining tasks to apply the essential manifests (Namespace, ServiceAccount, RBAC, CNI, etc.) and orchestrate the deployment of Operators.

One last piece of advice: whatever tool you choose, document your tasks with desc or comments. A project without documentation of its tasks is a project where every developer reinvents the wheel at deployment time.