Does Caching Work Inside a Container Job?

Yes, with wget and zstd in the image and the verification token passed into the container. The usual failure is a cache path that resolves to the wrong home.

Yes, a container job restores and saves a WarpBuild cache once the image carries wget and zstd and the job passes WARPBUILD_RUNNER_VERIFICATION_TOKEN into the container environment, which is the setup written down in the WarpBuild caching documentation. The failure that costs teams an afternoon is quieter than a missing binary: the cache step runs inside the container, so ~ expands to /github/home and a path like ~/.cargo points at a directory the toolchain in the image never writes to.

Answer

jobs.<job_id>.container starts your image on the runner VM and runs every step of the job inside it, per GitHub's workflow syntax reference. The cache action is a step, so it runs inside the container too. Every path in its path: input is read through the container filesystem.

Four conditions decide whether the cache works there, and each one fails in a way you can recognize from the job log.

ConditionWhy it is requiredWhat you see when it is missing
wget present in the imageThe cache action downloads entries with wgetThe restore step fails while fetching the entry
zstd present in the imageEntries are compressed with zstdzstd version: null, a gzip fallback, then a 404 warning on an entry saved with zstd
WARPBUILD_RUNNER_VERIFICATION_TOKEN in the container envThe variable is present on the WarpBuild runner and authenticates the actionThe step cannot authenticate against the cache service
path: resolves to where the tool writesHOME is /github/home inside a container jobA reported hit over an empty directory, and the install step runs at full cost anyway

The first three come from the caching documentation and are one-line fixes. The fourth is the one worth reading the rest of this page for, because it produces no error at all.

Container jobs are a Linux mechanism on both sides of this. WarpBuild caching is enabled by default on Linux runners and is not supported on Windows runners, per the cloud runners documentation, so the platform question resolves the same way whether or not a container is in the picture.

Detail

The documented container setup

name: test
on: [push]

jobs:
  unit-tests:
    runs-on: warp-ubuntu-latest-x64-4x
    container:
      image: golang:1.25
      env:
        WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }}
    steps:
      - uses: actions/checkout@v4
      - name: Install cache prerequisites
        run: apt-get update && apt-get install -y --no-install-recommends wget zstd

The env block is the part that gets skipped. WARPBUILD_RUNNER_VERIFICATION_TOKEN is set on the runner host, and a container gets only the variables the workflow hands it, so the token has to be named explicitly. Images built on Alpine install the same two tools with apk add --no-cache wget zstd.

Where the paths move

The runner mounts a fixed set of host directories into the job container and rewrites HOME. The mapping below is what ContainerOperationProvider.cs in the actions/runner repository sets up on every container job.

Host pathContainer pathWhat lives there
/home/runner/work/__wThe workspace, so GITHUB_WORKSPACE becomes /__w/repo/repo
/home/runner/externals/__e, read onlyThe Node.js build that executes JavaScript actions
/home/runner/work/_temp/__w/_tempStep temp files
/home/runner/work/_actions/__w/_actionsChecked-out action code
/home/runner/work/_temp/_github_home/github/homeHOME for every step in the job

Two consequences follow. Paths relative to the workspace keep working unchanged, so a cache of ./node_modules or vendor/ behaves the same as it does on a plain runner. And ~ no longer means what it means on the host: it expands to /github/home, a directory the runner created for this job.

The mismatch, with two images you probably use

/github/home would be harmless if every tool inside the container honored HOME. Official language images do not, because they place their caches at an absolute path baked in at image build time.

ImageVariable set in the imageWhere dependencies actually landWhat ~/... caches instead
golangGOPATH=/go (docker-library/golang)/go/pkg/mod/github/home/go/pkg/mod, empty
rustCARGO_HOME=/usr/local/cargo (rust-lang/docker-rust)/usr/local/cargo/registry/github/home/.cargo/registry, empty

A workflow copied from a non-container job carries the wrong path in:

      - uses: WarpBuilds/cache@v1
        with:
          path: ~/go/pkg/mod
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}

