Automation

Migrate from Earthly to Dagger: Complete Guide

Earthly Cloud shut down on July 16, 2025. The open-source CLI entered maintenance mode (critical bug fixes only, no new features), and Earthly Satellites, the remote caching runners, are gone. If you’re still running Earthfiles, now is the time to migrate.

Original content from computingforgeeks.com - post 165365

Dagger is the most natural replacement. Like Earthly, it runs every build step in containers for reproducibility. Unlike Earthly, your pipeline logic is written in a real programming language (Python, Go, or TypeScript) instead of a DSL. This guide maps every Earthly concept to its Dagger equivalent with side-by-side code examples, then walks through converting a real multi-target Earthfile.

Tested April 2026 | Dagger v0.20.3, Python SDK

What Happened to Earthly

Earthly CI (the full platform) shut down in October 2023 after failing to convert adoption into revenue. The company pivoted to Earthly Satellites (remote BuildKit runners with persistent caching), but that also couldn’t sustain the business. On July 16, 2025, Earthly Cloud and Satellites were discontinued. The company pivoted again to Earthly Lunar, an AI guardrails product.

The earthly/earthly GitHub repo is still public but in maintenance mode. No new PRs are accepted. The documentation at docs.earthly.dev remains accessible for now, but there’s no guarantee for how long. If you’re depending on Earthly features like Satellites or remote caching, those are already gone.

Why Dagger (and Not Something Else)

Several tools overlap with what Earthly did, but Dagger is the closest fit for three reasons:

  • Container-native execution: like Earthly, every Dagger function runs in its own container. Nothing leaks from the host. Builds are reproducible across environments
  • Content-addressed caching: Dagger’s engine caches by content hash, similar to how Earthly cached build layers. Unchanged steps are skipped automatically
  • CI portability: Earthly worked with any CI system. Dagger does too. Same pipeline code runs locally, in GitHub Actions, GitLab CI, and Jenkins

Dagger also offers a free one-year Cloud Team tier for former Earthly customers. Contact [email protected] with proof of Earthly usage to get the promo code.

Concept Mapping: Earthly to Dagger

Every Earthly concept has a Dagger equivalent. The syntax is different (DSL vs. programming language), but the mental model transfers directly.

EarthlyDagger (Python SDK)Notes
FROM imagedag.container().from_("image")Start from a base image
RUN command.with_exec(["cmd", "arg"])Run a command. Uses exec-style args, not shell strings
COPY +target/file ..with_file("/dest", source.file("path"))Copy files between stages
WORKDIR /path.with_workdir("/path")Set working directory
ENV KEY=value.with_env_variable("KEY", "value")Set environment variables
ARG name=defaultFunction parameter with defaultdef build(self, tag: str = "latest")
SAVE ARTIFACT ./appReturn dagger.FileExport with .export("/local/path")
SAVE IMAGE tag.publish("registry/image:tag")Push to registry
BUILD +targetCall another functionawait self.build(source)
Earthfile targetsDagger functionsEach @function = one build target
WITH DOCKERDagger Servicescontainer.as_service() + .with_service_binding()
Layer cachingAutomatic + with_mounted_cache()Content-addressed, no manual keys

Example Migration: Multi-Target Earthfile

Here’s a typical Earthfile for a Go application with separate build, test, and deploy targets:

Original Earthfile

VERSION 0.8

FROM golang:1.22-alpine
WORKDIR /app

deps:
    COPY go.mod go.sum .
    RUN go mod download
    SAVE ARTIFACT go.mod AS LOCAL go.mod
    SAVE ARTIFACT go.sum AS LOCAL go.sum

build:
    FROM +deps
    COPY . .
    RUN go build -o /usr/local/bin/myapp .
    SAVE ARTIFACT /usr/local/bin/myapp

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

lint:
    FROM +deps
    COPY . .
    RUN go vet ./...

docker:
    FROM alpine:latest
    COPY +build/myapp /usr/local/bin/myapp
    ENTRYPOINT ["/usr/local/bin/myapp"]
    EXPOSE 8080
    ARG tag=latest
    SAVE IMAGE myregistry/myapp:${tag}

all:
    BUILD +test
    BUILD +lint
    BUILD +docker

Migrated to Dagger (Python SDK)

First, set up the Dagger module in your project (see the installation guide if Dagger is not yet installed):

dagger init --name=myapp --sdk=python

Open the pipeline file and translate each Earthfile target into a Dagger function:

vi .dagger/src/myapp/main.py

Replace the contents with:

import dagger
from dagger import dag, function, object_type


