Caching Strategies in Bash CI Scripts: Cache Keys, Invalidation and Locking
AI generated
$_
#!/
Bash · CI/CD · Caching · Performance
Caching Strategies in Bash CI Scripts
Building cache keys from checksums and controlling invalidation cleanly

Every CI pipeline that reinstalls dependencies or rebuilds artifacts from scratch on every run wastes time and money. A Bash script that consistently derives its cache key from a checksum of the relevant files ensures a cache gets invalidated automatically the moment something changes, and reused reliably otherwise.

17 min read sha256sum · cache keys flock · race conditions

1. Why caching makes the difference in CI scripts

A typical CI run often spends more time installing dependencies than actually building or testing the code. An npm ci or composer install without a cache pulls the same packages from the network on every single pipeline run, even though the dependencies usually have not changed at all between two commits. Across hundreds of pipeline runs a month, that adds up to significant wasted compute time and, with paid CI minutes, real cost.

A well-built caching setup in Bash CI scripts solves exactly this problem by reusing the result of an expensive operation, such as an installed node_modules directory or a compiled build artifact, across pipeline runs as long as the inputs have not changed. The art is not in caching itself but in reliably detecting when a cache is still valid and when it must be discarded.

2. What is worth caching and what is not

Good candidates for caching are results that arise deterministically from clearly identifiable input files: installed dependencies from a lockfile, compiled build artifacts from source code, or downloaded Docker layers from a Dockerfile. In all these cases it is unambiguous when the result is still valid, namely as long as the input file has not changed.

Poor candidates are intermediate results that depend on external, unversioned state, such as test results carrying timestamps, or data that is deliberately meant to differ on every run, like generated random test data. Caching such results risks stale or incorrect output that is harder to debug than the time saved is worth.

3. Building cache keys from checksums

The central building block of any caching strategy is a cache key derived unambiguously from the relevant inputs. For a dependency installation that is usually the hash of the lockfile, such as package-lock.json or composer.lock, computed with sha256sum. If even a single character in the lockfile changes, the hash changes, and the cache is automatically recognized as invalid without anyone having to maintain invalidation logic by hand.

For more complex cases it pays off to combine several factors into the cache key, such as the lockfile hash together with the language version and the operating system of the CI runner. A cache built under Node 18 on Linux may not be valid under Node 20 on macOS, even with an identical lockfile, and a composite cache key prevents exactly this kind of silent mismatch.


#!/usr/bin/env bash
set -euo pipefail

compute_cache_key() {
  local lockfile="$1"
  local node_version os_name lockfile_hash
  node_version="$(node --version)"
  os_name="$(uname -s)"
  lockfile_hash="$(sha256sum "$lockfile" | awk '{print $1}')"

  # Combine everything that could invalidate the cache into one key
  echo "${os_name}-${node_version}-${lockfile_hash}" | sha256sum | awk '{print $1}'
}

CACHE_KEY="$(compute_cache_key package-lock.json)"
echo "Cache key: $CACHE_KEY"

4. Cache directory structure and storing by cache key

Once the cache key is computed, it should feed directly into the cache's storage path, for example /cache/deps-$CACHE_KEY.tar.gz. This path-based versioning has the advantage that several cache versions can coexist in the same cache directory without overwriting each other, which matters especially with parallel pipeline jobs running slightly different environments.

Many CI systems additionally support so-called restore keys: a prefix that, on a miss against the exact key, looks for the next-best, partially matching cache. A restore key like deps-linux-node20- then finds a somewhat older cache with a slightly different lockfile, which is not a perfect match but still contains most of the already-downloaded packages, enabling an incremental rather than a full rebuild.


#!/usr/bin/env bash
set -euo pipefail

readonly CACHE_DIR="/cache"
readonly CACHE_KEY="$1"
readonly CACHE_FILE="${CACHE_DIR}/deps-${CACHE_KEY}.tar.gz"

