LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Taskfile: A Better Makefile for Modern Development

devopsdeveloper-experiencetoolingautomationci-cd

Every project needs a task runner. Build the binary, run tests, lint code, spin up containers, deploy to staging — these operations need names, documentation, and repeatability. For decades, make dominated this space. But Makefiles were designed for C compilation in 1976, and the workarounds for using them as a general task runner have accumulated into a fragile mess of .PHONY declarations, tab indentation traps, and portability nightmares.

Taskfile (the task command) is a modern task runner built in Go that solves all of this elegantly. It’s declarative, readable, cross-platform, and designed from day one for developer experience rather than build artifacts.

Why Makefiles Fall Short as Task Runners

Before diving into Taskfile, it’s worth understanding what makes Makefiles awkward:

Tab sensitivity: Makefiles require hard tabs, not spaces. Mix them up and you get Makefile:12: *** missing separator. Stop. — a cryptic error that has wasted countless developer hours.

Everything is a build rule: Make checks if a file target exists and compares timestamps. To run a task unconditionally, you need .PHONY. Forget it once and make test silently does nothing when a file named test exists.

1
2
3
4
5
6
7
8
# The .PHONY ceremony you need for every non-file target
.PHONY: build test lint deploy clean

build:
	go build ./...

test:
	go test ./...

Shell portability: Make assumes bash on Linux/macOS. On Windows you need WSL or Git Bash. Cross-platform teams suffer.

No native variables from environment: Getting .env file values into Make requires hacky include directives or $(shell cat .env | grep VAR | cut -d= -f2).

No dependencies between tasks that share output: Make’s dependency model is file-centric. Expressing “run linting before tests” requires extra empty targets.

Poor error messages: When something goes wrong, Make’s output is often unhelpful.

Taskfile solves all of these.

Installing Task

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# macOS / Linux via Homebrew
brew install go-task

# macOS / Linux via the install script
sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin

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

# Windows via Scoop
scoop install task

# Windows via Chocolatey
choco install go-task

# Debian / Ubuntu via apt
sudo snap install task --classic
# or
sudo apt install task

Verify the installation:

1
2
task --version
# Task version: v3.35.0

Your First Taskfile

Taskfile is YAML. Create Taskfile.yml in your project root:

1
2
3
4
5
6
7
version: '3'

tasks:
  hello:
    desc: Print a greeting
    cmds:
      - echo "Hello from Taskfile!"

Run it:

1
2
3
task hello
# task: [hello] echo "Hello from Taskfile!"
# Hello from Taskfile!

List all available tasks:

1
2
3
task --list
# task: Available tasks for this project:
# * hello:   Print a greeting

The desc field is what shows up in task --list. Document your tasks — your teammates will thank you.

A Real Project Taskfile

Here’s a complete Taskfile.yml for a Go web service:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
version: '3'

vars:
  BINARY_NAME: myapp
  DOCKER_IMAGE: mycompany/myapp
  GO_MODULE: github.com/mycompany/myapp
  BUILD_DIR: ./build

env:
  CGO_ENABLED: "0"
  GOFLAGS: "-mod=readonly"

dotenv:
  - .env
  - .env.local

