just is a command runner written in Rust. You describe project commands, called recipes, in a justfile, and run them with just <recipe>. It borrows Make's syntax but drops the parts that make Make awkward as a command runner: there are no .PHONY targets, no file timestamp logic, recipes take arguments, and errors point to the exact line. In this tutorial you will install just on Ubuntu 24.04 and write a justfile that builds, serves and deploys a small static website.

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 shell knowledge.
  • Optional, for the deploy recipe: a second server you can reach over SSH, with a directory your SSH user can write to.

The example uses only tools that ship with Ubuntu (python3, sed, rsync), so you can run every recipe as you go.

Step 1 - Installing just

Ubuntu 24.04 has a just package in the universe repository, but it is an older release that lacks some features used in this guide. Install the current release with the project's official install script, which downloads a prebuilt binary from GitHub. Download it first and read it before running it:

curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh -o install-just.sh
less install-just.sh

Install the binary into ~/.local/bin, which needs no sudo:

mkdir -p ~/.local/bin
bash install-just.sh --to ~/.local/bin

On Ubuntu, ~/.local/bin is added to your PATH at login when the directory exists. Log out and back in (or run source ~/.profile), then verify:

just --version
just 1.x.y

Enable tab completion for recipe names in Bash:

mkdir -p ~/.local/share/bash-completion/completions
just --completions bash > ~/.local/share/bash-completion/completions/just

Open a new shell for completion to take effect.

Step 2 - Creating the example project

Create a tiny static site with a version placeholder that the build will fill in:

sudo apt update
sudo apt install git rsync
mkdir -p ~/site/src && cd ~/site
nano src/index.html
<!doctype html>
<html>
  <head><title>My site</title></head>
  <body>
    <h1>Hello from just</h1>
    <p>Version: __VERSION__</p>
  </body>
</html>

Put the project under Git so the build can use the commit as a version:

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

Step 3 - Writing your first recipes

Create a file called justfile in the project root. A recipe is a name followed by a colon, and its body is indented lines of shell commands:

nano justfile
# List available recipes
default:
    @just --list

# Build the site into dist/
build:
    rm -rf dist
    mkdir -p dist
    cp -r src/. dist/

# Remove build output
clean:
    rm -rf dist

The comment directly above a recipe becomes its description in just --list. The first recipe in the file is the default one, run when you type just with no arguments. A leading @ stops just from echoing that command before running it.

Run it:

just
Available recipes:
    build   # Build the site into dist/
    clean   # Remove build output
    default # List available recipes
just build
ls dist
rm -rf dist
mkdir -p dist
cp -r src/. dist/
index.html

just prints each command to standard error before running it and stops at the first command that fails. You can run several recipes in one call, such as just clean build, and preview what a recipe would do with just --dry-run build.

Step 4 - Using variables, parameters and dependencies

Variables are assigned with := at the top of the file and used inside recipes with {{name}}. A value in backticks is the output of a command, evaluated when the justfile loads.

Replace the contents of justfile with:

version := `git describe --tags --always 2>/dev/null || echo dev`
dist := "dist"

# List available recipes
default:
    @just --list

# Build the site into dist/
build:
    rm -rf {{dist}}
    mkdir -p {{dist}}
    cp -r src/. {{dist}}/
    sed -i 's/__VERSION__/{{version}}/' {{dist}}/index.html

# Serve the built site locally
serve port="8000": build
    python3 -m http.server {{port}} --directory {{dist}}

# Remove build output
clean:
    rm -rf {{dist}}

Two new things appear in serve:

  • port="8000" is a parameter with a default value. Parameters without a default are required.
  • : build after the parameters is a dependency: build runs first, every time.

Start the server on the default port, then on another one:

just serve
rm -rf dist
mkdir -p dist
cp -r src/. dist/
sed -i 's/__VERSION__/4e1d2a7/' dist/index.html
python3 -m http.server 8000 --directory dist
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

From a second terminal, confirm the version was stamped:

curl -s http://localhost:8000/ | grep Version
    <p>Version: 4e1d2a7</p>

Stop the server with Ctrl+C. To use another port, pass it as an argument: just serve 9000. Top-level variables can also be overridden from the command line, for example just version=v1.0.0 build.

To inspect values, just --evaluate prints every variable, and just --show serve prints a recipe's source.