save_cache() {
  local source_dir="$1"
  mkdir -p "$CACHE_DIR"
  tar -czf "${CACHE_FILE}.tmp" -C "$source_dir" .
  mv "${CACHE_FILE}.tmp" "$CACHE_FILE"  # atomic: never a half-written cache file
}

save_cache "node_modules"

5. Checking for a cache hit and conditionally skipping installation

The actual time savings only materialize once the script checks, before the expensive operation, whether a matching cache already exists, and skips the operation entirely on success. This check must consider both the existence of the cache file and its basic integrity, because an empty or partially written cache file is worse than no cache at all, since it fakes a failure that is hard to diagnose.

A robust pattern therefore checks not just whether the file exists, but also whether it has a plausible minimum size before extracting it. If extraction still fails, say because the file exists but is corrupt, the script should automatically fall back to a normal, full installation instead of aborting with a cryptic error.


#!/usr/bin/env bash
set -uo pipefail  # no -e: a cache miss or corrupt cache must not abort the script

restore_cache_or_install() {
  local cache_file="$1" target_dir="$2"

  if [[ -s "$cache_file" ]] && tar -tzf "$cache_file" >/dev/null 2>&1; then
    echo "Cache hit: restoring $target_dir from $cache_file"
    mkdir -p "$target_dir"
    tar -xzf "$cache_file" -C "$target_dir"
    return 0
  fi

  echo "Cache miss or corrupt cache, running full installation"
  npm ci
}

6. Controlling invalidation on changes cleanly

Because the cache key is already built from the hash of the relevant files, the actual invalidation happens automatically: if the lockfile changes, the key changes, and the old cache simply never gets referenced again. That alone is not enough, though, because unused old cache files accumulate over time and take up unbounded disk space in the cache directory if nobody cleans them up.

A separate cleanup script that runs regularly, for instance as its own nightly CI job, should delete cache files based on their last-accessed time once they have not been used for a certain number of days. That cleanly separates two independent concerns: the cache key handles correctness, the cleanup script handles bounded storage use, without a single script having to mix both tasks with unnecessary complexity.


#!/usr/bin/env bash
set -euo pipefail

readonly CACHE_DIR="/cache"
readonly MAX_AGE_DAYS=14

# Remove cache files that have not been accessed in MAX_AGE_DAYS
find "$CACHE_DIR" -maxdepth 1 -name '*.tar.gz' -atime "+${MAX_AGE_DAYS}" -print -delete

7. Race conditions with parallel pipelines: locking with flock

When several pipeline jobs run in parallel and potentially write to the same cache directory at once, a race condition emerges: two jobs could simultaneously detect the same cache key as missing, both perform the expensive installation, and then both write to the same cache file at once, which in the worst case produces a corrupt file half-overwritten by each job.

The atomic mv from the previous section already substantially defuses the problem for the write itself, because in the end a complete file from one of the two jobs always wins instead of a mixed one. Anyone wanting extra safety, for instance to prevent two jobs from redundantly running the same expensive installation at once, can guard the critical section with flock against a lock file, so only one job may write at a time while parallel jobs either wait or use the cache read-only.


#!/usr/bin/env bash
set -euo pipefail

readonly LOCK_FILE="/cache/.deps.lock"

(
  # Wait up to 120s for the lock, then proceed -- avoids indefinite hangs
  flock -w 120 200 || { echo "Could not acquire cache lock, proceeding without cache"; exit 0; }

  if [[ ! -f "$CACHE_FILE" ]]; then
    npm ci
    save_cache "node_modules"
  fi
) 200>"$LOCK_FILE"

8. Cache limits and pitfalls

The most common pitfall is a cache key that is too weak and leaves out important influencing factors, for instance hashing only the lockfile but not the language version. The result is a false cache hit, where an actually incompatible cache gets silently reused and causes hard-to-trace failures much later in the build, far removed from the actual cause.