tasks:
  default:
    desc: Show available tasks
    cmds:
      - task --list

  # ── Build ──────────────────────────────────────────────────────────────────

  build:
    desc: Build the binary for the current platform
    cmds:
      - mkdir -p {{.BUILD_DIR}}
      - go build -ldflags="-s -w -X main.version={{.GIT_TAG}}" -o {{.BUILD_DIR}}/{{.BINARY_NAME}} ./cmd/server
    vars:
      GIT_TAG:
        sh: git describe --tags --always --dirty 2>/dev/null || echo "dev"
    sources:
      - "**/*.go"
      - go.mod
      - go.sum
    generates:
      - "{{.BUILD_DIR}}/{{.BINARY_NAME}}"

  build:linux:
    desc: Build for Linux amd64
    env:
      GOOS: linux
      GOARCH: amd64
    cmds:
      - task: build
        vars:
          BUILD_DIR: "./build/linux"

  build:all:
    desc: Cross-compile for all target platforms
    deps:
      - task: build:linux
      - task: build:darwin
      - task: build:windows

  # ── Test ───────────────────────────────────────────────────────────────────

  test:
    desc: Run tests with race detector
    cmds:
      - go test -race -count=1 ./...

  test:coverage:
    desc: Run tests and generate coverage report
    cmds:
      - go test -race -coverprofile=coverage.out -covermode=atomic ./...
      - go tool cover -html=coverage.out -o coverage.html
      - echo "Coverage report written to coverage.html"

  test:unit:
    desc: Run only unit tests (skip integration tests)
    cmds:
      - go test -race -short ./...

  test:integration:
    desc: Run integration tests (requires running infrastructure)
    deps:
      - infra:up
    cmds:
      - go test -race -run Integration ./...

  # ── Code Quality ───────────────────────────────────────────────────────────

  lint:
    desc: Run golangci-lint
    cmds:
      - golangci-lint run ./...
    sources:
      - "**/*.go"
      - .golangci.yml

  fmt:
    desc: Format all Go code
    cmds:
      - gofmt -w .
      - goimports -w .

  vet:
    desc: Run go vet
    cmds:
      - go vet ./...

  check:
    desc: Run all code quality checks
    deps:
      - lint
      - vet
      - test:unit

  # ── Database ───────────────────────────────────────────────────────────────

  db:migrate:
    desc: Run database migrations
    cmds:
      - migrate -path ./migrations -database "{{.DATABASE_URL}}" up

  db:migrate:down:
    desc: Roll back the last migration
    cmds:
      - migrate -path ./migrations -database "{{.DATABASE_URL}}" down 1

  db:new-migration:
    desc: Create a new migration file
    cmds:
      - migrate create -ext sql -dir ./migrations -seq {{.CLI_ARGS}}

  db:seed:
    desc: Seed development database
    deps:
      - db:migrate
    cmds:
      - go run ./cmd/seed/...

  # ── Infrastructure ─────────────────────────────────────────────────────────

  infra:up:
    desc: Start local infrastructure (Postgres, Redis, etc.)
    cmds:
      - docker compose -f docker-compose.dev.yml up -d
      - task: db:migrate

  infra:down:
    desc: Stop local infrastructure
    cmds:
      - docker compose -f docker-compose.dev.yml down

  infra:reset:
    desc: Wipe and recreate local infrastructure
    cmds:
      - docker compose -f docker-compose.dev.yml down -v
      - task: infra:up
      - task: db:seed

  # ── Docker ─────────────────────────────────────────────────────────────────

  docker:build:
    desc: Build Docker image
    vars:
      GIT_SHA:
        sh: git rev-parse --short HEAD
    cmds:
      - docker build
          --build-arg VERSION={{.GIT_SHA}}
          -t {{.DOCKER_IMAGE}}:{{.GIT_SHA}}
          -t {{.DOCKER_IMAGE}}:latest
          .

  docker:push:
    desc: Push Docker image to registry
    deps:
      - docker:build
    cmds:
      - docker push {{.DOCKER_IMAGE}}:latest

  # ── Development ────────────────────────────────────────────────────────────

  dev:
    desc: Start development server with hot reload
    deps:
      - infra:up
    cmds:
      - air -c .air.toml

  gen:
    desc: Run all code generators
    cmds:
      - go generate ./...

  # ── CI ─────────────────────────────────────────────────────────────────────

  ci:
    desc: Run the full CI pipeline
    cmds:
      - task: check
      - task: test:coverage
      - task: docker:build

  # ── Cleanup ────────────────────────────────────────────────────────────────

  clean:
    desc: Remove build artifacts
    cmds:
      - rm -rf {{.BUILD_DIR}}
      - rm -f coverage.out coverage.html

Key Features Deep Dive

Task Dependencies

Tasks can depend on other tasks. Dependencies run before the task body, in parallel by default:

1
2
3
4
5
6
7
8
tasks:
  ci:
    deps:
      - lint
      - test
      - build
    cmds:
      - echo "All checks passed"

All three lint, test, and build tasks run concurrently. To force sequential execution:

1
2
3
4
5
  ci:
    cmds:
      - task: lint
      - task: test
      - task: build

Variables and Templating

