How Do I Reduce Docker Image Pull Time in GitHub Actions?
Shrink the image, keep the registry in the same Region as the runners, and stop re-pulling layers a remote builder or a snapshot runner already holds on disk.
Last verified:
Answer
Three changes reduce Docker image pull time in GitHub Actions: shrink what you pull, keep the registry in the same Region as the runners, and stop re-pulling what a builder or a snapshot already holds. The first two are edits to a Dockerfile and to where the runners sit, and the third needs a runner platform that keeps layer state between jobs.
Pull time on an ephemeral runner is arithmetic. The runner asks the registry for the layers its local image store lacks, moves the compressed bytes over the network, then decompresses and extracts them onto disk. A GitHub-hosted runner starts every job with an empty image store, so it lacks every layer, every time. That is why the same base image gets pulled on every pull request in a busy repository, and why the fix is either fewer bytes or fewer pulls.
The three levers, in the order most teams should work them:
- Fewer bytes. Multi-stage builds, a smaller runtime base, a real
.dockerignore, and layer ordering that puts stable layers first. The floor is the compressed size of the layers the runner does not already hold, so this lever never reaches zero. - Shorter path. Pulling from a registry in the same Region as the runner keeps the bytes inside the provider network. Pulling across the internet path adds latency to the pull and a per-gigabyte line to the cloud bill.
- Fewer pulls. A remote Docker builder keeps layers on its own disk between builds. A snapshot runner boots the job from a disk image that already contains the pulled images. Both remove the pull rather than making it faster.
Detail
Lever one: shrink what you pull
Start by measuring. Add a timed docker pull step to one job and record the wall clock and the compressed size the registry reports. Every estimate below multiplies against that measured number, so guessing at it makes the rest of the arithmetic worthless.
The usual reductions are structural. Move build toolchains into a builder stage that never ships. Pick a runtime base that carries the interpreter and nothing else. Write a .dockerignore that keeps .git, node_modules, and test fixtures out of the build context. Order the Dockerfile so dependency installation sits above application code, which keeps the large dependency layer stable across commits and lets a warm cache skip it.
The limit is worth stating plainly. Slimming changes the numerator of the pull, and it does nothing about the fact that an ephemeral runner starts empty. A team that halves its image size still pays that reduced pull on every single job.
Lever two: keep the registry and the runners in the same place
This lever changes both the pull duration and the invoice. AWS states that data transferred between Amazon ECR and other services in the same Region, including Amazon EC2, is free of charge at $0.00 per GB (Amazon ECR pricing, checked on 2026-08-13). The same page states that data transferred between Amazon ECR and services in different Regions is charged at internet data transfer rates, and its own worked example uses $0.09 per GB.
WarpBuild Cloud runners run in US and EU regions. BYOC places the runner fleet inside your own cloud account and Region, which is the placement that takes the registry pull onto the same-Region path by the provider's own pricing.
Here is what the placement is worth on a deploy pipeline running 40 jobs per weekday across 22 weekdays, which is 880 jobs per month. Each row is one image size, and the two cost columns are the same gigabytes priced on the two paths.
| Compressed image pulled per job | Jobs per month | GB pulled per month | Internet path at $0.09 per GB | Same-Region path at $0.00 per GB |
|---|---|---|---|---|
| 0.4 GB | 880 | 352 | $31.68 | $0.00 |
| 1.0 GB | 880 | 880 | $79.20 | $0.00 |
| 1.5 GB | 880 | 1,320 | $118.80 | $0.00 |
| 3.0 GB | 880 | 2,640 | $237.60 | $0.00 |
Read the table down a column to see what slimming buys, and across a row to see what placement buys. Both rates come from the Amazon ECR pricing page, checked on 2026-08-13. The full model, including the managed NAT residue that appears when runners sit in private subnets, is in the guide to what ECR pulls cost in GitHub Actions.
Lever three, option one: a remote Docker builder
A builder profile corresponds to one dedicated builder virtual machine with a persistent layer cache on its own local disk. Base image layers and intermediate layers stay on that disk between builds, so the second build reuses them in place with no export or restore step.
Four limits shape whether this fits:
- The builder cache has a TTL of 10 days. A profile that goes unused for more than 10 days is reset automatically, and the next build on it starts cold.
- Billing is per session. A session runs from when the builder action starts until the job completes, and multiple concurrent jobs on the same profile share one session, billed from the first job's start to the last job's completion.
- Multi-arch splits into sessions. Each architecture runs on a separate builder instance, so a multi-arch profile produces one session per architecture, billed independently.
- arm64 and multi-arch profiles cap at 64 vCPU. The 96 vCPU and 192 vCPU sizes exist only for amd64-only profiles.
Builder sizes and their published rates, from the Docker builders documentation and the pricing page, checked on 2026-08-13:
| Builder size | Disk | Price per minute | Architectures |
|---|---|---|---|
| 16 vCPU, 32 GB RAM | 100GB | $0.06 | amd64, arm64, multi |
| 32 vCPU, 64 GB RAM | 200GB | $0.12 | amd64, arm64, multi |
| 64 vCPU, 128 GB RAM | 200GB | $0.24 | amd64, arm64, multi |
| 96 vCPU, 192 GB RAM | 600GB | $0.36 | amd64 only |
| 96 vCPU, 192 GB RAM | 2TB | $0.52 | amd64 only |
| 192 vCPU, 384 GB RAM | 600GB | $0.72 | amd64 only |
| 192 vCPU, 384 GB RAM | 2TB | $0.88 | amd64 only |
Scope this option correctly. A builder removes the base image pull that happens during a build, and it leaves the pull that a deploy job performs at run time untouched, because that pull happens on the job runner against your registry. Builders answer the build half of the question. The next option answers the run half.
Lever three, option two: snapshot runners
A snapshot runner boots the job from a saved image of an earlier run's disk, so images already pulled into the local Docker store at capture time are present when the first step executes. Requesting one is a label change: append snapshot.key=<alias> to the runner label, and capture the disk with the WarpBuilds/snapshot-save action after the images are in place.
The limits are firm. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners, per the snapshot runners documentation. BYOC runners, Windows runners, and macOS runners are not supported, and the labels on those types are silently ignored, so a typo in the runner label yields a job that runs to completion at cold-boot speed with nothing in the log to flag it. Snapshots are deleted after 15 days. /tmp does not persist, since booting from a snapshot is a boot. Booting from a snapshot takes 45 to 60 seconds, which is the budget the skipped pull has to beat.
Costs sit on top of the runner rate, from the pricing page, checked on 2026-08-13: snapshot restore at $0.04 per job and snapshot storage at $0.025 per hour per snapshot. One alias held for a 720-hour month is $18.00 in storage.
A workflow that uses both
The default branch boots clean, builds on a remote builder, pulls the images the integration step needs, cleans credentials, and republishes the snapshot. Pull requests boot from that snapshot and skip the pull entirely.
name: build-and-verify
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: >-
${{ github.ref == 'refs/heads/main'
&& 'warp-ubuntu-latest-x64-4x;snapshot.enabled=true'
|| 'warp-ubuntu-latest-x64-4x;snapshot.key=service-images-main' }}
steps:
- uses: actions/checkout@v5
- name: Build and push service image
uses: Warpbuilds/build-push-action@v6
with:
context: .
push: true
tags: ${{ vars.REGISTRY }}/service:${{ github.sha }}
profile-name: "service-builder"
- name: Warm the local image store
run: |
if [ -z "$WARPBUILD_SNAPSHOT_KEY" ]; then
docker pull postgres:16
docker pull redis:7
else
echo "images restored from snapshot $WARPBUILD_SNAPSHOT_KEY"
fi
- name: Integration tests
run: docker compose -f compose.test.yml up --exit-code-from tests
- name: Cleanup credentials
if: github.ref == 'refs/heads/main'
run: |
rm -rf $HOME/.ssh $HOME/.aws
git clean -ffdx
- name: Save snapshot
if: github.ref == 'refs/heads/main'
uses: WarpBuilds/snapshot-save@v1
with:
alias: "service-images-main"
fail-on-error: false
wait-timeout-minutes: 60Two details in that file carry weight. Warpbuilds/build-push-action@v6 is the drop-in replacement for the standard build and push action and wires the remote builder for you, so the docker/setup-buildx-action step comes out. fail-on-error: false keeps a failed capture from turning a green pipeline red, which is the right default when the snapshot is an optimization.
warp-ubuntu-latest-x64-4x is $0.008 per minute and warp-ubuntu-latest-x64-2x is $0.004 per minute on the pricing page, checked on 2026-08-13. Every cost and performance number on this page carries a source link and a checked-on date, and the same number appears on every WarpBuild surface that states it.
What this is worth against the invoice
Teams that want the transfer line removed rather than reduced should read the zero egress page: on the enterprise tier, egress costs from the customer's cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, on BYOC and on WarpBuild-hosted runners.
For the compose-driven case specifically, where several service images get pulled before the tests start, Docker Compose integration tests in GitHub Actions works through the sizing. The remote Docker builders page covers profile setup and concurrency.
Related Questions
Does a multi-arch image double the bytes a job pulls?
No. A multi-arch tag is a manifest list, and a runner resolves it to the layers for its own architecture, so one job moves one architecture's bytes. Building is the other half: on remote Docker builders each architecture runs on a separate builder instance, which creates one session per architecture on the same builder profile, and each session keeps its own layer cache. The Docker builders documentation states the session rule.
How much does slimming the image actually help?
Pull time and transfer cost both scale with the compressed bytes the runner does not already have. Taking a 1.5 GB image down to 0.4 GB moves 880 monthly deploy jobs from 1,320 GB to 352 GB, which is $118.80 against $31.68 at the AWS internet data transfer rate of $0.09 per GB (Amazon ECR pricing, checked on 2026-08-13). On the same-Region path both numbers are $0.00, which is why placement usually outranks slimming on the invoice even though slimming still wins on wall clock.
Do snapshot runners help with image pulls on Windows, macOS, or BYOC runners?
No. Snapshot runners are supported only on WarpBuild Cloud Ubuntu runners. On BYOC runners, Windows runners, and macOS runners the snapshot labels are silently ignored, so the job runs normally with no snapshot behavior and no error in the log. The snapshot runners documentation lists the supported and unsupported platforms.
Can the cloud transfer charge on registry pulls go away entirely?
On the enterprise tier, egress costs from the customer's cloud drop to zero when runners pull large artifacts from ECR, S3, and similar stores during deployments, and this applies on BYOC and on WarpBuild-hosted runners. Qualification details are on the zero egress page, and the per-gigabyte model that charge replaces is in what ECR pulls cost in GitHub Actions.
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.