My Jekyll blog has been using a Makefile for as long as I can remember. It’s simple, it works, and honestly, I never thought much about it. But recently I’ve been writing more Ruby stuff, and it got me thinking: I’m already using bundle exec jekyll everywhere, so why not switch to Rake?

The Original Makefile

Here’s what I was working with:

JEKYLL = bundle exec jekyll

.PHONY: help build serve dev clean install

help:
	@echo "Available commands:"
	@echo "  make build   - Build the site"
	@echo "  make serve   - Serve the site locally with auto-reload (dev)"
	@echo "  make dev     - Alias for 'make serve'"
	@echo "  make clean   - Remove the generated site and cache"
	@echo "  make install - Install Ruby dependencies"

build:
	$(JEKYLL) build

serve:
	$(JEKYLL) serve --watch --livereload

dev: serve

clean:
	$(JEKYLL) clean
	rm -rf _site_*

install:
	bundle install

Five targets. Nothing fancy. It’s basically a shorthand so I don’t have to type bundle exec jekyll build a million times.

The Rakefile

I wanted a 1-to-1 mapping. Rake is Ruby’s build system, and since this whole project is Ruby-based, it makes more sense than pulling in Make (which is a C tool originally). Here’s what I ended up with:

JEKYLL = "bundle exec jekyll"

desc "Build the site"
task :build do
  sh "#{JEKYLL} build"
end

desc "Serve the site locally with auto-reload"
task :serve do
  sh "#{JEKYLL} serve --watch --livereload"
end

desc "Alias for serve"
task dev: :serve

desc "Remove the generated site and cache"
task :clean do
  sh "#{JEKYLL} clean"
  rm_rf Dir["_site_*"]
end

desc "Install Ruby dependencies"
task :install do
  sh "bundle install"
end

task default: :build

Let me walk through the interesting bits.

Variable

JEKYLL = "bundle exec jekyll"

Same idea as the Makefile, just Ruby syntax. Constants in Ruby start with a capital letter. This keeps the command in one place so I’m not repeating it everywhere.

Shell Commands

Make runs shell commands natively - that’s its whole deal. Rake needs sh() to do the same thing:

sh "#{JEKYLL} build"

Pretty clean. It prints the command and runs it. If the command fails, Rake stops with an error, just like Make does.

Task Dependencies

Make has prerequisites (like dev: serve), and Rake has the same concept:

task dev: :serve

This means running rake dev will first run the serve task. In Make, make dev does the same thing - it just runs the serve recipe directly since there’s no recipe of its own.

File Operations

For the clean target, I had to convert:

rm -rf _site_*

to:

rm_rf Dir["_site_*"]

Rake provides rm_rf (a wrapper around FileUtils.rm_rf) and Dir[] handles the glob pattern. This is actually better because Dir["_site_*"] expands the glob in Ruby rather than relying on the shell, which means it won’t silently fail if the glob doesn’t match anything (some shells return the literal _site_* string).

Descriptions

desc "Build the site"
task :build do
  ...
end

This is one feature I genuinely appreciate. Running rake -T gives me this:

rake build    # Build the site
rake clean    # Remove the generated site and cache
rake dev      # Alias for serve
rake install  # Install Ruby dependencies
rake serve    # Serve the site locally with auto-reload

No more maintaining a separate help target that I’ll forget to update. Rake auto-discovers task descriptions. In Make, I had to manually echo everything, and you know the docs would get out of sync eventually.

Default Task

task default: :build

Rake doesn’t have a built-in default target like Make does (where the first target is the default). You have to explicitly set it. Simple enough, just one line.

What About Justfiles?

For my Rust projects, I’ve been using just with justfiles instead of Make. It’s basically a modern Make that fixes a lot of the rough edges - no tabs required, recipes run with #!/usr/bin/env shells by default, and you get positional arguments out of the box. A typical justfile for a Rust project looks like:

build:
	cargo build

release:
	cargo build --release

test:
	cargo test

fmt:
	cargo fmt --all

check:
	cargo check

lint:
	cargo clippy -- -D warnings

It’s simpler than both Make and Rake, honestly.

My Own Attempt: Makeover

I even wrote my own build tool in Python called makeover. It uses a Buildfile with a cleaner syntax, supports variables, target dependencies (DAG execution), smart rebuilds based on file modification times, and group-based listing. Here’s what its Buildfile looks like:

[group: General]
# Default target runs all checks
all:
    ${py} ${src} --list

[group: Installation]
# Install binary to ~/.local/bin
install:
    echo "Installing ${bin} to ~/.local/bin..."
    mkdir -p ~/.local/bin
    cp ${src} ~/.local/bin/${bin}
    chmod +x ~/.local/bin/${bin}

[group: Utility]
# Clean up temporary files
clean:
    rm -rf __pycache__

lint:
    pylint ${src}

It worked well enough, but honestly, reinventing the wheel is more of a learning exercise than a practical solution. I don’t actually reach for it over Rake or just day-to-day.

One Tool Per Ecosystem

So that’s the pattern I’ve settled on: Make for C/C++, just for Rust, Rake for Ruby. I’ve tried rolling my own, but the existing tools just do the job better. For this blog, which is a Ruby project through and through, Rake fits perfectly. The conventions match (Gemfile, Rakefile, .rb files), and I don’t have to install anything extra since Rake ships with Ruby.

Did It Change My Life?

Honestly? Not really. It’s a build system for a static blog. But it does feel more natural in a Ruby project. rake build and rake serve look and feel like they belong in a Gemfile ecosystem.

The biggest practical win is rake -T - I can discover available commands without opening a file. That’s small, but it’s the kind of polish that makes a difference day-to-day.

If you’re running a Jekyll site or any Ruby project with a Makefile, the switch is painless and takes maybe five minutes. Your Makefile and Rakefile can even live side by side while you transition, since they don’t conflict.

The full Rakefile is up on GitHub if you want to check it out.