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.
1 The pitch
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:latestThe +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.
2 Parallelism and the BUILD keyword
A top-level target can orchestrate others. This is where it starts replacing CI YAML:
all:
BUILD +lint
BUILD +test
BUILD +dockerearthly +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.
3 CI that’s just earthly +all
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 +allThe --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.
4 The honest caveats
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.
5 Verdict
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.




