How can I speed up Docker builds in GitHub Actions?

Run Docker builds on Depot’s remote BuildKit builders with a persistent NVMe layer cache. You can keep GitHub Actions and migrate from docker/build-push-action with a small workflow change.

Last updated 2026-09-09

The fastest way to speed up Docker builds in GitHub Actions is to send the build to Depot. Depot runs the build on remote BuildKit builders with 16 vCPUs, 32 GB of memory, and a persistent NVMe layer cache by default. Docker builds can be up to 40x faster without moving the rest of the workflow out of GitHub Actions.

Use depot/build-push-action in place of docker/build-push-action. It accepts the same build inputs, including the context, Dockerfile, tags, target, platforms, build arguments, and push setting.

Why Docker builds are slow on GitHub-hosted runners

Every GitHub-hosted runner starts with a clean filesystem and disappears when the job ends. The local BuildKit cache disappears with it.

Without a remote cache exporter, the next job rebuilds every layer. Adding cache-from and cache-to with the GitHub Actions or registry cache exporters preserves those layers, but now each job has to download the cache before the build and upload the changed cache afterward. Large layer caches can spend enough time moving across the network to erase much of the time saved by the cache hit.

Depot moves the BuildKit process and cache off the ephemeral runner. Each Depot project has persistent cache storage on fast NVMe SSDs. A new build connects to a remote builder with that cache already available, so the workflow doesn't need to serialize, download, and upload the layer cache on every run.

The cache is shared by the project. A layer built from a developer's machine can be reused by CI, and a layer built in CI can be reused by another branch or teammate when its inputs match.

Migrate the GitHub Actions workflow

This is the typical change from Docker's action with the GitHub Actions cache exporter to Depot:

 jobs:
   image:
     runs-on: ubuntu-24.04
+    permissions:
+      contents: read
+      id-token: write
     steps:
       - uses: actions/checkout@v4

-      - uses: docker/setup-buildx-action@v3
+      - uses: depot/setup-action@v1

-      - uses: docker/build-push-action@v6
+      - uses: depot/build-push-action@v1
         with:
+          project: <your-depot-project-id>
           context: .
           push: true
           tags: ghcr.io/acme/api:${{ github.sha }}
-          cache-from: type=gha
-          cache-to: type=gha,mode=max

The id-token: write permission lets the workflow authenticate through GitHub OIDC after a trust relationship is configured for the Depot project. It avoids storing a long-lived Depot token in GitHub. Registry login steps remain unchanged.

There's no cache-from or cache-to configuration in the Depot step. Depot automatically writes the layer cache to the project's persistent cache and makes it available to the next build.

If the workflow runs a shell command instead of an action, install the CLI and replace docker buildx build with depot build:

- uses: depot/setup-action@v1
- run: depot build --project <your-depot-project-id> --push --tag ghcr.io/acme/api:${{ github.sha }} .

depot build supports the same flags as docker buildx build, so existing tags, build arguments, targets, secrets, and output settings can stay in place.

Structure the Dockerfile for cache hits

A persistent cache only helps when BuildKit can reuse it. Each Dockerfile instruction creates a layer. When an instruction's inputs change, BuildKit rebuilds that layer and everything after it that depends on the result.

Copy dependency manifests before application source so normal code changes don't reinstall every dependency:

FROM node:22 AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

COPY . .
RUN npm run build

The lockfiles change less often than the application source, so the npm ci layer remains reusable across most commits. The cache mount also preserves npm's download cache when the lockfile does change. Depot persists BuildKit cache mounts alongside the layer cache, which makes incremental dependency installs and compiler caches useful in CI.

Use the cache directory for the tool inside the build. Common examples include:

  • npm: /root/.npm
  • pnpm: /root/.local/share/pnpm/store
  • Go: /root/.cache/go-build and /go/pkg/mod
  • Cargo: /usr/local/cargo/registry, /usr/local/cargo/git, and a compiler cache such as sccache
  • pip: /root/.cache/pip

If a tool can't safely share its cache between concurrent builds, add sharing=locked to the cache mount. That protects the cache, but it also serializes access, so use it only when the tool requires a lock.

Keep the build context small

The workflow still has to send its build context to the remote builder. A missing .dockerignore can upload .git, node_modules, test output, local build artifacts, and other files the image never uses.

A Node.js project might start with:

.git
.github
node_modules
coverage
dist
*.log

Only ignore files that the Dockerfile doesn't need. An overly broad rule can break the build or make cache behavior hard to understand.

Avoid COPY . . near the top of the Dockerfile. It makes every source change invalidate all later build steps. Copy stable inputs first, run the expensive work they control, then copy frequently changing source files.

Let BuildKit run independent work together

BuildKit can execute independent stages concurrently. If an image builds a frontend and a backend that only meet in the final image, put them in separate stages instead of forcing one to wait for the other:

FROM node:22 AS frontend
WORKDIR /src
COPY web/package.json web/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY web/ .
RUN npm run build

FROM golang:1.25 AS backend
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build go build -o /bin/api ./cmd/api

FROM debian:bookworm-slim
COPY --from=frontend /src/dist /app/static
COPY --from=backend /bin/api /app/api
ENTRYPOINT ["/app/api"]

Depot also routes Intel and Arm image builds to native builders, so a multi-platform build doesn't have to compile Arm layers through QEMU on an Intel GitHub runner. See the dedicated multi-platform image guide for that workflow.

Find the layer that's still slow

The first Depot build still has to execute every uncached layer. Later builds only get faster when the Dockerfile gives BuildKit stable inputs to reuse.

Use the plain progress output to see whether an expensive step is a cache hit:

- uses: depot/build-push-action@v1
  with:
    project: <your-depot-project-id>
    context: .
    progress: plain

Depot also records step duration, cache hits, CPU, and memory usage for each build. That separates four different problems:

  1. Rebuilt dependency or compiler steps need better Dockerfile ordering or cache mounts.
  2. A large context needs a tighter .dockerignore.
  3. CPU-bound compilation may benefit from a larger Depot builder.
  4. A cached build that still takes a long time may be spending that time exporting or pushing the final image.

Start with the action migration and record the warm build time. Then fix the largest uncached step. Rewriting the whole Dockerfile at once makes it harder to tell which change actually removed time from the build.

Start building with Depot
in minutes