DEV Community

Cover image for Reuse cache mounts reliably across multi-stage Dockerfiles
Chris Rodriguez
Chris Rodriguez

Posted on Originally published at chriseugenerodriguez.com

Reuse cache mounts reliably across multi-stage Dockerfiles

Problem summary

When a small source change makes a Docker build re-run dependency installs, many developers say “the cache didn’t work.” The clearer diagnosis is that Docker has at least two different cache mechanisms: instruction/layer cache (which can skip an instruction entirely) and cache mounts (which provide a persistent directory to a step when that step runs). They solve different problems and have different guarantees.

Implementation approach

This article keeps the implementation narrow and verifiable: create a helper that validates the fields Docker documents for RUN --mount=type=cache, emits the mount declaration exactly, and uses a sensible default identity. Concretely, the helper verifies that the mount target is a non-empty absolute path composed of safe characters, that an explicit id (if provided) is a non-empty safe string, and that sharing is one of the documented values (shared, private, locked). The helper then returns the canonical shape for the mount options and formats the --mount fragment you will paste into a Dockerfile instruction.

Expected behavior

  • Default id equals the mount target path. That mirrors Docker's documented default and gives a stable, readable identity in multi-stage builds.
  • When the install step runs, the mount provides the package manager's actual cache directory so downloads can be reused if the builder still holds the storage.
  • Choose sharing=locked when a tool expects exclusive access to the cache files (helps with file-locking semantics on CI with concurrent builds).

Limitations and practical notes

  • A correct mount declaration does not force persistence across fresh builder instances. Docker stores cache mounts in the builder’s local storage; if your CI creates a fresh builder each job or the builder garbage-collects storage, the mount will start empty.
  • Cache mounts make reruns cheaper by preserving downloaded artifacts, but they do not change Docker’s invalidation rules. If a dependency manifest changes, the install step should run again; the mount only reduces the download cost during that rerun.
  • The helper intentionally restricts allowable characters in target and id to avoid injection into the flag syntax. Projects that need broader naming schemes should provide a separate, reviewed configuration path.

Why this framing helps

By separating what the Dockerfile can guarantee (a valid, stable mount declaration) from what the environment must provide (builder-local persistence), you get a predictable place to reason about performance: fix the declaration first, then address your CI/builder topology if persistence is still missing.

(The server will insert the verified implementation example as a fenced code block below this explanation.)

Working example

const SHARING_VALUES = new Set(['shared', 'private', 'locked']);
const SAFE_COMPONENT = /^[A-Za-z0-9_./-]+$/;

export function validateCacheMountInput({ target, id, sharing = 'shared' } = {}) {
  if (typeof target !== 'string' || target.length === 0) {
    throw new TypeError('target must be a non-empty string');
  }
  if (!target.startsWith('/')) {
    throw new RangeError('target must be an absolute path');
  }
  if (!SAFE_COMPONENT.test(target)) {
    throw new RangeError('target must contain only letters, digits, slash, dot, underscore or hyphen');
  }

  if (id !== undefined) {
    if (typeof id !== 'string' || id.length === 0) {
      throw new TypeError('id must be a non-empty string when provided');
    }
    if (!SAFE_COMPONENT.test(id)) {
      throw new RangeError('id must contain only letters, digits, slash, dot, underscore or hyphen');
    }
  }

  if (!SHARING_VALUES.has(sharing)) {
    throw new RangeError('sharing must be one of shared, private, locked');
  }
}

export function buildCacheMountOptions({ target, id, sharing = 'shared' } = {}) {
  validateCacheMountInput({ target, id, sharing });
  return { type: 'cache', target, id: id ?? target, sharing };
}

export function formatRunMount(options) {
  const { type, target, id, sharing } = buildCacheMountOptions(options);
  return `--mount=type=${type},target=${target},id=${id},sharing=${sharing}`;
}
Enter fullscreen mode Exit fullscreen mode

Read the full article: https://chriseugenerodriguez.com/blog/reuse-cache-mounts-multi-stage-dockerfiles

Top comments (1)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.