Pushing Earthly to the Limit

In every project, setting up CI pipelines in a way that is easy to build, understand, and test locally is a constant struggle. Time to test a tool that promises to solve that: Earthly.

Pushing Earthly to the Limit

Works on My Machine... But Not in CI

Setting up a reliable CI pipeline is often more complicated than it should be. Differences in environments and inconsistencies between local development and CI make workflows difficult to maintain and debug. A common struggle is ensuring that the same checks run everywhere—locally, in CI, and across different developers’ machines—without duplicating logic or encountering subtle execution differences. Too often, something that works on one developer’s machine fails in CI, or behaves differently on another developer’s machine, leading to unnecessary debugging and frustration.

Trying Earthly with Rust

This issue was especially noticeable in a Rust crate of mine, where I needed to run checks using different Rust compiler versions—stable, beta, and nightly. Switching between them locally was cumbersome, and remembering which version was required for each check became frustrating.

I had previously used tools like Just to define common tasks in my projects, but those still ran on the host system, meaning they weren’t isolated or fully reproducible. I wanted a tool that could run these checks in a controlled environment, and that’s when I came across Earthly.

My first Earthfile was quite straightforward, implementing the key checks I typically run in my projects. Below are two of the checks that kickstarted this journey—one using the beta toolchain, the other running on nightly. By running these with Earthly, I no longer had to worry about disrupting my local development environment.

deps-latest:
    FROM +sources

    # Switch to beta toolchain
    RUN rustup default beta

    # Update the dependencies to the latest versions
    RUN cargo update

    # Run tests to ensure the latest versions are compatible
    RUN cargo test --all-features --all-targets --locked

deps-minimal:
    FROM +sources

    # Switch to nightly toolchain
    RUN rustup default nightly

    # Set minimal versions for dependencies
    RUN cargo update -Z direct-minimal-versions

    # Run tests to ensure the minimal versions are compatible
    RUN cargo test --all-features --all-targets --locked

The next step was integration these checks into GitHub Actions.

jobs:
  minimal:
    name: Test minimal versions
    runs-on: ubuntu-latest

    needs: detect-changes
    if: needs.detect-changes.outputs.any_changed == 'true'

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install earthly
        uses: earthly/actions-setup@v1
        with:
          version: 0.8

      - name: Run tests with minimal versions
        run: earthly --ci +deps-minimal

This felt like a massive improvement over my previous workflow. Now, I could run these checks with a single command in an isolated environment, whether locally or in CI, and always get the same result.

Moving All Checks into Earthly

After getting comfortable with Earthly and seeing success with the Rust checks, I decided to migrate all other checks in my projects into the Earthfile. Here’s an example of how I handled JSON and Markdown formatting:

all:
    BUILD +json-format
    BUILD +markdown-format
    BUILD +markdown-lint
    BUILD +rust-deps-latest
    BUILD +rust-deps-minimal
    BUILD +rust-doc
    BUILD +rust-features
    BUILD +rust-format
    BUILD +rust-lint
    BUILD +rust-msrv
    BUILD +rust-test
    BUILD +yaml-format
    BUILD +yaml-lint

prettier-container:
    FROM node:alpine
    WORKDIR /typed-fields

    # Install prettier
    RUN npm install -g prettier

    # Copy the source code into the container
    COPY . .

json-format:
    FROM +prettier-container

    # Check the JSON formatting
    RUN prettier --check **/*.json

markdown-format:
    FROM +prettier-container

    # Check the Markdown formatting
    RUN prettier --check **/*.md

By the end, the Earthfile contained 13 different checks, all of which could be executed with a single command:

earthly +all

These checks covered code formatting, linting for common file types, and various Rust-related tasks. Just like in the first step, I also integrated them into GitHub Actions, making it possible—for the first time—to fully reproduce CI locally. 🥳

Trying Earthly Satellites

While migrating checks to GitHub Actions, I ran into a major performance issue with the larger Rust checks. Previously, I had used various Actions and some caching tricks to cache intermediary Rust build artifacts between runs. Earthly, on the other hand, is blazingly fast locally due to its built-in caching—but on GitHub’s ephemeral infrastructure, it rebuilt everything from scratch every time.

