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:
- CLI flags:
task build VERSION=1.2.3
- Task-level
vars:
- File-level
vars:
- Shell command output
.env files (via dotenv:)
- 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.
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:
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
- Install Task:
brew install go-task or the install script
- Create
Taskfile.yml in your project root with a version: '3' header
- Add a
default task that runs task --list so newcomers know where to start
- Replace your top Makefile targets first —
build, test, lint, clean
- Add
infra:up / infra:down for local development dependencies
- Add a
setup task that onboards new developers in one command
- Wire CI to run
task ci — the same command developers run locally
- 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