We migrated from Make to just about two years ago. The main reasons: the makefile-as-build-tool vs makefile-as-task-runner impedance mismatch was costing us time, and we kept writing .PHONY lists that were nine-tenths of the file.

just is great. But a monorepo with 40+ services has its own problems. Here are the patterns that stuck.

One justfile per service, plus a root justfile

Each service has its own justfile with its local rituals:

# services/api/justfile
set dotenv-load := true

test:
    pytest tests/

build:
    docker build -t api:dev .

dev:
    uvicorn app:app --reload --port 8001

The root justfile delegates:

# /justfile
default:
    @just --list

api +args='':
    just --justfile services/api/justfile --working-directory services/api {{args}}

web +args='':
    just --justfile services/web/justfile --working-directory services/web {{args}}

So just api test runs tests in the api service. just api build builds it. just web dev starts the web frontend. This composability is important — you don’t have to remember which directory to cd into.

just all test across services

The pattern I kept wanting is “run X on every service.” Got it with:

services := "api web worker scheduler"

all cmd='test':
    #!/usr/bin/env bash
    set -e
    for svc in {{services}}; do
        echo ">>> $svc"
        just "$svc" {{cmd}}
    done

just all test runs tests in every service. just all build builds them. Fails fast on the first failure, which is usually what I want.

Don’t put build logic in just

just is great for “run this 15-word command” not for “run this 50-line shell script with control flow.” The second case, make a real shell script, put it in scripts/, and have just call it.

deploy env:
    ./scripts/deploy.sh {{env}}

The scripts are tested, have proper error handling, and can be invoked outside just. The justfile is a thin directory of what the team does.

Positional arguments and defaults

just supports positional args nicely:

migrate direction='up' steps='1':
    alembic -c alembic.ini {{direction}} {{steps}}

just migrate does up 1. just migrate down 3 does down 3. Readable.

Shared variables via an imports file

If multiple justfiles need shared variables (image tag, registry URL, Python version), an import works but is awkward across services. We use an .env.tasks file at the repo root and load it everywhere:

set dotenv-load := true
set dotenv-filename := "../../.env.tasks"

Then PYTHON_VERSION=3.12, IMAGE_REGISTRY=myreg, etc. are just environment variables in every recipe.

The @ and quiet pragmas

Prefix a recipe line with @ to suppress command echoing:

clean:
    @rm -rf build/
    @echo "cleaned"

For a whole recipe, use @ on the recipe header:

@clean:
    rm -rf build/
    echo "cleaned"

This matters for composability. You don’t want just all test to dump 40 echoed pytest ... commands to stderr.

The CI/dev symmetry trick

I’ve made this a rule: if CI runs a command, there’s a just recipe that runs the same command. Always.

# .github/workflows/test.yml
- run: just test-ci
test-ci:
    pytest tests/ --cov=app --junitxml=report.xml

When CI is red, the engineer can run just test-ci locally and get the exact same failure. No “it passed locally but failed in CI” mysteries from subtle command differences. The CI yaml is thin; the justfile has the commands.

The thing that didn’t work

I tried to use just’s dependencies (recipe: dep1 dep2) as a poor-man’s build system. “build depends on test depends on lint.” This was a mistake. just’s dependency model doesn’t cache. Every run re-runs dependencies. For long chains, this was slow and confusing.

I went back to: just recipes are things you want to run as commands. If you want “build depends on test,” write a script that runs them in sequence with proper caching, or use a real build system (Bazel, buck, Nx).

Reflection

just is not revolutionary. It’s a nice task runner. The value comes from having a single canonical place where every team member can see what commands we run, written in a way that doesn’t require Make lore. For a monorepo, the per-service + root delegation pattern is the unlock that makes it work at scale.

Related: Dev containers at 30 engineers.