Task is a task runner and build tool written in Go that reads its tasks from a Taskfile.yml. It covers what most projects use make for (named commands, dependencies, skipping work that is already up to date) with YAML syntax, no tab rules and a single static binary. In this tutorial you will install Task on Ubuntu 24.04 and build a Taskfile for a small Go application, step by step, using dependencies, variables, .env files, cached builds, preconditions and includes.

Prerequisites

To follow this tutorial you need:

  • A machine running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has sudo privileges.
  • Basic knowledge of the shell and YAML.

The example project is a small Go program, so the tutorial installs Go from the Ubuntu repositories. Nothing in Task is specific to Go: the same Taskfile patterns work for Node.js, Python, Docker or plain shell projects.

Taskfile compared with Make

MakeTask
File formatMakefile, tab-indentedTaskfile.yml, YAML
Up-to-date checkFile timestamps of targetsChecksums (default) or timestamps of sources/generates
VariablesMake variables, $(shell ...)vars, env, sh: dynamic values, .env files
Task listingNot built intask --list from desc fields
DependenciesPrerequisitesdeps (parallel) and task: calls (sequential)
InstallationPreinstalled almost everywhereOne extra binary

Make is still the better fit when you are building C code with real file-to-file rules. Task shines as the "entry point" of a project: task build, task test, task deploy.

Step 1 - Installing Task

Task's official packages include a snap. Install it with:

sudo snap install task --classic

If you prefer not to use snap, the project publishes a .deb for each release on GitHub:

curl -fsSLO https://github.com/go-task/task/releases/latest/download/task_linux_amd64.deb
sudo apt install ./task_linux_amd64.deb

Use task_linux_arm64.deb on ARM servers. Verify the installation:

task --version
Task version: v3.x.y

Step 2 - Creating the example project

Install Go and Git, then create a small program with one test:

sudo apt update
sudo apt install golang-go git
mkdir -p ~/hello && cd ~/hello
go mod init example.com/hello
nano main.go
package main

import (
	"flag"
	"fmt"
)

var Version = "dev"

func Greeting(name string) string {
	return fmt.Sprintf("Hello, %s!", name)
}

func main() {
	name := flag.String("name", "world", "who to greet")
	flag.Parse()
	fmt.Println(Greeting(*name), "(version "+Version+")")
}
nano main_test.go
package main

import "testing"

func TestGreeting(t *testing.T) {
	if got := Greeting("Ana"); got != "Hello, Ana!" {
		t.Fatalf("unexpected greeting: %q", got)
	}
}

Initialize a Git repository so the build can read a version from it later:

git init
git add .
git commit -m "Initial commit"

Step 3 - Writing a first Taskfile

Create Taskfile.yml in the project root. Each entry under tasks has a desc shown in the task list and a list of cmds run in order by a shell:

nano Taskfile.yml
version: '3'

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

  fmt:
    desc: Format the code
    cmds:
      - go fmt ./...

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

  build:
    desc: Build the binary
    cmds:
      - go build -o bin/hello .

  clean:
    desc: Remove build artifacts
    cmds:
      - rm -rf bin/

List the tasks and run one:

task --list
task build
task: Available tasks for this project:
* build:         Build the binary
* clean:         Remove build artifacts
* default:       List available tasks
* fmt:           Format the code
* test:          Run the tests
task: [build] go build -o bin/hello .

Task prints each command before running it. Running task with no arguments runs the default task. You can also pass several tasks at once, such as task fmt test build, and preview the commands without running them with task --dry build.

Step 4 - Adding dependencies

Dependencies listed in deps run before the task's own commands, and they run in parallel. Use them for independent preparation work. When order matters, call other tasks from cmds with task:, which runs them one after another.

Update the build task and add a ci task:

  build:
    desc: Build the binary
    deps: [fmt, test]
    cmds:
      - go build -o bin/hello .

  ci:
    desc: Run the full pipeline in order
    cmds:
      - task: clean
      - task: build
task ci
task: [clean] rm -rf bin/
task: [fmt] go fmt ./...
task: [test] go test ./...
ok      example.com/hello       0.002s
task: [build] go build -o bin/hello .

fmt and test may appear in either order because they run concurrently. If two tasks depend on the same task and it must execute only once per invocation, add run: once to it.

Step 5 - Using variables and environment

Task variables use Go template syntax, {{.NAME}}. Static values go under vars; a sh: entry runs a command and uses its output. Environment variables for the commands go under env.

Replace the build task and add global vars and env below version:

version: '3'

vars:
  BINARY: bin/hello
  VERSION:
    sh: git describe --tags --always --dirty 2>/dev/null || echo dev

env:
  CGO_ENABLED: '0'

tasks:
  build:
    desc: Build the binary with version information
    deps: [fmt, test]
    cmds:
      - go build -ldflags "-X main.Version={{.VERSION}}" -o {{.BINARY}} .

Keep the other tasks as they were. Build and run the binary:

task build
./bin/hello
Hello, world! (version 3f2a1c9)

Variables can be overridden from the command line, which is handy for release builds:

task build VERSION=v1.0.0
./bin/hello
Hello, world! (version v1.0.0)

