Can I Cache a Built Image Instead of Rebuilding It?
Yes. Push the image once and pull it by digest in later jobs, which usually beats saving a tar through the cache. The byte math, the YAML, the exceptions.
Yes, and the mechanism that works best is a registry rather than the cache: push the image once in the build job and pull it by digest in every later job, which is usually cheaper than saving and loading a tar through the cache. The push is work the pipeline already does when it deploys the image, the pull moves compressed blobs, and the digest guarantees that the artifact the test job runs is byte for byte the artifact the build job produced.
Answer
Three mechanisms can get a built image into a downstream job, and they move very different amounts of data.
| Approach | Bytes into each downstream job | Extra work in the build job | Artifact identity |
|---|---|---|---|
| Registry push, then pull by digest | Compressed layer blobs the host does not already hold | One push, which a deploying pipeline already runs | Exact, pinned by digest |
docker save tar through the cache or artifacts | Compressed archive, then docker load unpacks the uncompressed image to disk | docker save, compress, upload | Exact, but the archive duplicates bytes the registry can already serve |
| Rebuild from a layer cache | Layer cache traffic plus the steps that miss | None | Not guaranteed identical to the build job's image |
Take a concrete image: 1.2 GB of uncompressed layers on disk and 420 MB of compressed blobs in the registry. Run docker manifest inspect against your own tag and sum the layer sizes to replace those two numbers with real ones.
A downstream job on the registry path downloads 420 MB and uploads nothing. The same job on the tar path downloads a comparable archive and then pays docker load to expand 1.2 GB back onto the disk, and the build job paid docker save plus a compress plus an upload of that archive before any downstream job started. Fan the pipeline out to a three-way test matrix and the registry path still uploads once, while the tar path has moved its archive four times.
The third row is the one that quietly breaks release pipelines. Rebuilding from a layer cache in the second job produces a new image, and two builds of the same Dockerfile are only bit identical when every step is reproducible. Timestamps, package index resolution, and generated files all move the digest. What you tested is then not provably what you shipped, which is the whole reason an image digest exists as a pinning tool and why build once, deploy many treats a single build as the release artifact.
Detail
The workflow
The build job publishes the image and exports its digest as a job output. Downstream jobs read that output through the needs context, documented in GitHub's job output reference, and pull the digest rather than the tag.
name: build-and-test
on: [push]
permissions:
contents: read
packages: write
jobs:
build:
runs-on: warp-ubuntu-latest-x64-4x
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v5
- name: Log in to the registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: Warpbuilds/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
profile-name: "super-fast-builder"
test:
needs: build
runs-on: warp-ubuntu-latest-x64-4x
strategy:
matrix:
suite: [unit, integration, e2e]
steps:
- name: Log in to the registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run the suite against the exact built image
run: |
docker run --rm \
ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }} \
./run-tests.sh ${{ matrix.suite }}Warpbuilds/build-push-action@v6 is a drop-in replacement for docker/build-push-action, which publishes the digest output this workflow reads, per the action's repository. The profile-name input routes the build to a WarpBuild remote Docker builder, described in the Docker builders documentation; those builders hold a persistent layer cache on a dedicated VM, so the build job is not starting cold either. On WarpBuild runners the action needs no API key, and on other runners it takes one through the api-key input.
Three details make this workflow behave:
permissions: packages: writeon the build job, because the default token cannot publish to the registry without it.- The digest, not the tag, in
docker run. A tag can be moved by a later push; a digest cannot. - Login in every job that pulls, since each job is a fresh machine.
When the saved tar still wins
Three situations keep the tar path in play.
The image must not be published. A scan-only or lint-only job on a build that should never reach a registry has nowhere to push, and a tar through the cache is the remaining transport.
The workflow runs on a pull request from a fork. GitHub gives fork pull request workflows a read-only token and withholds secrets, so registry write is unavailable and a saved archive is the only way to hand the image to a second job.
The image is small. Below a few hundred megabytes the save, compress, upload, download, and load sequence is short enough that the operational simplicity of not managing registry credentials is worth more than the bytes.
The size at which it stops winning
The ceiling decides it. GitHub caps the combined size of all caches in a repository at 10 GB by default and evicts in least recently used order once the total passes the cap, with any entry unused for 7 days deleted regardless, per GitHub's caching documentation, checked on 2026-08-13. An image archive is large enough to collide with that ceiling on its own.
WarpBuild cache storage bills at $0.20 per GB-month and cache write or restore operations at $0.0001 each, from the pricing page, checked on 2026-08-13.
| Archive size | Entries kept, one per active branch | WarpBuild cache storage per month | Total against GitHub's 10 GB ceiling |
|---|---|---|---|
| 0.5 GB | 10 | $1.00 | 5 GB, half the ceiling |
| 1 GB | 10 | $2.00 | 10 GB, at the ceiling |
| 2 GB | 5 | $2.00 | 10 GB, at the ceiling |
| 2 GB | 10 | $4.00 | 20 GB, evicting continuously |
A 2 GB archive stops fitting past five active branches, and the failure is not an error. The restore step logs a miss, the downstream job rebuilds or fails on a missing image, and nothing in the run points at eviction. Storing image archives as artifacts instead moves the problem rather than solving it: artifacts carry a default 90-day retention and bill storage, per GitHub's artifact documentation, and they cannot be restored by key from a later workflow the way a cache entry can.
If you keep the tar path deliberately, the WarpBuild cache is a drop-in replacement for actions/cache@v4 with no repository size ceiling, which removes the eviction column from the table above and leaves only the storage cost. The persistent caches guide covers the wider question of which state belongs in a cache, in a snapshot, or in a registry.
Where this runs
Remote Docker builder sessions start at $0.06 per minute for a 16 vCPU builder with 32 GB of RAM, and warp-ubuntu-latest-x64-4x is $0.008 per minute, both from the pricing page, checked on 2026-08-13.
Related Questions
Should I push the image to a registry or save it as a tar through the cache?
Push it. A registry push happens once in the build job, and every downstream job pulls the compressed blobs by digest without a second upload. The tar path moves the same bytes up once and down again for every job that needs them, then unpacks the uncompressed image a second time with docker load. The Docker builders documentation covers the build side, and reducing Docker image pull time covers the pull side.
How do I pass an image digest from one job to another?
Declare a job output on the build job and read it through the needs context in the downstream job. The build-push-action sets a digest output, so outputs.digest maps to steps.build.outputs.digest and the test job pulls image@${{ needs.build.outputs.digest }}. Image digest defines what that value is and why a tag is not a substitute.
Does the GitHub Actions cache size limit apply to a saved image tar?
Yes. GitHub caps the combined size of all caches in a repository at 10 GB by default and evicts entries in least recently used order once the total passes the cap, so a 2 GB image archive written per branch stops fitting past five active branches. Checked on 2026-08-13 against GitHub's caching documentation. The persistent caches guide and build once, deploy many cover the two ways out.
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.