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. The BuildKit log shows which one, and a remote builder keeps the layer cache between builds.

Last verified:

A Docker build in GitHub Actions is usually slow for one of three reasons. The job starts on a fresh machine with an empty layer store and rebuilds everything, a second architecture is emulated with QEMU, or the build runs on a runner with fewer cores than its compile steps can use. A remote Docker builder addresses all three. It keeps the layer cache on its own disk, is sized separately from the runner, and builds arm64 without QEMU.

How do you tell which cause is slowing your build?

Open the BuildKit log of one slow run before you change anything. Each cause leaves its own sign.

  1. Cold cache. The log never says CACHED, and every layer does real work.
  2. Emulation. A QEMU setup step is in the workflow, and the target platform is not the runner's architecture.
  3. Undersized runner. Compile and link steps take most of the time, and the job runs on a 2 vCPU label.

The fix for each cause is on Docker Builds on GitHub Actions: Remote Builders. Builder sizes and prices are on Remote Docker Builders: Sizes and Pricing.

Why does the layer cache not survive the job?

The runner is deleted when the job ends, and BuildKit's layer cache is on the runner's disk. The next run of the same workflow gets a clean disk, so 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 large 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 a dedicated builder virtual machine that keeps its layer cache on local disk between builds. The second build against that profile reads cached layers from the disk they were written to.

The cache is shared across jobs on the profile but eventually consistent, so layers from one in-flight build may not be visible to another build running at the same moment. 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 less often than that starts cold each time.

If the cache is warm and the build still repeats work, the problem is Dockerfile order, not cache storage. A COPY . . placed above the dependency install invalidates every layer below it on any file change. Why Your Docker Layer Cache Misses Every Run shows how to find the layer that keeps changing.

Why is the second architecture so slow under QEMU?

QEMU emulates the other architecture in software. Docker's multi-platform build docs say emulation "can be much slower than native builds, especially for compute-heavy tasks like compilation". With platforms: linux/amd64,linux/arm64 on an x64 runner, docker/setup-qemu-action makes the arm64 part of the build run under emulation.

With a builder profile that has both architectures enabled, each architecture runs on its own builder instance. Moving ARM64 Docker Builds Off QEMU covers the diagnosis in more depth, including why exec format error can appear after the emulation is removed.

When is the runner too small for the build?

The runner is too small when compile steps take most of the log and the job uses a small label. Runner sizes and per-minute prices are in the cloud runners documentation.

With a remote builder, the build runs on the builder, and the runner still sends the build context. The builder size is independent of the runner size, so the job can use a small runner label while the build runs on a larger builder profile. macOS runners cannot run Docker at all, so container work has to run from a Linux job.

Which builder size should you pick, and what does it cost?

Start from roughly 8 vCPU and 16GB memory per concurrent build job on a profile. Builders are billed per session, and concurrent jobs on one profile share one session. The runner is billed separately from the builder. Every profile size, its per-minute price and a worked billing example are on Remote Docker Builders: Sizes and Pricing.

FAQ

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. The cache is present but cannot be used. Fix the Dockerfile order before changing where the cache lives.

How do I tell which of the three causes is slowing my build?

Check one slow run's BuildKit log for three signs. No CACHED lines means a cold cache. A QEMU setup step with a foreign target platform means emulation. Compile steps that fill most of the log on a 2 vCPU runner mean the runner is too small.

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 and both are billed. The builder size is chosen independently of the runner size, so a small runner can drive a large builder profile.

Can one builder profile build both amd64 and arm64?

Yes. Enable both architectures on the builder profile. Each architecture then runs on its own builder instance. A profile without arm64 enabled fails an arm64 build with exec format error.

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.