Earthly's solution to this problem are Earthly Satellites, remote builders that persist a cache between builds. Earthly advertises a 2-20x speed improvement, and their free plan includes 6000 minutes, which seemed more than enough for my small project. At first, this sounded like a perfect solution. But in practice, I ran into two major problems that made Earthly Satellites unsuitable for my workflow.

The first issue was the size of the free Earthly runners, which were significantly smaller than GitHub’s free runners. If I remember correctly, Earthly’s xsmall instance—the default for free users—has only 2 vCPUs and 4GB of RAM, whereas GitHub’s free runners offer 4 vCPUs and 16GB of RAM. Rust benefits tremendously from more memory, and the limited resources of the Earthly runners meant I could never match GitHub’s performance.

The free plan does allow an upgrade to small instances, which doubles the CPU and RAM, but that also cuts the free build minutes in half. This tradeoff made it difficult to justify using Earthly Satellites, especially when GitHub’s free plan already provided better performance with no limitations.

(Earthly seems to have removed the instance size details from their website, so the hardware numbers are based on my memory. 🤷‍♂️)

The second issue was Earthly’s GitHub Actions integration, which is only available on paid plans. On the free plan, I could trigger an Earthly check from a GitHub Actions runner and have it execute remotely on an Earthly Satellite. But this setup introduced an extra cost: the GitHub Actions runner had to sit idle and wait for the Earthly Satellite to return a result. Since GitHub’s free plan gives me access to runners at no cost, this effectively meant I was “paying” for both a GitHub Actions runner and an Earthly Satellite just to run the same check.

- name: Run tests with test coverage
  env:
    EARTHLY_TOKEN: ${{ secrets.EARTHLY_TOKEN }}
  run: earthly --ci --org jdno --sat small-amd64-1 +all

I can imagine that Earthly Satellites are a great option for closed-source projects that already have to pay for GitHub Actions runners or for teams that need larger machines than the free plans provide. But for my needs, GitHub’s free open-source CI offering is unbeatable. It gave me faster builds at no cost without requiring any additional setup.

Caching using GitHub Container Registry

Even after switching back to GitHub’s free runners, I still had the problem of long, uncached builds. Earthly’s local caching made things fast on my machine, but every new CI run started from scratch, leading to unnecessary rebuilds.

After digging through Earthly’s documentation—and quite a few GitHub issues—I finally found the feature I needed: remote caching. Interestingly, it wasn’t in the latest documentation but in the docs for a previous version of Earthly.

As it turns out, setting up GitHub’s Container Registry (GHCR) as a remote cache for Earthly is actually quite simple. All it required was granting access to the registry within the CI job and passing the --remote-cache argument:

  rust-test:
    name: Run tests
    runs-on: ubuntu-latest

    permissions:
      packages: write
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Install earthly
        uses: earthly/actions-setup@v1
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          version: 0.8

      - name: Run tests with test coverage
        env:
          EARTHLY_TOKEN: ${{ secrets.EARTHLY_TOKEN }}
        run: |
          earthly \
            --ci \
            --push \
            --remote-cache=ghcr.io/jdno/typed-fields:cache-run-tests \
            +rust-test

The impact was immediate and significant. The first build—without caching—took around 4 minutes to complete. But once the cache was warm, the same build finished in just 33 seconds. Earthly was now correctly detecting which steps actually needed to be re-run, leading to huge efficiency gains.

And this wasn’t just a small-project benefit—larger projects saw an even bigger impact. Build times that previously took 15-20 minutes were reduced to around 2 minutes on average. This was the 2-20x speed improvement that Earthly had promised!

Refactoring using Earthly Functions

As my Earthfile grew, I started looking for ways to reduce duplication and keep the code manageable. Initially, I relied on different targets to share setup steps. For example:

prettier-container:
    FROM node:alpine
    WORKDIR /typed-fields

    # Install prettier
    RUN npm install -g prettier

    # Copy the source code into the container
    COPY . .