Step 5 - Loading settings from the environment and .env

Values that change per machine, such as where to deploy, should not be hard-coded. set dotenv-load makes just read a .env file in the project directory and export its variables to every recipe, and the env() function reads an environment variable with an optional default.

Add these lines at the top of the justfile:

set dotenv-load
set shell := ["bash", "-euo", "pipefail", "-c"]

deploy_host := env("DEPLOY_HOST", "")
deploy_path := env("DEPLOY_PATH", "/var/www/site")

set shell runs recipe lines with Bash in strict mode, so unset variables and failures inside pipelines stop the recipe instead of being ignored.

Create the .env file and keep it out of Git:

nano .env
DEPLOY_HOST=deploy@your_server_ip
DEPLOY_PATH=/var/www/site
printf '%s\n' '.env' 'dist/' >> .gitignore

The build output in dist/ is ignored too, since it is generated and would otherwise make the working tree look dirty. Replace deploy@your_server_ip with an SSH user and host you can log in to. Check that just picks the values up:

just --evaluate deploy_host
deploy@your_server_ip

Step 6 - Writing multi-line logic with shebang recipes

When a recipe needs if statements, loops or variables shared across lines, start its body with a shebang. just then writes the body to a temporary script and runs it with that interpreter, as one program.

Add a deploy recipe that checks its configuration and the Git state before copying files:

# Deploy the built site with rsync
[confirm]
deploy: build
    #!/usr/bin/env bash
    set -euo pipefail
    if [[ -z "{{deploy_host}}" ]]; then
        echo "DEPLOY_HOST is not set. Add it to .env." >&2
        exit 1
    fi
    if [[ -n "$(git status --porcelain)" ]]; then
        echo "Commit your changes before deploying." >&2
        exit 1
    fi
    rsync -avz --delete {{dist}}/ "{{deploy_host}}:{{deploy_path}}/"

The [confirm] attribute asks for confirmation before the recipe runs, which is a cheap safety net for anything that touches a server. Commit your work so the Git check passes, then deploy:

git add justfile .gitignore
git commit -m "Add justfile"
just deploy
Run recipe `deploy`? y
...
sending incremental file list
./
index.html

sent 312 bytes  received 38 bytes  700.00 bytes/sec

just --yes deploy skips the prompt, for example in CI. Verify on the server that /var/www/site/index.html contains the current commit hash.

Step 7 - Organizing a growing justfile

A few more features keep larger justfiles readable:

  • Private helpers: a recipe whose name starts with _ (or has the [private] attribute) works normally but is hidden from just --list.
  • Variadic parameters: +args accepts one or more arguments, *args zero or more. They are joined with spaces.
  • OS-specific recipes: attributes like [linux] and [macos] let you define the same recipe twice for different systems.

For example, a recipe that passes any extra arguments through to rsync, and a private helper:

# Dry-run the deploy, extra rsync flags allowed
preview *flags: build
    rsync -avzn --delete {{flags}} {{dist}}/ "{{deploy_host}}:{{deploy_path}}/"

_check-tools:
    command -v rsync python3 git
just preview --itemize-changes

rsync -n only shows what would change, so this is safe to run at any time.

just also searches parent directories for a justfile, so you can run recipes from any subdirectory of the project. Recipes always run from the directory that contains the justfile.

Troubleshooting

  • error: No justfile found: just looks for justfile, Justfile or .justfile in the current directory and its parents. Use just --justfile path/to/justfile to point to another file.
  • Recipe line has inconsistent leading whitespace: all lines of a recipe must use the same indentation, all spaces or all tabs.
  • cd seems to be ignored: each line runs in a new shell. Use cd dir && command or a shebang recipe.
  • Unknown attribute or Unknown function: your just is older than the feature. Compare just --version with the changelog and install the latest release as in Step 1.
  • A .env variable is empty: make sure set dotenv-load is present and that .env is in the directory of the justfile, not the directory you run just from.

Conclusion

You installed just on Ubuntu 24.04 and built a justfile that stamps a version into a static site, serves it locally, loads deployment settings from .env and deploys with rsync after a confirmation and a Git check. Next, add recipes for the commands your team runs every day (tests, database migrations, docker compose shortcuts), call just from your CI jobs so local and CI runs match, and keep just --list as the first thing new contributors run.