TL;DR: Dagger is the programmable CI/CD engine — write pipelines in TypeScript, Python, or Go, run them anywhere (locally and in CI), and cache everything in containers. Earthly combines Dockerfiles and Makefiles into Earthfiles — reproducible, containerized builds with native caching that work identically on your laptop and in CI. Depot is the remote build service — faster Docker builds with persistent caching, native multi-platform support, and drop-in replacement for docker build. In 2026: Dagger for code-first CI/CD pipelines, Earthly for reproducible containerized builds, Depot for faster Docker image builds.
Key Takeaways
- Dagger: SDK-based (TypeScript, Python, Go). Write pipelines as code, run locally or in any CI. Container-native execution, automatic caching, module ecosystem. Best for teams wanting portable CI/CD pipelines written in real programming languages
- Earthly: Earthfile syntax (Dockerfile + Makefile). Reproducible builds, shared caching, parallel execution. Best for monorepo builds and teams wanting Makefile-like workflows in containers
- Depot: Remote Docker build service. Persistent build cache, native ARM/x86 builders, BuildKit optimized. Best for teams needing faster Docker builds without infrastructure changes
Dagger — Programmable CI/CD Engine
Dagger lets you write CI/CD pipelines in TypeScript, Python, or Go — run them locally for fast iteration, then deploy to any CI platform.
TypeScript Pipeline
// ci/index.ts — Dagger pipeline in TypeScript
import { dag, Container, Directory, object, func } from "@dagger.io/dagger";
@object()
class CI {
// Lint the codebase
@func()
async lint(source: Directory): Promise<string> {
return dag
.container()
.from("node:20-slim")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "ci"])
.withExec(["npm", "run", "lint"])
.stdout();
}
// Run tests
@func()
async test(source: Directory): Promise<string> {
return dag
.container()
.from("node:20-slim")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "ci"])
.withExec(["npm", "run", "test"])
.stdout();
}
// Build the application
@func()
async build(source: Directory): Promise<Container> {
const deps = dag
.container()
.from("node:20-slim")
.withDirectory("/app", source)
.withWorkdir("/app")
.withExec(["npm", "ci"]);
return deps
.withExec(["npm", "run", "build"])
.withEntrypoint(["node", "dist/server.js"]);
}
// Full CI pipeline — lint, test, build, push
@func()
async ci(source: Directory, registryToken: string): Promise<string> {
// Run lint and test in parallel
const [lintResult, testResult] = await Promise.all([
this.lint(source),
this.test(source),
]);
console.log("Lint:", lintResult);
console.log("Test:", testResult);
// Build and push container image
const image = await this.build(source);
const digest = await image
.withRegistryAuth("ghcr.io", "github-actions", registryToken)
.publish("ghcr.io/org/app:latest");
return digest;
}
}
Services and Databases in Pipelines
@object()
class IntegrationTests {
@func()
async run(source: Directory): Promise<string> {
// Start a PostgreSQL service
const postgres = dag
.container()
.from("postgres:16")
.withEnvVariable("POSTGRES_PASSWORD", "test")
.withEnvVariable("POSTGRES_DB", "testdb")
.withExposedPort(5432)
.asService();
// Start a Redis service
const redis = dag
.container()
.from("redis:7")
.withExposedPort(6379)
.asService();
// Run tests with services attached
return dag
.container()
.from("node:20-slim")
.withDirectory("/app", source)
.withWorkdir("/app")
.withServiceBinding("postgres", postgres)
.withServiceBinding("redis", redis)
.withEnvVariable("DATABASE_URL", "postgres://postgres:test@postgres:5432/testdb")
.withEnvVariable("REDIS_URL", "redis://redis:6379")
.withExec(["npm", "ci"])
.withExec(["npm", "run", "test:integration"])
.stdout();
}
}
Running Locally and in CI
# Install Dagger CLI
curl -fsSL https://dl.dagger.io/dagger/install.sh | sh
# Run pipeline locally (same as CI)
dagger call ci --source=. --registry-token=env:GITHUB_TOKEN
# Run individual functions
dagger call lint --source=.
dagger call test --source=.
dagger call build --source=.
# Use in GitHub Actions
# .github/workflows/ci.yml
# - name: Run CI
# uses: dagger/dagger-for-github@v6
# with:
# verb: call
# args: ci --source=. --registry-token=env:GITHUB_TOKEN
Dagger Modules
// Use community modules from the Daggerverse
import { dag } from "@dagger.io/dagger";
@object()
class Deploy {
@func()
async deployToFly(source: Directory, flyToken: string): Promise<string> {
// Use the Fly.io module from Daggerverse
return dag
.fly()
.withToken(flyToken)
.deploy(source, { app: "my-app" });
}
@func()
async notifySlack(message: string, webhookUrl: string): Promise<void> {
await dag
.container()
.from("curlimages/curl")
.withExec([
"curl", "-X", "POST", webhookUrl,
"-H", "Content-Type: application/json",
"-d", JSON.stringify({ text: message }),
])
.sync();
}
}
Earthly — Reproducible Containerized Builds
Earthly combines the best of Dockerfiles and Makefiles — reproducible, containerized builds with caching that work identically everywhere.
Earthfile
# Earthfile — like Dockerfile + Makefile
VERSION 0.8
FROM node:20-slim
WORKDIR /app
# Shared dependency installation (cached)
deps:
COPY package.json package-lock.json ./
RUN npm ci
SAVE ARTIFACT node_modules
# Lint
lint:
FROM +deps
COPY . .
RUN npm run lint
# Unit tests
test:
FROM +deps
COPY . .
RUN npm run test
# Integration tests with services
integration-test:
FROM +deps
COPY . .
WITH DOCKER --compose docker-compose.test.yml
RUN npm run test:integration
END
# Build
build:
FROM +deps
COPY . .
RUN npm run build
SAVE ARTIFACT dist /dist
# Docker image
docker:
FROM node:20-slim
WORKDIR /app
COPY +deps/node_modules ./node_modules
COPY +build/dist ./dist
COPY package.json .
EXPOSE 3000
ENTRYPOINT ["node", "dist/server.js"]
SAVE IMAGE --push ghcr.io/org/app:latest
# Full CI pipeline
ci:
BUILD +lint
BUILD +test
BUILD +integration-test
BUILD +docker
# Multi-platform builds
docker-multiplatform:
BUILD --platform=linux/amd64 --platform=linux/arm64 +docker
Monorepo Support
# Earthfile — monorepo with shared dependencies
VERSION 0.8
# Root-level shared setup
FROM node:20-slim
WORKDIR /workspace
# Install root dependencies
root-deps:
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/shared/package.json ./packages/shared/
COPY packages/api/package.json ./packages/api/
COPY packages/web/package.json ./packages/web/
RUN npm install -g pnpm && pnpm install --frozen-lockfile
SAVE ARTIFACT node_modules
SAVE ARTIFACT packages/*/node_modules
# Build shared library first
shared-build:
FROM +root-deps
COPY packages/shared ./packages/shared
RUN cd packages/shared && pnpm run build
SAVE ARTIFACT packages/shared/dist
# API service
api-build:
FROM +root-deps
COPY +shared-build/dist ./packages/shared/dist
COPY packages/api ./packages/api
RUN cd packages/api && pnpm run build
SAVE ARTIFACT packages/api/dist
api-test:
FROM +root-deps
COPY +shared-build/dist ./packages/shared/dist
COPY packages/api ./packages/api
RUN cd packages/api && pnpm run test
api-docker:
FROM node:20-slim
COPY +api-build/dist /app/dist
COPY packages/api/package.json /app/
WORKDIR /app
RUN npm install --production
ENTRYPOINT ["node", "dist/server.js"]
SAVE IMAGE --push ghcr.io/org/api:latest
# Web frontend
web-build:
FROM +root-deps
COPY +shared-build/dist ./packages/shared/dist
COPY packages/web ./packages/web
RUN cd packages/web && pnpm run build
SAVE ARTIFACT packages/web/dist
# Build everything in parallel
all:
BUILD +api-test
BUILD +api-docker
BUILD +web-build
Earthly Satellites (Remote Caching)
# Install Earthly
curl -fsSL https://earthly.dev/get-earthly | sh
# Run locally
earthly +ci
# Run specific targets
earthly +lint
earthly +test
earthly +docker
# Remote caching with Earthly Satellites
earthly --ci --remote-cache=ghcr.io/org/earthly-cache +ci
# Use Earthly Satellite (remote runner with persistent cache)
earthly sat launch my-satellite
earthly --sat my-satellite +ci
# GitHub Actions
# - name: Build with Earthly
# run: earthly --ci --push +ci
Secrets and Conditional Builds
VERSION 0.8
# Secrets — never stored in cache layers
docker-with-secrets:
FROM +build
RUN --secret SENTRY_AUTH_TOKEN \
npx @sentry/cli releases new $VERSION && \
npx @sentry/cli releases files $VERSION upload-sourcemaps ./dist
# Conditional targets
deploy:
IF [ "$EARTHLY_CI" = "true" ]
BUILD +docker
RUN --secret FLY_API_TOKEN flyctl deploy
ELSE
RUN echo "Skipping deploy in local environment"
END
# Import from other Earthfiles
IMPORT github.com/earthly/lib/utils:3.0.2 AS utils
check:
DO utils+CHECK_TAG --tag=$TAG
Depot — Faster Docker Builds
Depot provides remote Docker build infrastructure — persistent caching, native multi-platform builders, and drop-in replacement for docker build.
Drop-In Replacement
# Install Depot CLI
curl -L https://depot.dev/install-cli.sh | sh
# Login
depot login
# Build — same syntax as docker build
depot build -t myapp:latest .
# Build and push
depot build -t ghcr.io/org/app:latest --push .
# Multi-platform build (native ARM + x86 builders)
depot build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/org/app:latest \
--push .
# Build with specific project
depot build --project my-project -t myapp:latest .
GitHub Actions Integration
# .github/workflows/build.yml
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: depot/setup-action@v1
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: depot/build-push-action@v1
with:
project: ${{ secrets.DEPOT_PROJECT_ID }}
push: true
tags: ghcr.io/${{ github.repository }}:latest
platforms: linux/amd64,linux/arm64
# Persistent cache — survives across builds
# No cache import/export needed
# Bake — multi-image builds
- uses: depot/bake-action@v1
with:
project: ${{ secrets.DEPOT_PROJECT_ID }}
push: true
files: docker-bake.hcl
Docker Bake (Multi-Image)
// docker-bake.hcl — build multiple images
group "default" {
targets = ["api", "web", "worker"]
}
target "api" {
context = "."
dockerfile = "packages/api/Dockerfile"
tags = ["ghcr.io/org/api:latest"]
platforms = ["linux/amd64", "linux/arm64"]
cache-from = ["type=registry,ref=ghcr.io/org/api:cache"]
cache-to = ["type=registry,ref=ghcr.io/org/api:cache,mode=max"]
}
target "web" {
context = "."
dockerfile = "packages/web/Dockerfile"
tags = ["ghcr.io/org/web:latest"]
platforms = ["linux/amd64", "linux/arm64"]
}
target "worker" {
context = "."
dockerfile = "packages/worker/Dockerfile"
tags = ["ghcr.io/org/worker:latest"]
platforms = ["linux/amd64"]
}
Programmatic API
// Depot API — manage projects and builds programmatically
const DEPOT_TOKEN = process.env.DEPOT_TOKEN!;
// List builds
const builds = await fetch("https://depot.dev/api/v1/builds", {
headers: { Authorization: `Bearer ${DEPOT_TOKEN}` },
});
const data = await builds.json();
for (const build of data.builds) {
console.log(`${build.id}: ${build.status} — ${build.duration}s`);
}
// Get build details
const build = await fetch(`https://depot.dev/api/v1/builds/${buildId}`, {
headers: { Authorization: `Bearer ${DEPOT_TOKEN}` },
});
// Trigger build via API
const newBuild = await fetch("https://depot.dev/api/v1/builds", {
method: "POST",
headers: {
Authorization: `Bearer ${DEPOT_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
project_id: projectId,
dockerfile: "Dockerfile",
tags: ["ghcr.io/org/app:latest"],
platforms: ["linux/amd64", "linux/arm64"],
push: true,
}),
});
Feature Comparison
| Feature | Dagger | Earthly | Depot |
|---|---|---|---|
| Type | Programmable CI engine | Containerized build tool | Remote Docker builder |
| Pipeline Format | TypeScript/Python/Go code | Earthfile (Dockerfile-like) | Dockerfile (standard) |
| Run Locally | ✅ (identical to CI) | ✅ (identical to CI) | ✅ (depot build) |
| Container Native | ✅ (everything in containers) | ✅ (BuildKit-based) | ✅ (BuildKit-based) |
| Caching | Automatic (container layers) | Layer + shared caching | Persistent remote cache |
| Multi-Platform | ✅ (cross-compilation) | ✅ (SAVE IMAGE --platform) | ✅ (native ARM + x86) |
| Services in Pipeline | ✅ (asService) | ✅ (WITH DOCKER) | ❌ (build only) |
| Monorepo Support | ✅ (module composition) | ✅ (target dependencies) | ❌ (Dockerfile only) |
| Secrets Handling | ✅ (function parameters) | ✅ (--secret flag) | ✅ (Docker secrets) |
| Parallel Execution | ✅ (Promise.all) | ✅ (BUILD targets) | ✅ (bake groups) |
| CI Integration | Any CI (GitHub, GitLab, etc.) | Any CI | Any CI |
| Remote Execution | Dagger Cloud | Earthly Satellites | Depot runners |
| Module/Plugin System | Daggerverse modules | IMPORT from Git | ❌ |
| License | Apache 2.0 | BSL → Apache 2.0 | Proprietary |
| Learning Curve | Medium (SDK + containers) | Low (Dockerfile knowledge) | Very low (docker build) |
| Best For | Code-first CI/CD | Reproducible builds | Faster Docker builds |
When to Use Each
Choose Dagger if:
- You want to write CI/CD pipelines in TypeScript, Python, or Go
- Running pipelines locally before pushing to CI is important
- You need services (databases, caches) in your pipeline for integration tests
- The Daggerverse module ecosystem provides reusable pipeline components
- Portable pipelines that work on any CI platform matter
Choose Earthly if:
- Your team knows Dockerfiles and wants a similar syntax for builds
- Monorepo builds with cross-project dependencies are your use case
- Reproducible builds (same result everywhere) are a priority
- Shared caching with Earthly Satellites speeds up team builds
- Makefile-like target dependencies organize complex build graphs
Choose Depot if:
- You just need faster Docker builds with zero config changes
- Multi-platform builds (ARM + x86) without emulation are important
- Persistent build caching across CI runs eliminates cold starts
- Drop-in replacement for
docker buildin existing workflows - You don't need a full CI engine — just faster image builds
Caching Architecture and Cache Invalidation
The caching model is where these three tools diverge most meaningfully in production CI performance. Dagger's caching is container-layer-based — each withExec() step produces a cached layer keyed by the layer content and the command. This means adding a new dependency to package.json invalidates the npm ci cache layer, but all steps before that change are served from cache. Earthly's caching is target-based with finer-grained layer control — the SAVE ARTIFACT directive lets you cache build artifacts independently of the full layer stack, enabling cross-target caching that standard Docker layer caching doesn't support. Depot's caching is persistent across all builds for a project — a cold build on Monday populates the cache, and Tuesday's build for a completely different PR can reuse those layers. This cross-PR persistent cache is Depot's primary value proposition: GitHub Actions and most CI providers start with empty caches and fill them per-run, while Depot's centralized cache means your node_modules installation layer is almost always warm.
Security Considerations for CI Pipelines
CI pipelines are high-value attack targets — a compromised pipeline can exfiltrate secrets, tamper with build artifacts, or insert malicious code into Docker images. All three tools handle secrets differently. Dagger passes secrets as typed Secret objects that are never printed to logs and not included in the layer cache key — a dedicated security primitive. Earthly's --secret flag mounts secrets as environment variables available only during a specific RUN --secret command and never baked into the image layer. Depot as a remote builder handles the standard Docker --secret mount syntax, which also keeps secrets out of image layers. For all three, secrets should be sourced from your CI provider's secret management (GitHub Actions secrets, GitLab CI/CD variables) rather than environment files committed to the repository. The Dagger module approach has an additional security benefit: pipeline logic expressed as TypeScript code can be reviewed in PRs with the same scrutiny as application code, unlike opaque YAML pipelines where injected steps can be harder to audit.
Monorepo Build Optimization
Monorepos with multiple services benefit significantly from proper dependency tracking in CI — rebuilding all services when only one service's code changed is wasteful. Earthly's target dependency graph handles this naturally: api-test depends on shared-build, and Earthly rebuilds only the targets whose inputs have changed. Dagger's module composition provides similar capability through TypeScript function calls — you can check whether source files have changed before executing build steps. Depot accelerates the Docker builds but doesn't provide dependency tracking; you still need a separate tool (turborepo, nx, changesets) to determine which packages changed. For monorepos, the typical production setup combines Earthly or Dagger for dependency-aware build execution with Depot as a remote builder backend, getting both intelligent change detection and persistent cache warm-up. This combination requires configuring Earthly or Dagger to use Depot's BuildKit as the remote execution environment.
Comparing with Traditional GitHub Actions Workflows
The primary alternative to all three tools is GitHub Actions native workflows with Docker layer caching. Traditional GitHub Actions with actions/cache for node_modules and Docker's cache-from/cache-to registry caching can achieve good performance for simple projects. The advantage of Dagger, Earthly, or Depot over raw GitHub Actions is local reproducibility — running the exact same build pipeline on your laptop before pushing to CI eliminates the frustrating cycle of pushing commits to trigger CI runs just to see build failures. This developer experience improvement compounds in large teams: reducing the CI feedback loop from 10 minutes to 30 seconds for a local test run changes how developers work. Depot fits into existing GitHub Actions workflows with minimal changes, making it the lowest adoption barrier for teams wanting faster builds without rewriting pipelines.
Licensing and Commercial Considerations
License and pricing transparency matter for build infrastructure. Dagger is Apache 2.0 for the core CLI and SDK; Dagger Cloud (for distributed execution and caching across team members) is commercial. Earthly's core is BSL (Business Source License) converting to Apache 2.0 after a time delay — self-hosted use for your own company's CI is permitted, but embedding it in a commercial product requires a commercial license. Earthly Satellites (managed remote runners) are a paid service. Depot charges per build minute on their managed builders, with a free tier for open-source projects and individual developers; pricing scales with build parallelism and platform requirements. For open-source projects, Depot and Dagger both have free tiers that cover typical usage patterns. For enterprise teams, all three vendors offer enterprise agreements with SLAs and security reviews. The total cost of each tool should account for reduced CI minutes (from better caching) against any subscription costs.
Methodology
Feature comparison based on Dagger v0.x (TypeScript SDK), Earthly v0.8+, and Depot documentation as of March 2026. Dagger evaluated on SDK expressiveness, service support, and Daggerverse. Earthly evaluated on Earthfile syntax, monorepo support, and caching. Depot evaluated on build speed, multi-platform support, and CI integration. Code examples use official CLIs and APIs.
See also: How to Set Up CI/CD for a JavaScript Monorepo, Expo EAS vs Fastlane vs Bitrise, and Podman vs Docker Desktop vs OrbStack 2026