How the Caret (^) in package.json Actually Works — A Docker Build Incident Caused by a Missing Lockfile

2026-05-25 hit count image

One morning our Docker production build broke without any code change. The cause was a transitive package deep in the dependency tree that had published a new patch a few days earlier. This post walks through the incident and clarifies exactly how the caret range in package.json works and what role yarn.lock plays.

web

Introduction

One ordinary morning, an alert came in that our production deploy had failed — even though no one had pushed any code change. Opening the CI logs, I found the build failing at the yarn install step with this error:

error [email protected]: The engine "node" is incompatible with this module.
      Expected version ">=20". Got "18.17.0"
error Found incompatible module.

The library js-cookie is not something we list directly in our package.json. And until yesterday, the same code had been deploying without issue.

This post analyzes how “the build broke even though we didn’t change anything” incidents happen, by walking through this exact case. To spoil the conclusion: the culprit was the combination of the caret (^) range in package.json and a yarn.lock that wasn’t part of the build context.

The Production Build Suddenly Broke

The situation, simplified:

  • A Nuxt 2 frontend project
  • Dockerfile built on top of node:18.17.0-alpine
  • CI runs yarn install --frozen-lockfile && yarn build

A build that worked yesterday broke today, with no new commits pushed to our repo. By definition, this kind of incident means “something in the external environment changed without our knowing.”

The two most common suspects are:

  1. A package in the base image got updated — ruled out, since we explicitly pin Node to 18.17.0.
  2. A dependency on the npm registry got updated — worth investigating.

Step 1 — What Package Got Pulled In

First, we trace where js-cookie comes from. It’s not listed as a direct dependency in our package.json, and grepping yarn.lock finds no entry for it either. In other words, this package shouldn’t even exist in the dependency tree resolved from our lockfile. Yet the build is clearly trying to install it.

To trace it, we reproduce a fresh resolve in a temporary directory without a lockfile, then ask yarn why for the origin:

mkdir /tmp/trace && cd /tmp/trace
cp ~/project/package.json .
# Private git deps need auth, so drop that line for this trace
grep -v "private-internal-lib" package.json > package.json.tmp && mv package.json.tmp package.json

yarn install --ignore-engines --ignore-scripts
yarn why js-cookie

Result:

info Found "[email protected]"
   - "vue-jest#js-beautify" depends on it
   - Hoisted from "vue-jest#js-beautify#js-cookie"

The traced dependency chain is:

vue-jest (devDependency)
  └─ js-beautify (^1.6.14)
       └─ js-cookie (^3.0.5)

vue-jest is a devDependency used for testing Vue components with Jest. We never import it ourselves, but in the build stage yarn install installs everything including devDependencies (the Docker builder stage doesn’t use --production), so its transitive dependencies come along for the ride.

Step 2 — Why It Worked Yesterday but Not Today

Looking at js-cookie’s history on the npm registry makes the answer obvious. Here is the publish history of the 3.x line, confirmed via the semver calculator and the registry API:

VersionPublishedengines
3.0.52023-04-24node: '>=14'
3.0.62026-05-15node: '>=20'
3.0.72026-05-16node: '>=20'

Between 3.0.5 and 3.0.6, the engines requirement was bumped from >=14 to >=20. Per semver convention, this is the sort of breaking change that should warrant a major bump — but the maintainer released it as a patch (3.0.6). The moment that patch arrives in our Node 18 environment, the engine check fails.

The key question is this: we never specified any version of js-cookie directly, so why does a newly published 3.0.6/3.0.7 end up in our build?

The answer is a caret range buried in the innermost link of the dependency chain:

// Inside [email protected]'s package.json:
"dependencies": {
  "js-cookie": "^3.0.5"
}

^3.0.5 is the “semver caret range”, and it means “every version >=3.0.5 and <4.0.0.” That includes 3.0.6, 3.0.7, even 3.99.99. Every time yarn resolves, it picks the latest version on the registry that matches this range. After 2026-05-15, that latest was 3.0.6/3.0.7 — so yesterday it pulled 3.0.5, and today it pulled 3.0.7.

Step 3 — So Why Didn’t the Lockfile Save Us?

This is where the puzzle gets interesting. We do have a yarn.lock in the repo, and we already confirmed that it has no js-cookie entry at all. If the lockfile had been honored, js-cookie shouldn’t even have appeared in the tree. Yet the build was clearly trying to install it.

Back to the Dockerfile:

COPY package.json ./
RUN yarn install --frozen-lockfile

There’s the problem. The yarn.lock is not being copied into the build context. When the builder stage has no yarn.lock, yarn 1.x — even with --frozen-lockfile — has no lockfile to enforce, so it just resolves fresh and installs. That means it always picks up the current latest on the registry, so the moment someone publishes a new patch externally, our build silently follows along.

