Earthly: Containerized Build Pipelines That Combine Dockerfile and Makefile

Reproducible builds with a syntax you already half-know, that run the same on your laptop and in CI

Every project I’ve maintained eventually grows a Makefile full of build, test, lint, and release targets that work beautifully on my machine and nowhere else. The targets assume a particular toolchain, a particular OS, particular environment variables. Then CI does something subtly different, and you maintain two parallel descriptions of the same build forever. Earthly is the tool that finally made me delete that duplication. It’s what you get if a Makefile and a Dockerfile had a child who insisted everything run in a container.

Advertisement

An Earthfile looks like a Dockerfile with named targets. Each target runs in its own container, inherits a base, and can produce artifacts or images. Because every step runs in a container, the build is the same on your laptop, on a colleague’s laptop, and in CI — there is no “but my Go version is different.” And because it’s built on BuildKit, caching is content-addressed and aggressive: unchanged steps are skipped, and independent targets run in parallel automatically.

Here’s a realistic Earthfile for a Go service:

VERSION 0.8
FROM golang:1.22-bookworm
WORKDIR /app

deps:
    COPY go.mod go.sum ./
    RUN go mod download
    SAVE ARTIFACT go.mod
    SAVE ARTIFACT go.sum

build:
    FROM +deps
    COPY . .
    RUN CGO_ENABLED=0 go build -o vors-api ./cmd/api
    SAVE ARTIFACT vors-api AS LOCAL build/vors-api

test:
    FROM +deps
    COPY . .
    RUN go test -race ./...

lint:
    FROM +deps
    COPY . .
    RUN go vet ./... && test -z "$(gofmt -l .)"

docker:
    FROM alpine:3.20
    COPY +build/vors-api /usr/local/bin/vors-api
    ENTRYPOINT ["/usr/local/bin/vors-api"]
    SAVE IMAGE vors-api:latest

The +name syntax references another target. +build/vors-api means “the vors-api artifact produced by the build target.” SAVE ARTIFACT ... AS LOCAL writes a file back to your actual filesystem; SAVE IMAGE produces a Docker image. You run any target by name:

$ earthly +test
 1. Init 🚀
 2. Build 🔧
           +deps | --> RUN go mod download
           +test | --> RUN go test -race ./...
           +test | ok  github.com/vors/api/internal/http  0.412s
 3. Push ⬆️  (disabled)
 ========================== SUCCESS ===========================

Run earthly +docker and it builds, packages, and tags the image — reusing the cached deps layer it already computed for test. That’s the whole trick: shared base targets mean go mod download happens once even though three targets depend on it.

A top-level target can orchestrate others. This is where it starts replacing CI YAML:

all:
    BUILD +lint
    BUILD +test
    BUILD +docker

earthly +all runs lint, test, and docker — and because they share the deps base but are otherwise independent, BuildKit runs them concurrently up to your local resource limits. No fan-out/fan-in YAML, no manually declaring “needs” between jobs. The dependency graph is implicit in the FROM and +target references.

The reason I bother is that CI becomes thin. A GitHub Actions workflow drops to essentially one meaningful line:

jobs:
  ci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: earthly/actions-setup@v1
        with: { version: v0.8.15 }
      - run: earthly --ci +all

The --ci flag enforces a clean, push-disabled run with strict caching. The logic lives in the Earthfile, which a developer can run identically with earthly +all before pushing. When CI fails, you reproduce it locally in one command. That alone has saved me more debugging than I can count — no more pushing “fix CI” commits to poke at a black box.

It is not free of friction. Earthly needs a BuildKit daemon, which it manages in a container; that’s a moving part, and on constrained CI runners the daemon’s disk cache needs minding or it fills the volume. Remote caching across CI runs takes configuration — locally caching is automatic, but a fresh CI runner starts cold unless you wire up a shared cache, and that setup is the least pleasant part of adopting it.

There’s also a real adoption question. The company behind Earthly wound down commercial operations a while back, so you should treat it as a stable, capable open-source tool rather than something with a vendor’s roadmap behind it. For me that’s fine — the format is simple, the behaviour is predictable, and BuildKit underneath is going nowhere. But if you need a guaranteed long-term support contract, factor that in.

Earthly is worth it if you have a polyglot monorepo, a build that’s painful to reproduce, or a CI config that has drifted from how anyone actually builds locally. The single biggest win is that “reproduce CI on your machine” becomes one command. It’s overkill for a tidy single-language project where the native tooling already gives you reproducible builds, and it adds a daemon you have to babysit a little. But for the messy, real-world projects where I’ve reached for it — where half the bugs were environment differences — it earned its place. I’d recommend trying it on one annoying service before committing the whole org.

Advertisement

Related Content

Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.