That step saves an empty directory on the first run and restores an empty directory on every run after it. The log line says the cache was restored, go mod download still pulls every module, and the job time never moves. It looks like a key problem, so teams go and rewrite the key, which is why cache key and why a GitHub Actions cache misses every run are the two pages people land on before finding this one.

A container job that caches the right paths

Ask the toolchain where its cache is instead of guessing. The command runs inside the container, so the answer is correct for that image without you tracking what each image sets.

name: test
on: [push]

jobs:
  unit-tests:
    runs-on: warp-ubuntu-latest-x64-4x
    container:
      image: golang:1.25
      env:
        WARPBUILD_RUNNER_VERIFICATION_TOKEN: ${{ env.WARPBUILD_RUNNER_VERIFICATION_TOKEN }}
    steps:
      - uses: actions/checkout@v4

      - name: Install cache prerequisites
        run: apt-get update && apt-get install -y --no-install-recommends wget zstd

      - name: Resolve cache paths
        id: paths
        run: |
          echo "modcache=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
          echo "buildcache=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"

      - name: Restore module and build caches
        id: cache
        uses: WarpBuilds/cache@v1
        with:
          path: |
            ${{ steps.paths.outputs.modcache }}
            ${{ steps.paths.outputs.buildcache }}
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-go-

      - name: Download modules
        if: steps.cache.outputs.cache-hit != 'true'
        run: go mod download

      - name: Verify the restore landed
        run: du -sh "${{ steps.paths.outputs.modcache }}"

      - run: go test ./...

du -sh on the restored path is the whole verification. A hit with a few kilobytes behind it is the empty-directory failure. A hit with hundreds of megabytes behind it is a working cache. Keep that step for a run or two after any change to the image, then drop it.

The same shape works for other toolchains: npm config get cache, pip cache dir, yarn config get cacheFolder, composer config cache-dir. Each prints the path the tool resolved inside the container, which is the path the cache step needs.

What the cache costs, and where the container fits

Cache work is billed separately from runner minutes. Cache storage is $0.20 per GB-month and each cache write or restore is $0.0001, both from the pricing page, checked on 2026-08-13. A repository holding 20 GB of module and build caches across branches and running 3,000 container jobs per month, each doing one restore and one write, pays 20 x $0.20 = $4.00 in storage and 3,000 x 2 x $0.0001 = $0.60 in operations, so $4.60 per month. At $0.008 per minute on warp-ubuntu-latest-x64-4x, that total is worth about 575 runner minutes, which one working Go module cache recovers in a few dozen jobs.

When the setup inside the image is the slow part rather than the dependencies, a snapshot runner captures the prepared VM instead, and the persistent caches guide covers which state belongs in a cache, a snapshot, or an image. The wider mechanics of running steps in a container are in the guide to container jobs on GitHub Actions.

What does a container image need before the cache action will work?

Three things, per the WarpBuild caching documentation. The image needs wget, because the cache action downloads entries with it. The image needs zstd, because entries are compressed with it. And the job needs WARPBUILD_RUNNER_VERIFICATION_TOKEN passed into the container env block, because that variable exists on the runner host and does not cross into the container on its own. Once the container can reach the cache, the persistent caches guide covers which state is worth putting in it.

Why does my cache restore inside a container log "zstd version: null"?

The image has no zstd binary, so the action falls back to gzip. The warning is followed by a 404 warning when the entry was saved with zstd, because a gzip restore cannot read a zstd entry. Install zstd in the image or in a step before the cache step, then rerun. The caching documentation lists this under cache restore errors.

Why does caching ~/.cargo inside a container job restore nothing?

HOME inside a container job is /github/home, so ~/.cargo expands to /github/home/.cargo. The official Rust image sets CARGO_HOME to /usr/local/cargo, so the registry never lands under the path being cached. The cache step reports a hit and the directory is empty. Cache the path the toolchain actually uses, which cargo config get will print. The same rule covers the workspace: GITHUB_WORKSPACE reads as /__w/repo/repo inside the container, so absolute host paths written into a workflow file break there too. Cache key covers the separate question of when a key is at fault, and why a GitHub Actions cache misses every run covers the misses that survive a correct path.

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.