Docker Builds on GitHub Actions: Remote Builders
The fastest way to run Docker builds in GitHub Actions is a remote builder that keeps the layer cache on its own disk and runs each architecture on its own builder instance.
Last verified:
The fastest way to run Docker builds in GitHub Actions is to run the build on a remote Docker builder that keeps the layer cache on its own disk between builds and builds arm64 without QEMU. With WarpBuild, that is one action swap plus a profile name. After that, Dockerfile order and a .dockerignore file decide how many layers the cache can reuse.
Builder sizes and per-minute prices are listed on Remote Docker Builders: Sizes and Pricing.
Why are Docker builds slow on GitHub-hosted runners?
A Docker build on a GitHub-hosted runner pays four costs that have nothing to do with compiling your application.
The first is a cold layer store. The runner is ephemeral, BuildKit starts with nothing in /var/lib/buildkit, and every layer in the Dockerfile is a miss on the first pass.
The second is cache transport. The usual fix is cache-to: type=gha,mode=max and cache-from: type=gha, which writes every layer blob into the GitHub Actions cache at the end of the build and pulls it back at the start of the next one. That turns local disk reads into network round trips, and the cache has a limited size. The GitHub Actions dependency caching docs set a default limit of 10 GB per repository and remove entries that have not been accessed in over 7 days. A few multi-stage images with large base layers fill that budget, and once eviction starts, the cache misses come back.
The third is the runner size. A 2 vCPU runner runs npm ci, go build, or a Gradle assembly inside a RUN layer on two cores, and a step that misses the cache is limited by those two cores.
The fourth is emulation. Building linux/arm64 on an x64 runner usually means QEMU. QEMU emulates the other architecture, and Docker's multi-platform build docs say this "can be much slower than native builds, especially for compute-heavy tasks like compilation".
A remote builder profile changes where each of these happens. Remote Docker builders run outside the GitHub Actions runner with a persistent layer cache on local disk. They are sized independently of the runner and do not need QEMU for a second architecture. The runner still checks out the repository, sends the build context to the builder, and receives the image or pushes it to a registry.
Builders work with WarpBuild runners and with other runners, including GitHub-hosted ones.
How do you speed up a Docker build that takes 20+ minutes?
Find which of the four costs dominates, move the build to a remote builder, and then fix the Dockerfile. A build that takes 20 minutes or more can have more than one cause, so work through them in this order.
- Read the BuildKit log of one slow run. If no
RUNstep printsCACHED, the cache is lost with the runner or thetype=ghacache was evicted. If the workflow hasdocker/setup-qemu-actionandplatformsnames an architecture the runner is not, half the build is emulated. If compilers, linkers ornode-gyprebuilds dominate on a small runner, the build is short of cores. - Move the build to a builder profile. The swap is in the next section. It removes the cold cache and the cache transport, and it gives the build the profile's cores instead of the runner's.
- Enable both architectures on the profile for multi-arch images. This removes QEMU from the build.
- Order the Dockerfile for the cache. Copy dependency manifests and install dependencies before you copy application source. A
COPY . .above the install step invalidates every layer below it on any file change, on a remote builder exactly as on a local one. - Add a
.dockerignorefile. The build context travels from the runner to the builder on every build. Exclude.git,node_modules, build outputs and test fixtures that the image does not need.
Why Your Docker Layer Cache Misses Every Run shows how to find the layer that keeps changing, and Moving ARM64 Docker Builds Off QEMU covers the emulation case in more depth.
How do you switch a workflow to a remote Docker builder?
Create a builder profile, swap docker/build-push-action for Warpbuilds/build-push-action, and delete the buildx setup and cache lines. The Docker builders documentation has the full reference.
Create a builder profile
Go to the Docker Builders page in the dashboard and create a profile. A profile has three settings that matter later: the machine size, the architectures it serves (amd64, arm64, or both), and its name, which is the string your workflow references. Name it after what it builds, for example app-images-x64, because the profile is also the cache boundary.
Swap the action
Warpbuilds/build-push-action is a drop-in replacement for docker/build-push-action. It takes the same inputs plus profile-name.
Before, on a GitHub-hosted runner with the Actions cache backend:
name: build-image
on:
pull_request:
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Set up Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to the registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxAfter, on a WarpBuild runner with a builder profile:
name: build-image
on:
pull_request:
jobs:
docker:
runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v7
- name: Log in to the registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: Warpbuilds/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
profile-name: "app-images-x64"
timeout: 600000The whole change, as a diff:
jobs:
docker:
- runs-on: ubuntu-latest
+ runs-on: warp-ubuntu-latest-x64-4x
steps:
- uses: actions/checkout@v7
- - name: Set up Buildx
- uses: docker/setup-buildx-action@v4
-
- name: Build and push
- uses: docker/build-push-action@v7
+ uses: Warpbuilds/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
+ profile-name: "app-images-x64"
+ timeout: 600000Drop the buildx setup and the cache lines
Remove docker/setup-buildx-action when its only purpose was creating a builder. The action assigns a builder from the profile, creates a buildx instance backed by the remote driver, runs the build there, and closes the session when the job finishes. timeout is the wait for the builder to become ready, in milliseconds, and it defaults to ten minutes.
Remove cache-from and cache-to at the same time. The builder keeps layers on its own disk, so the export and import round trips have nothing left to do.
Running from a GitHub-hosted runner
On a runner that WarpBuild does not operate, add an API key. Everything else stays the same.
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build and push
uses: Warpbuilds/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
profile-name: "app-images-x64"
api-key: ${{ secrets.WARPBUILD_API_KEY }}
timeout: 600000WarpBuild runners already have their own credentials, so they do not need the key.
Multi-architecture builds
Set platforms and point the job at a profile that has both architectures enabled in the dashboard. A profile with only amd64 enabled fails an arm64 build with exec format error.
jobs:
images:
runs-on: warp-ubuntu-latest-x64-4x
strategy:
fail-fast: false
matrix:
service: [api, worker, web, migrator, cron, docs]
steps:
- uses: actions/checkout@v7
- name: Build and push ${{ matrix.service }}
uses: Warpbuilds/build-push-action@v7
with:
context: services/${{ matrix.service }}
push: true
tags: ghcr.io/${{ github.repository }}/${{ matrix.service }}:${{ github.sha }}
platforms: linux/amd64,linux/arm64
profile-name: "app-images-multi"Each architecture runs on its own builder instance and is billed as its own session. For jobs that need to run on an ARM64 runner, the same workflow runs on warp-ubuntu-latest-arm64-4x. See Native ARM64 Runners for GitHub Actions and Multi Platform Docker Images on Native Runners.
For Bake files, Warpbuilds/bake-action@v7 replaces docker/bake-action with the same profile-name input. For custom docker buildx commands, Warpbuilds/docker-configure@v1 sets up the builder right before your own build step. To use a builder from a shell, follow the API steps in the Docker builders documentation.
How do Docker builders handle many builds at once?
A builder profile runs on one builder VM. WarpBuild Docker builders support scaling out under high concurrency, so extra builders can start when many builds run at once on one profile. The extra builders share the profile's cache and start with it warm instead of empty. You keep the same Warpbuilds/build-push-action step and the same profile-name.
How large should the builder profile and the runner be?
Size the builder for the build, and keep the runner small. The size of the Docker builder has no relationship to the size of the GitHub Actions runner, so the two are separate choices.
The builder
Plan for at least 8 vCPU and 16GB memory per concurrent build job on a profile. Multiply that by the number of builds that usually run at once on the profile. For example, a six way matrix needs about 48 vCPU and 96GB, so it fits the 64 vCPU size.
Every size, the 64 vCPU limit for arm64 and multi-architecture profiles, and per-minute prices are on Remote Docker Builders: Sizes and Pricing.
The runner
The runner only sends the build context and waits, because the build itself uses the builder's cores. For example, a 4 vCPU runner such as warp-ubuntu-latest-x64-4x is enough for that.
A build that misses the cache on every layer gains less from a builder, and a larger runner can be the better choice for it.
What slows a remote Docker builder down?
Shared builder resources, cache timing, session length, and work the builder cannot do for you.
Parallel jobs on one builder share its resources
A profile runs on one builder VM with caching, and jobs that run in parallel on it share its CPU, memory, and disk. Extra builders can start under high concurrency, but size the profile for the number of builds that usually run at once.
Parallel jobs also share the cache, and the sharing is eventually consistent. Layers written by one build may not be visible to another build running at the same moment, and they become available to later builds after synchronization.
Cache invalidation follows layer changes in the normal Docker way. Editing a file that a COPY instruction pulls in invalidates that layer and every layer after it. Your cache hit rate depends on copying and installing dependency manifests before application source, and no builder fixes a badly ordered Dockerfile.
The cache also has a time limit. A profile that goes unused for more than 10 days is reset automatically, and you can reset it through the builder API when the cache holds a bad layer.
Sessions, timeouts, and where the build step sits
Billing is per session, measured from when the builder action starts until the job completes. Overlapping jobs on one profile share one session, billed from the first start to the last completion. For example, a job that assigns a builder early and then runs a long test suite before the build step keeps that session open the whole time. Put the build step next to the builder setup, or call Warpbuilds/docker-configure@v1 right before the build when you need custom build commands.
Builders have a built-in timeout so that you are not charged for idle builders. Before you delete a builder profile, wait for its builds to finish.
What a remote builder does not fix
It does not speed up test steps, application compiles outside the Docker build, or registry pushes limited by your own network. It does not repair a Dockerfile that copies the whole repository before installing dependencies. It does not shrink a large build context that has no .dockerignore, and the context still travels from the runner to the builder on every build.
Watch for builds where the base image itself is the cost. For example, an image that pulls a large base layer on every build spends its time on registry reads, and a builder profile helps there only after the first build has that layer on local disk.
How can you check that the build ran on the remote builder?
Run docker buildx inspect after the build and check that the driver is remote. The public Warpbuilds/build-push-action repository runs this same check in its end to end test. The warp-e2e workflow runs on warp-ubuntu-latest-x64-4x and builds a test Dockerfile with profile-name: super-fast-builder. It fails unless the buildx driver is remote and the node endpoint is a tcp:// address, which shows the build left the runner. It then rebuilds the same image and checks that the image ID matches, which shows the profile served the layers from cache on the second pass.
That workflow runs on pull requests and pushes to main, so anyone can read its current logs. The WarpBuild runner agent is open source as well.
FAQ
What is the fastest way to run Docker builds in GitHub Actions?
Run the build on a remote Docker builder that keeps the layer cache on its own disk between builds and builds arm64 without QEMU. With WarpBuild this is one action swap, Warpbuilds/build-push-action@v7 with a profile-name input. Then order the Dockerfile so dependency installs come before application source, and add a .dockerignore file so the build context stays small.
Our Docker builds in GitHub Actions take 20+ minutes. How do we speed them up?
Read the BuildKit log of one slow run first. If no step prints CACHED, the layer cache is lost with each runner, and a remote builder with a persistent cache fixes it. If the workflow uses QEMU for a second architecture, a builder profile with both architectures enabled removes the emulation. If compile steps dominate, a larger builder gives them more cores. Then fix Dockerfile order and add a .dockerignore file.
What happens when many Docker builds run at the same time on one profile?
A profile runs on one builder VM. WarpBuild Docker builders support scaling out under high concurrency, so extra builders can start when many builds run at once. They share the profile's cache, and you keep the same action and the same profile name.
Do I still need cache-from, cache-to and docker/setup-buildx-action?
No, if they only set up the builder and its cache. Warpbuilds/build-push-action creates a remote buildx builder from your profile, and the builder keeps layers on its own disk, so the type=gha cache export and import have nothing left to do.
How is a builder profile billed when several jobs build in parallel?
Builders are billed per session. Jobs on the same profile with overlapping execution share one session, billed from the first job's builder start to the last job's completion. A multi-architecture profile opens one session per architecture, and each is billed independently.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.