Why Is My Docker Build Slow in GitHub Actions?
Usually three causes: no persistent layer cache, QEMU emulation for a second architecture, or an undersized runner. Move the build to a remote builder.
Last verified:
Answer
A Docker build in GitHub Actions is slow for one of three reasons almost every time: the job starts on a fresh machine with an empty layer store and rebuilds everything, a second architecture is being emulated with QEMU instead of built natively, or the build sits on a runner with fewer cores than its compile steps can use. The repair for the first two is the same move, which is to run the build on a remote Docker builder that keeps a persistent layer cache on its own disk and builds each architecture on its own machine (Docker builders documentation, checked on 2026-08-13).
Read the BuildKit output of one slow run before changing anything. Three signatures separate the three causes.
- Cold cache. Every
RUNline prints real work and none printCACHED. A GitHub Actions runner is destroyed at the end of the job, so the layer store goes with it, and the next run starts from nothing unless layers are exported somewhere and pulled back. - Emulation. The workflow contains
docker/setup-qemu-actionand theplatformsvalue names an architecture the runner is not. The non-native half of the graph runs through a user mode interpreter that translates instructions before the host CPU executes them. - Undersized runner. The steps that dominate the log are compilers, linkers,
node-gyprebuilds, orpipbuilds from source, and the job holds a 2 vCPU label. Those steps scale with cores, and the runner is the ceiling.
The configuration change is small. Point the build at a builder profile, drop the local buildx and QEMU setup steps, and let the profile carry both architectures:
jobs:
image:
- runs-on: warp-ubuntu-latest-x64-8x
+ runs-on: warp-ubuntu-latest-x64-2x
steps:
- uses: actions/checkout@v4
- - uses: docker/setup-qemu-action@v3
- - uses: docker/setup-buildx-action@v3
-
- name: Build and push
- uses: docker/build-push-action@v6
+ uses: Warpbuilds/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/acme/api:${{ github.sha }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
+ profile-name: "api-builder"The full workflow after the change:
name: image
on:
push:
branches: [main]
jobs:
image:
runs-on: warp-ubuntu-latest-x64-2x
steps:
- uses: actions/checkout@v4
- name: Build and push
uses: Warpbuilds/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/acme/api:${{ github.sha }}
profile-name: "api-builder"
api-key: ${{ secrets.WARPBUILD_API_KEY }}
timeout: 600000Warpbuilds/build-push-action is a drop-in replacement for the standard build and push action, so the context, push, tags, and platforms inputs keep their meaning. The api-key input is only needed when the driving job runs somewhere other than a WarpBuild runner. cache-from and cache-to are gone because a cached builder holds the layers locally and reuses them on the next build without an export and import round trip.
Per minute rates for runners and builders are on the pricing page.
Detail
Cause one: the layer cache does not survive the job
A GitHub Actions job is an ephemeral machine. BuildKit writes its layer cache to that machine's disk, the machine is deleted when the job ends, and the next run of the same workflow gets a clean disk. Every dependency install, every compile, and every asset build runs again.
The usual workaround exports the cache to a registry or to the Actions cache service and imports it at the start of the next run. That converts CPU time into network time. A 4GB layer set has to travel out at the end of every build and back in at the start of the next one, and the import happens before the first RUN step can be skipped.
A remote Docker builder removes the transfer. A builder profile maps to one dedicated builder virtual machine that keeps its layer cache on local disk between builds (Docker builders documentation). The second build against that profile reads cached layers from the disk they were written to.
Two behaviors are worth knowing before you rely on it. The cache is shared across jobs on the profile but eventually consistent, so layers produced by one in-flight build may not be visible to another build running at the same moment, and they become available to later builds after synchronization. A profile that goes unused for 10 days has its cache reset automatically, so a repository that builds once a fortnight starts cold each time.
If the cache is warm and the build still repeats work, the problem is Dockerfile ordering rather than cache storage. A COPY . . placed above the dependency install invalidates every layer below it on any file change. The Docker cache invalidation guide walks through finding the layer that keeps changing.
Cause two: the second architecture runs under emulation
docker/setup-qemu-action registers binfmt_misc handlers so the kernel routes foreign binaries to a user mode interpreter. With platforms: linux/amd64,linux/arm64 on an x64 runner, the amd64 half runs natively and the arm64 half runs every instruction through that interpreter, with each syscall marshalled across two ABIs.
The cost lands unevenly. FROM pulls, COPY, and ADD are executed by BuildKit on the host and are unaffected. RUN steps that burn CPU inside the target root filesystem carry the whole penalty, which is why a Go or Rust compile or a native extension build is where an emulated multi-arch build spends its time.
Splitting the work removes the interpreter. Enable both architectures on the builder profile and each architecture builds on a separate builder instance on its own native hardware. The QEMU and native ARM64 guide covers the diagnosis in more depth, including why exec format error persists after the emulation is removed.
Two constraints apply to that split. arm64 and multi-arch builder profiles cap at 64 vCPU, so the 96 vCPU and 192 vCPU sizes are available only on amd64-only profiles. A multi-arch build also opens one session per architecture, and each session is billed independently.
Cause three: the runner is smaller than the build
The Linux x64 rate doubles at each size step, so cores and cost move together (cloud runners documentation, checked on 2026-08-13):
| Runner label | vCPU | Memory | Per minute |
|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8GB | $0.004 |
warp-ubuntu-latest-x64-4x | 4 | 16GB | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32GB | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64GB | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 128GB | $0.064 |
Once the build moves to a builder, the runner size stops mattering for build speed. The builder size is independent of the runner size, so the driving job can hold a 2 vCPU label while the build executes on a much larger machine. macOS runners are the exception to all of this, because they cannot run Docker at all and have to hand container work to a Linux job or a builder.
Builder profile sizes and rates
Seven profile sizes are available, from the Docker builders documentation, checked on 2026-08-13:
| Profile | vCPU | Memory | Disk | Per minute | Architectures |
|---|---|---|---|---|---|
| 16 vCPU | 16 | 32GB | 100GB | $0.06 | amd64, arm64, multi |
| 32 vCPU | 32 | 64GB | 200GB | $0.12 | amd64, arm64, multi |
| 64 vCPU | 64 | 128GB | 200GB | $0.24 | amd64, arm64, multi |
| 96 vCPU | 96 | 192GB | 600GB | $0.36 | amd64 only |
| 96 vCPU, large disk | 96 | 192GB | 2TB | $0.52 | amd64 only |
| 192 vCPU | 192 | 384GB | 600GB | $0.72 | amd64 only |
| 192 vCPU, large disk | 192 | 384GB | 2TB | $0.88 | amd64 only |
Disk is the input people underestimate. A profile holds a persistent layer cache, and a repository with several large base images and a wide build matrix fills 100GB faster than expected. The recommended floor is roughly 8 vCPU and 16GB of memory per concurrent build job on a profile, so a profile serving six parallel jobs belongs in the 64 vCPU row rather than the 16 vCPU row. The full catalog with per size notes is on the remote Docker builder page.
How a builder session is billed, and how it stacks with the runner
Builders are billed per session. A session runs from the moment the builder action starts until the job completes, and several concurrent jobs sharing one profile share one session, measured from the first job start to the last job completion. A fan-out of eight matrix jobs against one profile produces one billable stretch of time rather than eight.
The runner and the builder are two separate resources and both are billed. The builder minute costs more than the runner minute it replaces, and it earns its place by removing minutes from the build.
Sessions also end when the work does, so invoke the builder action immediately before the build step. Builders have a built in timeout so idle time is not charged forever, but a builder assigned at the top of a long job and used at the bottom is paid for throughout.
When the builder pays for itself
Take a driving job on warp-ubuntu-latest-x64-2x at $0.004 per minute and a 16 vCPU builder profile at $0.06 per minute, with the job waiting on the build. The combined rate while building is $0.064 per minute. Compare that against whatever the build costs today as a single job:
| Build runs today on | Rate today | Combined rate with a 16 vCPU builder | Build time that keeps the bill flat |
|---|---|---|---|
warp-ubuntu-latest-x64-2x | $0.004 | $0.064 | one sixteenth of today |
warp-ubuntu-latest-x64-4x | $0.008 | $0.064 | one eighth of today |
warp-ubuntu-latest-x64-8x | $0.016 | $0.064 | one quarter of today |
warp-ubuntu-latest-x64-16x | $0.032 | $0.064 | one half of today |
warp-ubuntu-latest-x64-32x | $0.064 | $0.064 | the same as today |
All rates come from the cloud runners documentation and the Docker builders documentation, checked on 2026-08-13.
A worked month makes the shape concrete. Say the repository pushes 600 image builds a month and the build takes 9 minutes today on warp-ubuntu-latest-x64-8x, which is 600 x 9 x $0.016 = $86.40.
| Scenario | Runner line | Builder line | Monthly total |
|---|---|---|---|
| Today, 9 minutes on 8 vCPU | 600 x 9 x $0.016 = $86.40 | none | $86.40 |
| Builder, build lands at 3 minutes | 600 x 3 x $0.004 = $7.20 | 600 x 3 x $0.06 = $108.00 | $115.20 |
| Builder, build lands at 2 minutes | 600 x 2 x $0.004 = $4.80 | 600 x 2 x $0.06 = $72.00 | $76.80 |
At three minutes the bill rises by $28.80 a month and the pipeline returns 6 minutes per build, which is 3,600 minutes of wall clock a month that people and merge queues were waiting on. At two minutes the bill falls by $9.60 a month as well. Which row you land on depends on how much of the Dockerfile is dependency installation that a warm cache skips, so measure a warm build before committing to a profile size.
Doubling the profile to 32 vCPU at $0.12 per minute doubles the builder line, so the break-even times halve. Larger profiles suit builds with wide parallelism inside a single Dockerfile and multi-stage graphs with independent branches.
Remote Docker builders sit alongside snapshot runners, CI observability, an MCP server, and the Action Debugger in the WarpBuild product surface, and the Docker builds on GitHub Actions page covers the full configuration end to end.
Related Questions
My build has a cache backend configured and it still runs cold. Why?
A cache backend only helps when the cache key still matches. A COPY of the whole repository placed above the dependency install, a lockfile that changes on every run, or an ARG that carries the commit SHA invalidates every layer below it, so the cache is present and unusable. Fix the Dockerfile ordering before changing where the cache lives.
Is a multi-arch build billed as one builder session or two?
Two. Each architecture runs on a separate builder instance, so a multi-arch profile opens one session per architecture and the two are billed independently. Both sessions stay alive until the post-action steps finish. Note also that arm64 and multi-arch builder profiles cap at 64 vCPU.
Which builder profile size should I start with?
The 16 vCPU, 32GB, 100GB disk profile at $0.06 per minute covers most single-image builds, and it supports amd64, arm64, and multi-arch. WarpBuild recommends roughly 8 vCPU and 16GB of memory per concurrent build job on a profile, so size up when several jobs share one profile.
Do I pay for the GitHub Actions runner and the Docker builder at the same time?
Yes. The runner and the builder are two separate resources on two separate lines. The builder size is chosen independently of the runner size, so a 2 vCPU runner can drive a 192 vCPU builder profile, and both minutes are billed.
Price your own build against the break-even table above on the pricing page, then create a builder profile and change one action reference.
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.