This is the most important realization that came out of debugging this incident. Not copying the lockfile into the build context is equivalent to saying “our dependency tree may shift every time, based on what version of a package was newly published to the npm registry”.

The fix is simple:

COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile

Two lines like this, and yarn now faithfully reproduces the exact versions written in the lockfile (in our case, a tree that contains no js-cookie at all). No matter what gets published to the external registry, our tree doesn’t change.

What Does the Caret (^) in package.json Mean?

Let’s step back and look at semver and the caret directly, because the caret is the core mechanism behind this incident.

According to npm’s Semantic Versioning documentation, a version consists of MAJOR.MINOR.PATCH, with each segment carrying a specific meaning:

SegmentMeaning
MAJORIncompatible API change (breaking change)
MINORNew features added in a backwards-compatible way
PATCHBackwards-compatible bug fix

The range notation in package.json’s dependencies decides how far automatic updates are allowed. The most common notation is the caret (^).

According to the npm CLI documentation on caret ranges, the caret means “fix the leftmost non-zero segment, and allow all updates below it.”

NotationMatched rangeNote
^1.2.3>=1.2.3 <2.0.0The MAJOR segment (1) is fixed
^0.2.5>=0.2.5 <0.3.0The leftmost nonzero is MINOR (2), so MINOR is fixed
^0.0.4>=0.0.4 <0.0.5The leftmost nonzero is PATCH (4), so PATCH is fixed

The last two cases matter. The 0.x line is treated by semver as “still unstable,” so the caret behaves conservatively there. When adopting a library, it’s worth remembering that a caret on the 0.x line and a caret on the 1.x line don’t mean the same thing.

If you want to see exactly which versions a specific caret notation matches, npm’s semver calculator shows you immediately, displayed alongside the actual list of published versions on the registry — quite useful when debugging.

The carets that caused this incident weren’t written by us. They were the “js-beautify”: “^1.6.14” that the vue-jest maintainer wrote in their package.json, and the “js-cookie”: “^3.0.5” that the js-beautify maintainer wrote. Both are carets, so every new publish within the same major flows automatically into our tree.

Does Removing the Caret Solve It? A Common Misconception

“So can we just remove all the carets from our package.json and exact-pin everything?”

It’s a natural reaction when tracing this kind of incident — a colleague asked the same thing. The answer is “only partially, and not for our particular incident.”

The key is whose package.json the caret lives in.

[ Our package.json ]                ← Removing carets here only pins our direct deps
   └─ [email protected]
        [ vue-jest's package.json ]      ← Carets here are out of our control
          └─ js-beautify ^1.6.14
               [ js-beautify's package.json ]   ← Also out of our control
                 └─ js-cookie ^3.0.5    ← The origin of the incident

Changing "vue-jest": "^3.0.4" to "vue-jest": "3.0.4" in our package.json pins vue-jest exactly, but vue-jest’s own "js-beautify": "^1.6.14" caret stays alive. As a result, a fresh resolve still pulls in the latest js-beautify, which still pulls in the latest js-cookie.

Put differently: transitive drift is something only the use of a lockfile can stop, not the notation in our package.json. Removing carets restricts automatic minor updates of direct dependencies, but to control transitives you need either a lockfile or a mechanism that operates over the entire tree, such as yarn’s resolutions field.

This is something that’s hard to notice in calm times and only surfaces when an incident hits. As long as the lockfile is working, the tree is reproduced exactly, so carets look harmless on the surface — even though the ranges are wide open underneath.

Summary

This incident started with one missing line in the Dockerfile, was triggered by a new patch publish on the external registry, and brought down the build. Here’s the timeline once more:

  1. The Dockerfile didn’t copy yarn.lock into the build context.
  2. yarn install --frozen-lockfile effectively becomes a fresh resolve when no lockfile is present.
  3. On most days, by luck, the dependency tree happened to be “same as yesterday.” But once the registry shifts, the tree shifts with it.
  4. The js-cookie maintainer changed engines from >=14 to >=20 in a patch release (3.0.6).
  5. Our caret (more precisely, a transitive’s caret) matched that patch → fresh resolve pulled it in and tried to install.
  6. The engine check failed on Node 18 → production deploy halted.

The essence of the fix is two lines: include the lockfile in the build context, and force its use with --frozen-lockfile. Doing just this closes the conduit through which external whims can shake our build. It’s a separate layer from how you choose to use carets.

Semver is the maintainer’s promise; the caret is our expression of trust in that promise. Both ultimately rest on human judgment, so they can break by nature — and the lockfile is the last safety net protecting our build when they do.

References

Was my blog helpful? Please leave a comment at the bottom. it will be a great help to me!

App promotion

You can use the applications that are created by this blog writer Deku.
Deku created the applications with Flutter.

If you have interested, please try to download them for free.



SHARE
Twitter Facebook RSS