← All articles
DEVOPS Earthly: Reproducible Builds with Earthfile 2026-02-14 · 8 min read · earthly · ci-cd · docker

Earthly: Reproducible Builds with Earthfile

DevOps 2026-02-14 · 8 min read earthly ci-cd docker builds containers devops reproducible

Earthly: Reproducible Builds with Earthfile

Earthly reproducible builds logo

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.