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
sudoprivileges. - 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
NoteIf you prefer a distribution package and do not need the newest features,
sudo apt install justworks too. Checkjust --versionand the changelog on GitHub if a feature from this guide is missing.
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.
ImportantEach line of a recipe runs in its own shell. A
cdor a shell variable set on one line does not carry over to the next. Use&&on one line, or a shebang recipe (Step 6), when lines depend on each other.
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.: buildafter the parameters is a dependency:buildruns 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 fromjust --list. - Variadic parameters:
+argsaccepts one or more arguments,*argszero 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:justlooks forjustfile,Justfileor.justfilein the current directory and its parents. Usejust --justfile path/to/justfileto 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.cdseems to be ignored: each line runs in a new shell. Usecd dir && commandor a shebang recipe.Unknown attributeorUnknown function: yourjustis older than the feature. Comparejust --versionwith the changelog and install the latest release as in Step 1.- A
.envvariable is empty: make sureset dotenv-loadis present and that.envis in the directory of the justfile, not the directory you runjustfrom.
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.