A second, security-relevant pitfall is accidentally writing secrets into the cache, for instance when a .env file ends up in the cached directory because too broad a path got archived. Since caches often outlive individual pipeline runs and are sometimes shared across branches, such a leak can spread credentials across team and time boundaries. Explicitly excluding sensitive files when building the cache is therefore mandatory, not optional.

9. Comparing caching strategies

Depending on the kind of resource being cached, a different combination of cache key composition, storage location, and invalidation logic fits best. The table below places the most common cases.

Resource Cache key basis Invalidation Notable detail
Dependencies (npm/composer) Hash of the lockfile + language version Automatic on lockfile change Use restore keys for partially matching caches
Build artifacts Hash of all source files Automatic on code change A finer-grained hash per module saves more time
Docker layers Hash of the Dockerfile + context Automatic, usually managed by the build tool BuildKit cache mounts are often more efficient than manual caching
Test results Not recommended to cache Not applicable Timestamps and random data invalidate results

Mironsoft

Shell automation, DevOps tooling and deployment infrastructure

Shell scripts that hold up in production?

We review existing Bash scripts, spot fragile patterns and replace them with robust Bash patterns: complete error handling, logging and safe parallelization for your deployment stack.

Code Review

ShellCheck analysis and manual review for critical Bash pattern violations.

Refactoring

Retrofitting error handling, logging and safe file operations.

CI Integration

Wiring ShellCheck and BATS into pipelines and building regression tests.

10. Summary

Caching in CI Scripts: The Essentials at a Glance

Cache key

sha256sum over the lockfile combined with language version and operating system builds a key that invalidates itself automatically on change.

Atomic saving

Write the cache to a temporary file and move it into place with mv only afterward, to avoid half-written cache files.

Race conditions

flock against a lock file prevents multiple parallel jobs from redundantly running the same expensive installation.

Cleanup

A separate, regularly run script deletes cache files by last-accessed time, independent of the actual cache key logic.

11. FAQ: Caching in CI Scripts: The Essentials at a Glance

1How do I build a reliable cache key in Bash?
With sha256sum over the relevant lockfile, combined with other factors like language version and operating system that could also affect the result. Hashing all factors together again yields a compact, unambiguous key.
2Why is the lockfile hash alone sometimes not enough?
Because a cache built under a different language version or operating system can be incompatible despite an identical lockfile. A composite cache key built from several factors prevents such silent mismatches.
3What is a restore key and when do I need one?
A restore key is a prefix that, on an exact cache miss, looks for the next-best, partially matching cache. It enables an incremental rather than a full rebuild when only part of the inputs has changed.
4How do I avoid a corrupt cache file after an aborted write?
By first writing the cache to a temporary file and only moving it into place afterward with mv, which is atomic within the same filesystem.
5How do I detect a corrupt cache before extracting it?
With a check like tar -tzf file.tar.gz, which lists the archive without extracting it. If that fails, the cache is corrupt and the script should fall back to a normal installation.
6How do I stop parallel pipeline jobs from stepping on each other while writing the cache?
With flock against a dedicated lock file, so only one job writes to the cache at a time while others either wait or use the existing version read-only.
7Do I need to manually invalidate caches when code changes?
No, as long as the cache key is correctly derived from the hash of the relevant files. Any change to those files automatically produces a new key, so the old cache simply never gets referenced again.
8Why should I still delete old cache files regularly?
Because unused cache files orphaned by invalidation otherwise accumulate without bound and consume disk space. A separate, regularly run cleanup script deletes files based on their last-accessed time.
9What data should I never write into a CI cache?
Secrets such as credentials or .env files. Caches often outlive a single pipeline run and are sometimes shared across branches, so a leak can spread credentials widely.
10Is caching worth it for small, fast installations too?
Only to a degree. The overhead of hash computation, compression, and locking can exceed the time saved for very small dependency trees. Caching pays off mainly for installations that noticeably take several seconds to minutes.