Taskfile uses Go’s text/template syntax for variable interpolation. Variables can come from multiple sources in precedence order:

  1. CLI flags: task build VERSION=1.2.3
  2. Task-level vars:
  3. File-level vars:
  4. Shell command output
  5. .env files (via dotenv:)
  6. Environment variables
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
vars:
  # Static value
  APP_NAME: myapp

  # Shell command — evaluated lazily when first used
  GIT_COMMIT:
    sh: git rev-parse --short HEAD

  # Reference another variable
  FULL_TAG: "{{.APP_NAME}}:{{.GIT_COMMIT}}"

tasks:
  tag:
    cmds:
      - echo "Building {{.FULL_TAG}}"

Pass variables from the CLI:

1
task build VERSION=2.0.0 ENV=production

.env File Support

1
2
3
dotenv:
  - .env           # Always loaded
  - '.env.{{.ENV}}'  # Loaded if .env.staging exists when ENV=staging

This integrates cleanly with twelve-factor app conventions.

Source and Generate: Intelligent Caching

Taskfile can skip tasks when outputs are newer than inputs — similar to Make’s file-based caching, but without the footguns:

1
2
3
4
5
6
7
8
9
tasks:
  proto:
    desc: Generate protobuf code
    sources:
      - "**/*.proto"
    generates:
      - "**/*.pb.go"
    cmds:
      - buf generate

Run task proto twice — the second time it prints task: Task "proto" is up to date and skips execution. This dramatically speeds up CI pipelines.

For tasks where file-based caching doesn’t apply, use method: checksum or method: timestamp:

1
2
3
4
5
6
7
tasks:
  build:
    method: checksum  # Re-run when source checksums change
    sources:
      - "**/*.go"
    generates:
      - ./build/myapp

Passing Arguments to Tasks

Use {{.CLI_ARGS}} to forward arbitrary arguments:

1
2
3
4
5
tasks:
  db:new-migration:
    desc: "Usage: task db:new-migration -- add_user_table"
    cmds:
      - migrate create -ext sql -dir ./migrations {{.CLI_ARGS}}
1
task db:new-migration -- add_user_email_index

Looping Over Items

1
2
3
4
5
6
7
8
tasks:
  build:services:
    desc: Build all microservices
    vars:
      SERVICES: "auth api gateway worker"
    cmds:
      - for: {var: SERVICES, split: ' '}
        cmd: docker build -t mycompany/{{.ITEM}} ./services/{{.ITEM}}

Or loop over file globs:

1
2
3
4
  lint:configs:
    cmds:
      - for: {glob: "configs/**/*.yaml"}
        cmd: yamllint {{.ITEM}}

Prompt for Confirmation

Protect destructive tasks with prompt:

1
2
3
4
5
6
tasks:
  db:drop:
    desc: Drop the production database
    prompt: "Are you sure you want to drop the production database? This is irreversible!"
    cmds:
      - psql $DATABASE_URL -c "DROP DATABASE myapp"

Running task db:drop asks for confirmation:

task: Are you sure you want to drop the production database? This is irreversible! [y/N]

Ignore Errors

Sometimes you want a cleanup task to continue even if a step fails:

1
2
3
4
5
6
tasks:
  clean:
    cmds:
      - rm -rf ./build
      - docker rmi myapp:latest
        ignore_error: true  # Don't fail if image doesn't exist

Or ignore errors for the entire task:

1
2
3
4
5
tasks:
  stop:
    ignore_error: true
    cmds:
      - pkill myapp

Running Tasks Silently

Suppress command echoing for specific commands:

1
2
3
4
5
tasks:
  status:
    silent: true  # Don't print the command, only its output
    cmds:
      - echo "Build: $(git rev-parse --short HEAD)"

Or prefix individual commands with @:

1
2
3
4
  build:
    cmds:
      - "@echo Building..."
      - go build ./...

Defer: Cleanup on Exit

Run cleanup commands when a task exits, even on failure:

1
2
3
4
5
6
7
tasks:
  test:integration:
    cmds:
      - defer: docker compose -f docker-compose.test.yml down
      - docker compose -f docker-compose.test.yml up -d
      - go test -run Integration ./...
      # docker compose down runs even if tests fail