@object_type
class Myapp:
    @function
    def deps(self, source: dagger.Directory) -> dagger.Container:
        """Install Go dependencies (equivalent to +deps target)"""
        return (
            dag.container()
            .from_("golang:1.22-alpine")
            .with_workdir("/app")
            .with_file("/app/go.mod", source.file("go.mod"))
            .with_file("/app/go.sum", source.file("go.sum"))
            .with_exec(["go", "mod", "download"])
        )

    @function
    async def build(self, source: dagger.Directory) -> dagger.File:
        """Build the Go binary (equivalent to +build target)"""
        return (
            self.deps(source)
            .with_directory("/app", source)
            .with_exec(["go", "build", "-o", "/usr/local/bin/myapp", "."])
            .file("/usr/local/bin/myapp")
        )

    @function
    async def test(self, source: dagger.Directory) -> str:
        """Run tests (equivalent to +test target)"""
        return await (
            self.deps(source)
            .with_directory("/app", source)
            .with_exec(["go", "test", "-v", "./..."])
            .stdout()
        )

    @function
    async def lint(self, source: dagger.Directory) -> str:
        """Run linter (equivalent to +lint target)"""
        return await (
            self.deps(source)
            .with_directory("/app", source)
            .with_exec(["go", "vet", "./..."])
            .stdout()
        )

    @function
    async def publish(self, source: dagger.Directory, tag: str = "latest") -> str:
        """Build and push container image (equivalent to +docker target)"""
        binary = await self.build(source)
        return await (
            dag.container()
            .from_("alpine:latest")
            .with_file("/usr/local/bin/myapp", binary)
            .with_entrypoint(["/usr/local/bin/myapp"])
            .with_exposed_port(8080)
            .publish(f"myregistry/myapp:{tag}")
        )

    @function
    async def all(self, source: dagger.Directory) -> str:
        """Run tests, lint, and build (equivalent to +all target)"""
        await self.test(source)
        await self.lint(source)
        return await self.publish(source)

What Changed

  • Targets became functions: each Earthfile target (+deps, +build, +test) maps to a @function method
  • ARG became function parameters: ARG tag=latest becomes tag: str = "latest" with type safety
  • COPY between targets became function calls: instead of COPY +build/myapp, the publish function calls self.build(source) and gets the file object directly
  • SAVE IMAGE became publish(): .publish("registry/image:tag") pushes to a registry and returns the image reference with digest
  • BUILD +target became function calls: the all() function calls other functions sequentially. The Dagger Engine automatically parallels independent operations within each function

Running the Migrated Pipeline

Where you previously ran earthly +test, now run:

dagger call test --source=.

Where you ran earthly +docker --tag=v2.0:

dagger call publish --source=. --tag=v2.0

The full pipeline (earthly +all):

dagger call all --source=.

Updating CI Configuration

Your CI files need a one-line change. Replace earthly +target with dagger call function --source=..

For GitHub Actions, swap the Earthly action for Dagger’s:

# Before (Earthly)
- uses: earthly/actions-setup@v1
- run: earthly +test

# After (Dagger)
- uses: dagger/dagger-for-github@v8
  with:
    verb: call
    args: test --source=.

For GitLab CI:

# Before (Earthly)
script:
  - earthly +test

# After (Dagger)
before_script:
  - curl -fsSL https://dl.dagger.io/dagger/install.sh | BIN_DIR=/usr/local/bin sh
script:
  - dagger call test --source=.

For a complete GitHub Actions integration guide with secrets and caching, see our dedicated article.

Replacing Earthly Satellites (Remote Caching)

Earthly Satellites were remote BuildKit runners with persistent caching. They kept the cache warm between CI runs, which made builds significantly faster on ephemeral runners. That service is gone.

Dagger Cloud offers similar functionality. The $50/mo Team plan distributes your build cache across 26 global regions. Ephemeral CI runners (like GitHub Actions’ standard runners) get cache hits because the cache lives in Dagger Cloud, not on the runner. The free tier provides pipeline visualization and traces for a single user.

For self-hosted runners, Dagger’s cache persists automatically in Docker volumes between jobs. No cloud service needed.

What You Gain (and Lose) in the Migration

AreaEarthlyDagger
Pipeline languageEarthfile DSL (Dockerfile-like)Python, Go, TypeScript (real code)
Learning curveLow (if you know Docker)Medium (requires SDK knowledge)
Type safetyNone (string-based)Full type checking with editor support
Local debuggingRun locally with earthly +targetRun locally with dagger call, plus interactive breakpoints
Remote cachingSatellites (discontinued)Dagger Cloud (active, $50/mo)
IDE supportSyntax highlighting onlyFull autocompletion, refactoring, inline docs
Module ecosystemCross-repo importsDaggerverse (1,500+ modules)
Active developmentMaintenance modeVery active (v0.20.3, March 2026)

The biggest loss is Earthly’s low barrier to entry. If your team could write Dockerfiles, they could write Earthfiles in minutes. Dagger requires learning a programming SDK. The biggest gain is real programming language support with full IDE tooling, type safety, and the ability to write complex logic (conditionals, loops, error handling) without wrestling a DSL.

Migration Checklist

  • Install Dagger CLI and Docker on your development machine
  • Run dagger init --sdk=python (or go/typescript) in your project
  • Map each Earthfile target to a Dagger function using the concept table above
  • Test locally with dagger call <function> --source=.
  • Update CI configuration to call dagger call instead of earthly
  • Set up Dagger Cloud for remote caching (replaces Satellites)
  • Remove the Earthfile and .earthly/ directory once Dagger pipeline is verified
  • Contact [email protected] for the free one-year Cloud Team tier (Earthly customer offer)

Start with your simplest Earthfile target, verify it works in Dagger, then migrate the rest incrementally. There’s no need to convert everything at once.

Related Articles

Automation Install and Use KubeSphere on existing Kubernetes cluster Automation Dagger vs GitHub Actions: When to Use What Openshift Run MicroShift on RHEL 10 / Fedora for Edge OpenShift Cloud Install Lightweight Openshift for Edge Computing using Microshift

Leave a Comment

Press ESC to close