Automation à la Carte: Makefile, justfile, or Shell Script?
Every software project needs a little backstage magic - the part where folders get created, servers spin up, and tests run with a single command. But which tool do you hand that wand to?
Should you write a Makefile? A justfile? A classic shell script?
Or… try to glue it all together with subprocess.run()
and regret it later?
Let’s walk through your options with examples, trade-offs, and a few pro tricks - served just the way you like it.
🍳 The Kitchen Setup
Imagine your project like a cozy kitchen.
Automation Tool | Role in the Kitchen |
---|---|
Makefile | The order slip system (tracks what to do) |
justfile | The chalkboard menu (quick, clear, editable) |
Shell script | The seasoned sous-chef (does the actual work) |
Python subprocess | The accountant you asked to make coffee |
You write orders on the menu or ticket (justfile or Makefile), but the real cooking happens in the kitchen (shell scripts).
Use the right tool in the right role - and your kitchen runs smooth.
🛠️ Makefile - The Order Slip System
Reliable, fast, and built for jobs that only need to run when something’s changed.
Best for:
- Rebuilding files only when needed
- CI pipelines and build logic
- Task chaining
✅ Pros
- Knows when to rerun things (thanks to timestamps)
- Standard in almost every language ecosystem
- Super concise for simple tasks
build: ## Compile the app
cargo build
❌ Cons (with a splash of bitterness)
Tab-sensitive syntax
init:
echo "Hello world" # those are spaces! ☠️
make: *** missing separator. Stop.
You’ll curse the tab key at least once.
Awkward parameter handling
say_hello:
echo "Hello, $(NAME)"
# Run like:
make say_hello NAME=Alice
Want make say_hello Alice
? Nope.
Feels ancient for real logic
ifeq ($(ENV),prod)
CMD = run-prod
else
CMD = run-dev
endif
If you want if/else
, loops, or functions - push them to a script. Seriously.
Bash logic gets ugly fast
Bash in Makefiles is fine for one-liners. But for loops?
clean:
for dir in logs cache temp; do \
echo "Cleaning $$dir..."; \
rm -rf "$$dir"/*.tmp || true; \
done
Not terrible… but:
- You need to double-escape variables (
$$dir
) - Every line ends in a backslash
- Missing a semicolon? Silent failure
For anything beyond three lines: call a script.
📋 justfile - The Chalkboard Menu
justfile is your kitchen cheat sheet - modern, clean, and delightfully predictable.
Best for:
- Developer tasks
- Parameterized CLI commands
- Shell scripting with fewer gotchas
✅ Pros
- No tabs, no drama
- Arguments work the way you expect:
greet name="chef":
echo "Hello, {{name}}!"
- Easy
.env
support:
set dotenv-load
❌ Cons (nothing burnt, just a few spills)
No idea when files changed
build:
cargo build # this always runs, even if unchanged
There’s no dependency tracking - everything runs every time.
Requires install
It’s not built-in like Make. But it’s easy:
brew install just
# or
cargo install just
Same Bash problem as Make
Bash in justfile is fine for simple echo tasks. But for loops or conditionals?
clean:
for dir in logs cache temp; do \
echo "Cleaning $dir..."; \
rm -rf "$dir"/*.tmp || true; \
done
Looks simple - until:
- You forget a
;
- You mess up a quote
- You try nesting logic 😵💫
Like Make, justfile shines as a wrapper, not a full-blown script.
🐚 Shell Scripts - The Seasoned Sous-Chef
Shell scripts are flexible, reliable, and perfect when you need control over how the onions are chopped.
Best for:
- Bootstrapping file trees
- Writing logic-heavy workflows
- Anything with loops, conditionals, or fallback logic
✅ Pros
- Full power: variables, loops, conditionals, functions
- Portable (bash is everywhere)
- Works great behind the scenes of Makefile or justfile
#!/usr/bin/env bash
set -euo pipefail
BASE="${1:-Project}"
mkdir -p "$BASE"/{scripts,assets,logs}
echo "✅ Project initialized in $BASE/"
❌ Cons (if left unsupervised)
Reusability is manual
You have to source other scripts to reuse logic:
source ./helpers.sh
greet_user "Alice"
Can get messy fast
Without great discipline, your deploy.sh
turns into a 400-line spaghetti bowl.
Unforgiving unless told to be
echo "Welcome, $USERNAME" # if USERNAME isn't set? No error.
# Always start with:
set -euo pipefail
🧩 The Golden Trick: Mix and Match
Here’s the trick every great project uses:
Use Makefile or justfile as your menu.
Let shell scripts do the cooking.
That way:
- You avoid logic mess in Makefile or justfile
- You still get a clean, discoverable CLI in Makefile:
init: ## Set up the project
./scripts/init.sh
or in justfile:
init:
./scripts/init.sh
Easy for newcomers. Powerful for the team.
🐍 Python’s subprocess - The Accountant with an Apron
You can automate with Python. But should you?
import subprocess
subprocess.run(["mkdir", "-p", "data/results"])
That’s a lot of ceremony to create a folder.
❌ Why it often sucks
- Too verbose for small tasks
- Fragile quoting with
shell=True
- Error handling is noisy
- You reinvent Bash… but worse
Use Python for data and logic-heavy work. For glue? Stick to shell.
🧠 When to Use What
Task Type | Use This |
---|---|
One-liner automation | Makefile or justfile |
Tasks with arguments | justfile |
Complex setup or logic | Shell script |
File-based rebuilds | Makefile |
Data-heavy or async logic | Python |
🔧 Pro Tips & Tricks
📝 Self-documenting Makefile
help: ## Show all commands
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-16s\033[0m %s\n", $$1, $$2}'
Run:
make help
Boom: instant CLI doc.
🌿 Load .env
in Makefile
ifneq (,$(wildcard .env))
include .env
export
endif
Then:
# .env
PORT=8080
ENV=prod
And use it like:
serve:
python app.py --port $(PORT) --env $(ENV)
Define your vars in .env
, use $(VAR_NAME)
in the Makefile. Clean and versionable.
🧰 Built-in variables cheat sheet
Make has smart defaults - less typing, more automation:
$(MAKE)
- Recursive make$(RM)
- Cross-platformrm -f
$(CURDIR)
- Absolute path to the current directory$(MAKEFILE_LIST)
- List of included Makefiles$(MAKEFLAGS)
- Flags passed to make (e.g.-j
,--silent
)$(wildcard ...)
- Match files with globbing (e.g.*.sh
)$(basename ...)
,$(notdir ...)
- Strip path or extension
# Recursive make to keep your root Makefile clean.
subdir:
$(MAKE) -C subdir
# Shell command to get current Git branch
branch:
@echo "Current branch: $(shell git rev-parse --abbrev-ref HEAD)"
# Use wildcard to list script files dynamically
SCRIPTS := $(wildcard scripts/*.sh)
# Use CURDIR for absolute paths
print-dir:
@echo "This Makefile lives in $(CURDIR)"
# Using RM for safe deletion
clean:
$(RM) -rf build dist
# Manipulate filenames
FILES := $(notdir $(SCRIPTS))
NAMES := $(basename $(FILES))
🌀 Automatic variables
These are dynamically set by Make for each rule (valid only inside recipe commands):
$@
- The target name$<
- The first prerequisite$^
- All prerequisites$?
- Only the prerequisites newer than the target
output.txt: input.txt
cat $< > $@ # Reads input.txt and writes to output.txt
Use these when chaining steps or avoiding hardcoded filenames.
🔁 Smart dependency handling
output.txt: input.txt process_data.sh
./process_data.sh input.txt output.txt
Only reruns when input.txt
or process_data.sh
changes.
⚡ Parallel Make
.PHONY: all build test
all: build test
build:
cargo build
test:
cargo test
Run with:
make -j 2
Builds and tests at the same time - faster feedback!
🪄 One-liner to create a Makefile from existing scripts
# For Makefile (requires tabs!)
ls scripts/*.sh | sed 's|scripts/||; s|\.sh||' | xargs -I{} echo -e "{}:\n\t./scripts/{}.sh\n"
# For justfile (uses spaces, no tab issues)
ls scripts/*.sh | sed 's|scripts/||; s|\.sh||' | xargs -I{} echo "{}:\n ./scripts/{}.sh"
This generates:
init:
./scripts/init.sh
deploy:
./scripts/deploy.sh
build:
./scripts/build.sh
Assumes you have shell scripts like scripts/init.sh
, scripts/deploy.sh
, etc.
Great for bootstrapping your automation menu in seconds.
🎯 Use .PHONY
to avoid caching weirdness
.PHONY: init test deploy help
🗂 Keep all logic in scripts/
scripts/init.sh
scripts/deploy.sh
scripts/build.sh
Your Makefile or justfile should read like a menu - not a novel.
🍰 Final Slice
Your project glue shouldn’t feel like a pile of tangled spaghetti.
- Makefile: Great for builds and CI
- justfile: Friendly for local tasks
- Shell scripts: Where real logic belongs
- Python: Use when Bash can’t cut it
Some folks cram everything into a Makefile or one giant Python script and expect magic.
But glue code should stay minimal - not become the whole system.
Keep things modular. Let each part do one job well.
And when it breaks? Fix it with one clean command.
☕ Automation is craft - serve it like a pro.