json-format:
    FROM +prettier-container

    # Check the JSON formatting
    RUN prettier --check **/*.json

In this example, the +json-format target inherits the setup from the +prettier-container target. This pattern worked well for avoiding repeated setup steps across multiple targets, like +markdown-format or +yaml-format.

However, this approach only works for early-stage setup—it’s not well-suited for sharing steps that happen later in the pipeline. That’s where functions come in.

One great example is the COPY . . directive. Every target needs to copy the source code into the container, but for optimal caching, this should happen after the environment is prepared. Different targets might use different base images, making it impossible to share a single setup target. The solution? Extract it into a function:

COPY_SOURCES:
    FUNCTION

    # Copy the source code into the container
    COPY . .

prettier-container:
    FROM node:alpine
    WORKDIR /typed-fields

    # Install prettier
    RUN npm install -g prettier

    # Copy the source code into the container
    DO +COPY_SOURCES

The COPY_SOURCES function is so simple that I technically don’t need it. However, I’m still experimenting with ways to copy only the relevant files into the container to optimize caching. Having the ability to centralize logic like this makes evolving my CI pipelines much easier.

Breaking Up the Monolithic Earthfile

At this point, my Earthfile had grown to over 200 lines, containing 2 functions and around 20 targets. I tried to keep it concise, but for what was supposed to be a “simple” project, it was becoming increasingly difficult to manage.

One of the biggest challenges I encountered with Earthly was how imports and build contexts interact. Before diving into the problem, let me first share the solution I came up with. Here’s a heavily redacted version of my repository structure:

tree -a -L 3 .
.
├── .earthly
│   ├── markdown
│   │   └── Earthfile
│   ├── prettier
│   │   └── Earthfile
│   ├── rust
│   │   └── Earthfile
│   ├── toml
│   │   └── Earthfile
│   └── yaml
│       └── Earthfile
├── .earthlyignore
└── Earthfile

I ended up creating a .earthly directory, with subdirectories for the different tech stacks I was testing. Each subdirectory contained its own Earthfile, which implemented the necessary checks using functions. The top-level Earthfile became nothing more than a thin wrapper that called these functions:

format-markdown:
    ARG FIX="false"
    DO ./.earthly/prettier+PRETTIER --EXTENSION="md" --FIX="$FIX"

While this approach felt like a bit of a hack, it significantly improved readability and maintainability. The top-level Earthfile became much shorter, making it easier to see which checks were available and what arguments they accepted. Meanwhile, the individual Earthfile allowed me to add more functionality and documentation without worrying about bloating a single file.

Beyond just improving this project, I see this as the first step toward reusing Earthly functions across multiple projects. I’m currently working on extracting these Earthfile into a separate repository, allowing me to share them across all my projects without duplicating code.

Making CI Fully Dynamic

At this point, I could have stopped and called it quits. But I had two more ideas I wanted to test—starting with reducing duplication in my GitHub Actions setup.

Previously, I split my workflows by tech stack to define granular rules for when to run each workflow. Each workflow would first check the Git diff to determine whether any relevant files had changed, and only then would it execute the necessary checks. This approach worked well and saved me both time and money over the years:

$ tree .github/workflows 
.github/workflows
├── json.yml
├── markdown.yml
├── rust.yml
└── yaml.yml

However, Earthly’s caching made this optimization unnecessary. Since Earthly automatically skips unnecessary steps, I realized I could merge all checks into a single ci.yml workflow while removing as much duplication as possible.

I started by renaming my Earthly targets to follow a consistent naming convention:

  • check-* run general validation tasks
  • format-* run code formatters
  • lint-* run linters
  • test-* run test suites

Then, I used earthly ls to list all available targets and jq to filter out only the ones that matched the above prefixes:

$ earthly ls | jq -nR '[inputs | sub("\\+"; "") | select(startswith("check-") or startswith("format-") or startswith("lint-") or startswith("test-"))]' 
[
  "check-docs",
  "check-features",
  "check-latest-deps",
  "check-minimal-deps",
  "check-msrv",
  "format-json",
  "format-markdown",
  "format-rust",
  "format-toml",
  "format-yaml",
  "lint-markdown",
  "lint-rust",
  "lint-yaml",
  "test-rust"
]

On GitHub Actions, I then fed this list into a matrix strategy, allowing me to dynamically execute the relevant checks:

strategy:
  fail-fast: false
  matrix:
    target: ${{ fromJson(needs.targets.outputs.matrix) }}

By using a matrix strategy, I was able to eliminate all workflow duplication and create a single workflow that automatically runs any target I add to the Earthfile—without requiring manual updates.

Earthly in Pre-Commit Hooks

At this point, adopting Earthly felt like a huge improvement over my previous setup. I could now run the same checks both locally and in GitHub Actions, making it trivially simple to reproduce issues and share complex tasks with a team. However, there was still one part of my development workflow that remained outside of Earthly: pre-commit hooks.

I’ve always liked pre-commit as a tool for managing hooks and already had it set up in this project. Fortunately, there’s an integration that makes it easy to run Earthly as a pre-commit hook:

- repo: https://github.com/hongkongkiwi/earthly-precommit
  rev: v0.0.5
  hooks:
    - id: earthly-target
      name: run Earthly checks
      args: ["./Earthfile", "+pre-commit", "--FIX=true"]
      files: .*

In my Earthfile, I created a new +pre-commit target that runs a small subset of checks before every commit:

# These targets get executed by pre-commit before every commit. Some need to be
# run sequentially to avoid overwriting each other's changes.
pre-commit:
    WAIT
        BUILD +prettier
    END
    WAIT
        BUILD +format-toml
    END
    BUILD +format-rust
    BUILD +lint-markdown
    BUILD +lint-rust
    BUILD +lint-yaml

Earthly executes targets in an isolated environment, but it can write changes back to the local filesystem. The --FIX=true flag enables this feature, allowing pre-commit hooks to automatically fix as many issues as possible.

To prevent different formatting checks from overwriting each other’s changes, the +prettier and +format-toml targets are run sequentially using WAIT blocks.

While running pre-commit hooks with Earthly takes a few seconds longer than before, due to the overhead of spinning up a Docker container and copying project files, the tradeoff feels worth it. The consistency gained by running the exact same checks everywhere—locally, in CI, and across different machines—in my opinion far outweighs the slight performance hit.

Summary – Bringing It All Together

Migrating my CI/CD pipeline to Earthly has been a journey of continuous refinement and optimization. What started as an experiment to simplify Rust checks gradually evolved into a fully automated, highly reproducible, and maintainable system for running all my validation tasks—locally and in CI.

Adopting Earthly has dramatically improved my CI/CD workflow. Reproducibility, automation, and caching have made my builds more reliable and faster, while reducing manual work and duplication.

There are still areas to explore—such as refining caching even further or extracting my Earthly functions into a reusable repository—but for now, this setup feels like a massive leap forward from where I started.

However, I also have some issues with Earthly.

My biggest concern is its lack of offline support. Earthly simply does not work without an internet connection. A GitHub issue for offline support was opened in 2021, has received only five comments, and still shows no signs of progress. This doesn’t give me much confidence that offline support is coming anytime soon. (For the record, Dagger, a competing tool, has the same limitation.)

Beyond that, adopting Earthly required a lot of trial and error. While the official documentation is well-written, it mostly covers the “happy path”. When things go wrong, debugging can be difficult, as there aren’t many resources or community discussions available.

Finally, there’s the question of Earthly’s long-term direction. I understand that Earthly, the company, needs to monetize its open-source project. However, the approach of restricting free features to push users toward a paid product doesn’t inspire much confidence. And more recently, Earthly seems to have shifted focus towards compliance rather than CI/CD, which raises concerns about how well it will continue to align with my needs.

Would I use Earthly in other projects? Probably. The benefits outweigh the downsides for now. But I’ll also be keeping my eyes open for a similar tool that offers true offline support—because in a perfect world, a build tool should work anywhere, even without an internet connection.

If you’re curious about the implementation details, feel free to check out the code and browse through the Git history to see how it evolved over time:

GitHub - jdno/typed-fields: Macros to create strongly-typed fields for structs
Macros to create strongly-typed fields for structs - jdno/typed-fields