To pass arbitrary arguments through to a command, use {{.CLI_ARGS}}, which contains everything after --:

  run:
    desc: Run the program, pass arguments after --
    cmds:
      - go run . {{.CLI_ARGS}}
task run -- -name Ana
task: [run] go run . -name Ana
Hello, Ana! (version dev)

Loading a .env file

Settings that differ per developer or per environment belong in a .env file that is not committed. Add dotenv at the top level of the Taskfile, next to vars:

dotenv: ['.env']

Create the file and ignore it in Git:

echo 'GREET_NAME=CubePath' > .env
echo '.env' >> .gitignore

Now a task can use the value as a normal shell variable:

  greet:
    desc: Greet the name from .env
    deps: [build]
    cmds:
      - ./{{.BINARY}} -name "$GREET_NAME"
task greet
Hello, CubePath! (version 3f2a1c9)

Step 6 - Skipping work that is already done

Like Make, Task can skip a task when its inputs have not changed. List the input files in sources and the outputs in generates; Task stores a checksum of the sources in a .task/ directory and compares it on the next run.

Add them to build:

  build:
    desc: Build the binary with version information
    deps: [fmt, test]
    sources:
      - '**/*.go'
      - go.mod
    generates:
      - '{{.BINARY}}'
    cmds:
      - go build -ldflags "-X main.Version={{.VERSION}}" -o {{.BINARY}} .

Run the build twice:

task build
task build

The second run skips the build step:

task: Task "build" is up to date

Add .task/ to .gitignore. To force a run anyway, use task build --force. With sources defined you can also use watch mode, which reruns the task whenever a source file changes:

task build --watch

For tasks without files, status does the same job with shell checks: the task is skipped when every command in status exits with 0. For example, a task that installs a tool only if it is missing:

  tools:
    desc: Install golangci-lint if missing
    status:
      - command -v golangci-lint
    cmds:
      - go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

Step 7 - Guarding tasks with preconditions and required variables

preconditions stop a task with a clear message when something is wrong, and requires makes a variable mandatory. Both are useful for anything that deploys:

  deploy:
    desc: Copy the binary to a server
    deps: [build]
    requires:
      vars: [HOST]
    preconditions:
      - sh: git diff --quiet
        msg: "You have uncommitted changes. Commit them before deploying."
    cmds:
      - scp {{.BINARY}} {{.HOST}}:/usr/local/bin/hello
task deploy
task: Task "deploy" cancelled because it is missing required variables: HOST

git diff --quiet only looks at files Git already tracks. To see the precondition fail, change the greeting text in main.go without committing it and run the task with HOST set:

task deploy HOST=deploy@your_server_ip
...
task: You have uncommitted changes. Commit them before deploying.
task: Failed to run task "deploy": task: precondition not met

Replace deploy@your_server_ip with a user and host you can reach over SSH, and make sure that user can write to the target directory.

Step 8 - Splitting tasks with includes

As a Taskfile grows, move related tasks to separate files and include them under a namespace. Create a file for Docker tasks:

mkdir -p taskfiles
nano taskfiles/docker.yml
version: '3'

vars:
  IMAGE: hello

tasks:
  build:
    desc: Build the Docker image
    cmds:
      - docker build -t {{.IMAGE}}:latest .

Include it from the main Taskfile.yml at the top level:

includes:
  docker:
    taskfile: ./taskfiles/docker.yml
    dir: .

dir: . runs the included tasks from the project root instead of the taskfiles/ directory. The tasks are now available with the namespace as a prefix:

task --list
* docker:build:      Build the Docker image
...

Run it with task docker:build once the project has a Dockerfile. Add optional: true to an include if the file may not exist, for example a per-developer Taskfile.

Step 9 - Running Task in CI

Because CI only has to call task ci, the pipeline stays identical to what developers run locally. In GitHub Actions, install Task with the arduino/setup-task action:

name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-go@v5
        with:
          go-version: stable

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

      - run: task ci

fetch-depth: 0 fetches the tags so that git describe produces a real version. On other CI systems, install the .deb from Step 1 in the job before calling task.

Troubleshooting

  • task: No Taskfile found: Task looks for Taskfile.yml (and variants such as Taskfile.yaml or taskfile.yml) in the current directory and its parents. Run it from the project, or point to the file with task --taskfile path/to/Taskfile.yml.
  • YAML errors such as mapping values are not allowed: commands that contain : or start with { must be quoted. Wrap the whole command in single quotes.
  • A variable is empty: Task variables are {{.VAR}}, shell and .env variables are $VAR. Mixing them up is the most common mistake. Run task --verbose build or task --summary build to inspect a task.
  • A task never rebuilds, or always rebuilds: check the sources and generates globs, and delete .task/ to reset the stored checksums.

Conclusion

You installed Task on Ubuntu 24.04 and built a Taskfile that formats, tests and builds a Go program with version information, skips up-to-date builds, loads settings from .env, guards deployments and splits tasks into namespaced files, all runnable the same way locally and in CI. Next, move your team's remaining Makefile targets into the Taskfile, add a lint task, and use task --list as the project's self-documenting entry point in the README.