Organizing Large Projects

Namespacing Tasks

Colons create visual namespaces in task names:

1
2
3
4
task build:linux
task test:unit
task db:migrate
task docker:push

This is purely convention — colons have no special meaning to Task. But task --list groups them visually:

build:all         Cross-compile for all target platforms
build:linux       Build for Linux amd64
db:migrate        Run database migrations
db:new-migration  Create a new migration file
docker:build      Build Docker image
docker:push       Push Docker image to registry

Including External Taskfiles

Split large Taskfiles into focused modules:

1
2
3
4
5
6
7
# Taskfile.yml
version: '3'

includes:
  db: ./taskfiles/database.yml
  docker: ./taskfiles/docker.yml
  k8s: ./taskfiles/kubernetes.yml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# taskfiles/database.yml
version: '3'

tasks:
  migrate:
    cmds:
      - migrate -path ./migrations up

  seed:
    cmds:
      - go run ./cmd/seed

Tasks from included files are namespaced:

1
2
3
task db:migrate
task docker:build
task k8s:deploy

Include with a different directory context:

1
2
3
4
includes:
  frontend:
    taskfile: ./frontend/Taskfile.yml
    dir: ./frontend  # Commands run from ./frontend/

Monorepo Setup

For monorepos with multiple services:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Root Taskfile.yml
version: '3'

includes:
  auth:
    taskfile: ./services/auth/Taskfile.yml
    dir: ./services/auth
  api:
    taskfile: ./services/api/Taskfile.yml
    dir: ./services/api
  web:
    taskfile: ./apps/web/Taskfile.yml
    dir: ./apps/web

tasks:
  build:all:
    deps:
      - auth:build
      - api:build
      - web:build

  test:all:
    cmds:
      - task: auth:test
      - task: api:test
      - task: web:test

Each service maintains its own Taskfile.yml and can be run independently. The root Taskfile orchestrates the whole monorepo.

CI/CD Integration

GitHub Actions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'

      - name: Install Task
        uses: arduino/setup-task@v2
        with:
          version: 3.x
          repo-token: ${{ secrets.GITHUB_TOKEN }}

      - name: Run CI pipeline
        run: task ci
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}

The arduino/setup-task action installs Task and caches it. Your CI pipeline now runs the exact same task ci command that developers run locally.

GitLab CI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# .gitlab-ci.yml
default:
  image: golang:1.22

before_script:
  - sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /usr/local/bin

test:
  stage: test
  script:
    - task test:coverage
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage.xml

build:
  stage: build
  script:
    - task build:linux
  artifacts:
    paths:
      - build/

Makefile Compatibility Shim

If your team is gradually migrating from Make, or if you have tools that assume a Makefile exists, add a thin shim:

1
2
3
4
5
6
# Makefile — delegates to Taskfile

.DEFAULT_GOAL := default

%:
	@task $@ -- $(MAKEFLAGS)

Now make build calls task build, and developers can migrate at their own pace.

Cross-Platform Development

One of Taskfile’s killer features is cross-platform support. On Windows, task runs commands through PowerShell or cmd.exe unless you specify otherwise. Use platforms to run OS-specific variants:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
tasks:
  open:
    desc: Open the project in the browser
    platforms: [linux]
    cmds:
      - xdg-open http://localhost:8080

  open:
    desc: Open the project in the browser
    platforms: [darwin]
    cmds:
      - open http://localhost:8080

  open:
    desc: Open the project in the browser
    platforms: [windows]
    cmds:
      - start http://localhost:8080

Or use the sh interpreter explicitly for portable shell commands:

1
2
3
4
tasks:
  setup:
    cmds:
      - sh -c 'mkdir -p ./build ./dist ./coverage'

Advanced Patterns

Dynamic Task Generation with Matrix

Run the same task across multiple configurations:

1
2
3
4
5
6
7
8
tasks:
  test:matrix:
    desc: Test against multiple Go versions
    vars:
      VERSIONS: "1.21 1.22 1.23"
    cmds:
      - for: {var: VERSIONS, split: ' '}
        cmd: docker run --rm -v $(pwd):/app -w /app golang:{{.ITEM}} go test ./...

Watching for File Changes

Task has a built-in watch mode:

1
task --watch test:unit

Any time a source file changes, test:unit re-runs. Combine with sources: for fine-grained watching:

1
2
3
4
5
6
tasks:
  test:unit:
    sources:
      - "**/*.go"
    cmds:
      - go test -short ./...

Environment-Specific Overrides

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Taskfile.yml
version: '3'

dotenv:
  - .env
  - '.env.{{.ENV}}'  # .env.development, .env.staging, .env.production

vars:
  ENV: '{{.ENV | default "development"}}'
  DB_HOST: '{{.DB_HOST | default "localhost"}}'

tasks:
  deploy:
    cmds:
      - echo "Deploying to {{.ENV}}"
      - kubectl apply -f k8s/{{.ENV}}/
1
2
ENV=staging task deploy
ENV=production task deploy

Using Output Across Tasks

Share output between tasks using task dependencies and generated files, or capture output with variables:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
tasks:
  get-version:
    cmds:
      - git describe --tags --always > .version
    generates:
      - .version

  build:
    deps:
      - get-version
    vars:
      VERSION:
        sh: cat .version
    cmds:
      - go build -ldflags="-X main.version={{.VERSION}}" ./...

Taskfile vs Alternatives

Feature Taskfile Make Just Doit
Language YAML Custom DSL Custom DSL Python
Cross-platform Partial
Parallel tasks
File-based caching
.env loading Manual Manual
Task includes Via include
Watch mode No No
Zero config runtime ✅ (single binary) Usually pre-installed Single binary Requires Python
Learning curve Low High Low Medium

Just is the closest competitor to Taskfile. It uses its own justfile DSL (similar to Make but without the file-target model) and is excellent if you prefer a non-YAML syntax. Taskfile’s advantages are YAML’s ecosystem (syntax highlighting everywhere, schema validation, editor support) and the built-in watch mode.

Make is worth keeping for projects that genuinely benefit from file-based incremental builds — C/C++ projects, for example. For everything else, Taskfile is more ergonomic.

Common Patterns Reference

Project Onboarding Task

Every project should have a setup task that gets a new developer running in one command:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
tasks:
  setup:
    desc: "One-time project setup for new developers"
    cmds:
      - cp .env.example .env
      - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
      - go install github.com/cosmtrek/air@latest
      - task: infra:up
      - task: db:migrate
      - task: db:seed
      - echo ""
      - echo "✓ Setup complete. Run 'task dev' to start the development server."

Pre-commit Hook Integration

1
2
3
4
5
6
7
8
tasks:
  pre-commit:
    desc: Run pre-commit checks (called by git hook)
    cmds:
      - task: fmt
      - task: vet
      - task: lint
      - task: test:unit
1
2
3
# .git/hooks/pre-commit
#!/bin/sh
task pre-commit

Release Task

1
2
3
4
5
6
7
tasks:
  release:
    desc: "Usage: task release -- v1.2.3"
    cmds:
      - git tag -a {{.CLI_ARGS}} -m "Release {{.CLI_ARGS}}"
      - git push origin {{.CLI_ARGS}}
      - goreleaser release --clean

Getting Started Checklist

  1. Install Task: brew install go-task or the install script
  2. Create Taskfile.yml in your project root with a version: '3' header
  3. Add a default task that runs task --list so newcomers know where to start
  4. Replace your top Makefile targets first — build, test, lint, clean
  5. Add infra:up / infra:down for local development dependencies
  6. Add a setup task that onboards new developers in one command
  7. Wire CI to run task ci — the same command developers run locally
  8. Commit Taskfile.yml and delete the Makefile (or add the shim)

Conclusion

Taskfile occupies a sweet spot: it’s more expressive than Make for task running, simpler than shell scripts for complex orchestration, and more portable than both. The YAML format is familiar to anyone who has written GitHub Actions or Docker Compose, and the single static binary makes installation trivial in CI and containers.

The real win is consistency. When task test runs the same way locally, in CI, and in Docker, you eliminate an entire class of “works on my machine” problems. New team members run task setup and are productive in minutes instead of following a stale README.

For any project with more than two or three operations, Taskfile.yml deserves to be in the root of your repository alongside README.md and .gitignore.

Comments