Earthly: Reproducible Builds with Earthfile
Earthly: Reproducible Builds with Earthfile

Every developer has experienced the "works on my machine" problem with CI/CD. Your build passes locally but fails in GitHub Actions. Your colleague gets different test results because they have a different version of a system library. You spend hours debugging pipeline YAML only to discover the issue was a caching artifact. The root cause is always the same: your build is not reproducible.
Earthly solves this by combining the best ideas from Dockerfiles and Makefiles into a single build tool. An Earthfile defines your build steps using familiar Dockerfile syntax, runs them in isolated containers for reproducibility, supports targets and dependencies like Make, and caches aggressively for speed. The result is the same: whether you run the build on your laptop, your colleague's machine, or CI, the output is identical.
What Earthly Replaces
Earthly is not just another CI tool. It replaces the combination of tools most projects use:
Dockerfiles: Earthly uses Dockerfile-compatible syntax but extends it with build targets, arguments, and artifact outputs. Your Earthfile can produce Docker images, but it can also produce binaries, test reports, or any other build artifact.
Makefiles: Earthly targets work like Make targets. You define dependencies between them, and Earthly builds the dependency graph and executes in the right order with caching.
CI pipeline YAML: Instead of encoding build logic in GitHub Actions YAML or GitLab CI YAML, you put the logic in Earthfile and call it from any CI system with a one-line command. This means your CI configuration is portable.
Shell scripts: The glue scripts that tie your build together (build.sh, test.sh, deploy.sh) move into Earthfile targets with proper dependency tracking and caching.
Installation
Linux
sudo /bin/sh -c 'wget https://github.com/earthly/earthly/releases/latest/download/earthly-linux-amd64 -O /usr/local/bin/earthly && chmod +x /usr/local/bin/earthly && /usr/local/bin/earthly bootstrap'
macOS
brew install earthly/earthly/earthly && earthly bootstrap
Windows
choco install earthly
Via npm/Bun (CI-Friendly)
npm install -g @anthropic-ai/earthly
The bootstrap command sets up BuildKit, which Earthly uses under the hood for containerized execution. You need Docker installed and running.
Verify Installation
earthly --version
earthly github.com/earthly/hello-world+hello
The second command runs a remote Earthfile as a quick test. If you see "Hello, World!" you are ready.
Earthfile Syntax
An Earthfile lives at the root of your project (or in subdirectories). The syntax deliberately mirrors Dockerfiles, so if you know Docker, you already know most of Earthly.
Basic Structure
VERSION 0.8
FROM node:20-slim
WORKDIR /app
deps:
COPY package.json package-lock.json ./
RUN npm ci
SAVE ARTIFACT node_modules
build:
FROM +deps
COPY src/ src/
COPY tsconfig.json .
RUN npm run build
SAVE ARTIFACT dist AS LOCAL ./dist
test:
FROM +deps
COPY src/ src/
COPY tsconfig.json .
COPY vitest.config.ts .
RUN npm test
lint:
FROM +deps
COPY src/ src/
COPY biome.json .
RUN npx biome check .
docker:
FROM +build
EXPOSE 3000
CMD ["node", "dist/index.js"]
SAVE IMAGE --push my-app:latest
Targets
Each named section (deps:, build:, test:) is a target. Targets are like Make targets: you invoke them individually and they can depend on each other.
# Run a specific target
earthly +build
# Run multiple targets
earthly +test +lint
# Run the default target (the last one, or one named "all")
earthly +docker
FROM +target
The FROM +deps syntax means "start this target from the result of the deps target." This creates a dependency graph. When you run earthly +build, Earthly automatically runs +deps first if it has not been cached.
SAVE ARTIFACT
SAVE ARTIFACT exports files from the container:
# Save as a build artifact (available to other targets)
SAVE ARTIFACT dist
# Save to local filesystem
SAVE ARTIFACT dist AS LOCAL ./dist
# Save a specific file
SAVE ARTIFACT dist/index.js AS LOCAL ./output/index.js
SAVE IMAGE
SAVE IMAGE creates a Docker image from the current target state:
SAVE IMAGE my-app:latest
SAVE IMAGE --push ghcr.io/myorg/my-app:latest
The --push flag means the image will be pushed to the registry when Earthly runs with --push.
Real-World Earthfile Examples
Full-Stack TypeScript Application
VERSION 0.8
FROM node:20-slim
WORKDIR /app
deps:
COPY package.json bun.lockb ./
RUN npm install -g bun && bun install --frozen-lockfile
SAVE ARTIFACT node_modules
api-build:
FROM +deps
COPY packages/api/ packages/api/
COPY packages/shared/ packages/shared/
COPY tsconfig.json .
RUN bun run build:api
SAVE ARTIFACT packages/api/dist
web-build:
FROM +deps
COPY packages/web/ packages/web/
COPY packages/shared/ packages/shared/
COPY tsconfig.json .
RUN bun run build:web
SAVE ARTIFACT packages/web/dist
test:
FROM +deps
COPY . .
RUN bun test
SAVE ARTIFACT coverage AS LOCAL ./coverage
api-docker:
FROM node:20-slim
WORKDIR /app
COPY +api-build/dist ./dist
COPY +deps/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
SAVE IMAGE --push ghcr.io/myorg/api:latest
web-docker:
FROM nginx:alpine
COPY +web-build/dist /usr/share/nginx/html
SAVE IMAGE --push ghcr.io/myorg/web:latest
all:
BUILD +test
BUILD +api-docker
BUILD +web-docker
Running earthly +all builds everything: runs tests, builds both applications, and creates Docker images. Caching means subsequent runs only rebuild what changed.
Go Microservice with Multi-Platform Support
VERSION 0.8
FROM golang:1.22-bookworm
WORKDIR /app
deps:
COPY go.mod go.sum ./
RUN go mod download
SAVE ARTIFACT go.mod AS LOCAL go.mod
lint:
FROM +deps
RUN go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
COPY . .
RUN golangci-lint run ./...
test:
FROM +deps
COPY . .
RUN go test -race -coverprofile=coverage.out ./...
SAVE ARTIFACT coverage.out AS LOCAL ./coverage.out
build:
FROM +deps
COPY . .
ARG GOOS=linux
ARG GOARCH=amd64
RUN go build -ldflags="-s -w" -o bin/server ./cmd/server
SAVE ARTIFACT bin/server
docker:
FROM gcr.io/distroless/static-debian12
COPY +build/server /server
ENTRYPOINT ["/server"]
SAVE IMAGE --push ghcr.io/myorg/service:latest
multi-platform:
BUILD --platform=linux/amd64 --platform=linux/arm64 +docker
The multi-platform target builds Docker images for both AMD64 and ARM64 architectures in a single command.
Python Data Pipeline
VERSION 0.8
FROM python:3.12-slim
WORKDIR /app
deps:
RUN pip install uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen
SAVE ARTIFACT .venv
test:
FROM +deps
COPY . .
RUN uv run pytest --cov=src tests/
SAVE ARTIFACT htmlcov AS LOCAL ./htmlcov
lint:
FROM +deps
COPY . .
RUN uv run ruff check .
RUN uv run mypy src/
docker:
FROM python:3.12-slim
WORKDIR /app
COPY +deps/.venv .venv
COPY src/ src/
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "-m", "src.main"]
SAVE IMAGE --push ghcr.io/myorg/pipeline:latest
Caching
Caching is where Earthly truly shines. Every step in an Earthfile is cached by default, similar to Docker layer caching but smarter.
Layer Caching
Each RUN, COPY, and other commands creates a cached layer. If the inputs have not changed, Earthly reuses the cached result. This is why the common pattern is:
deps:
COPY package.json package-lock.json ./ # Only these files
RUN npm ci # Cached unless lockfile changes
By copying only the dependency files first, the npm ci step is cached as long as dependencies have not changed. Source code changes do not invalidate this cache.
Explicit Caching with CACHE
For tools that have their own cache directories, use the CACHE command:
build:
CACHE /root/.gradle
CACHE /root/.m2
RUN gradle build
This persists the cache directory across builds, dramatically speeding up Java/Gradle builds.
Remote Caching
For CI environments, you can share cache across machines:
# Push cache to a registry
earthly --push --remote-cache=ghcr.io/myorg/cache:main +build
# Pull cache from registry
earthly --remote-cache=ghcr.io/myorg/cache:main +build
This means your CI runners benefit from cache populated by previous builds, even across different machines.
Arguments and Conditional Logic
Earthly supports build arguments for parameterized builds:
VERSION 0.8
build:
ARG environment=production
ARG version=dev
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm run build -- --mode $environment
IF [ "$environment" = "production" ]
RUN npm run optimize
END
SAVE ARTIFACT dist
docker:
ARG tag=latest
FROM +build
SAVE IMAGE --push ghcr.io/myorg/app:$tag
# Build for staging
earthly +build --environment=staging
# Build and tag with version
earthly +docker --tag=v1.2.3 --environment=production
Conditional Execution
test:
ARG run_integration=false
FROM +deps
COPY . .
RUN npm run test:unit
IF [ "$run_integration" = "true" ]
RUN npm run test:integration
END
FOR Loops
lint-all:
FOR service IN api web worker
BUILD ./packages/$service+lint
END
Integrating with CI Systems
The beauty of Earthly is that your CI configuration becomes trivially simple.
GitHub Actions
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: earthly/actions-setup@v1
- name: Build and test
run: earthly --ci +all
- name: Push images
if: github.ref == 'refs/heads/main'
run: earthly --ci --push +docker
env:
EARTHLY_TOKEN: ${{ secrets.EARTHLY_TOKEN }}
GitLab CI
build:
image: earthly/earthly:latest
script:
- earthly --ci +all
Jenkins
pipeline {
agent any
stages {
stage('Build') {
steps {
sh 'earthly --ci +all'
}
}
}
}
In every case, the CI file is minimal. The build logic lives in the Earthfile, which you can test locally with the exact same command.
Earthly vs. Alternatives
Earthly vs. Docker + Make
The traditional approach of Dockerfiles for images and Makefiles for orchestration works but has no caching awareness between the two. Make does not know about Docker layer caching, and Docker does not understand Make target dependencies. Earthly unifies both concepts.
Earthly vs. Bazel
Bazel is a powerful build system from Google that provides hermetic, reproducible builds. However, Bazel has a steep learning curve, requires restructuring your project, and uses its own dependency management. Earthly is much simpler to adopt because it uses Dockerfile syntax you already know and works with your existing package managers.
Earthly vs. Dagger
Dagger lets you write build pipelines in a real programming language (Go, Python, TypeScript). This is more flexible than Earthly's declarative approach but also more complex. If your build requires sophisticated conditional logic, Dagger might be better. For most projects, Earthly's declarative Earthfile is simpler and sufficient.
Earthly vs. Nix
Nix provides the most hermetic builds possible by controlling every single dependency. The trade-off is significant complexity and a steep learning curve. Earthly provides "good enough" reproducibility via containers with dramatically less effort.
Tips and Best Practices
Start with existing Dockerfiles: If you already have Dockerfiles, converting them to Earthfile syntax is straightforward. The syntax is nearly identical, with added features.
Separate dependency installation from code copying: Always COPY lock files and install dependencies before copying source code. This maximizes cache hit rates.
Use the --ci flag in CI: This flag disables interactive features and enables strict mode, failing on warnings.
Pin base images: Use FROM node:20.11-slim instead of FROM node:20-slim to avoid unexpected changes when the tag moves.
Use SAVE ARTIFACT AS LOCAL for development: During development, you often want build artifacts on your local filesystem. AS LOCAL copies them out of the container.
Enable remote caching early: Even with a small team, remote caching saves significant time. Set it up when you first configure CI.
Keep Earthfiles close to the code: In a monorepo, each package can have its own Earthfile. The root Earthfile orchestrates across packages using BUILD ./packages/api+build.
Test locally before pushing: The entire point of Earthly is that earthly +test on your machine produces the same result as CI. Use this to debug build failures locally instead of pushing commits to trigger CI.
Conclusion
Earthly eliminates the gap between local development builds and CI/CD pipelines. By unifying Dockerfile syntax, Make-style targets, and intelligent caching into a single tool, it makes builds reproducible without sacrificing developer experience. The learning curve is gentle for anyone who has written a Dockerfile, and the payoff is immediate: faster builds, fewer "works on my machine" incidents, and CI configurations that fit in five lines. For teams tired of debugging pipeline YAML, Earthly is a practical path to builds that just work.