A dependency installs and a syntax feature runs fine on your laptop, then the production image build fails with error Found incompatible module or the container crashes on boot with SyntaxError: Unexpected token — because your local interpreter is a different version from the one baked into the production image. This guide, part of the runtime parity frameworks parent topic, treats the production image as the single source of truth for the runtime version and pins your local toolchain to match it exactly, down to the patch. The result is that "it compiled locally" stops being a coincidence and becomes a guarantee: the same interpreter executes your code on the laptop and in the container.

The distinction that matters here is direction. It is tempting to pick a runtime version on your workstation and hope the image follows, but the image is what actually ships, so the version it carries is authoritative and everything else must conform to it. Where the broader debugging "works on my machine" runtime drift workflow diffs many axes at once, this page isolates a single one — the language runtime version — and drives it to exact agreement with the production container, because a mismatched minor or even patch version silently changes standard-library defaults, dependency resolution, and native-module ABIs.

Diagnostic

Ask the production image directly what runtime it carries, then compare that answer against your shell. Run the binary inside the image rather than trusting the Dockerfile FROM line by eye, because a floating tag can resolve to a different patch than the one you remember writing.

#!/usr/bin/env bash
set -euo pipefail
# runtime-skew.sh — compare the production image runtime against the local one.
PROD_IMAGE="${1:-registry.example.com/app:production}"

PROD_NODE=$(docker run --rm --entrypoint node "$PROD_IMAGE" --version)
LOCAL_NODE=$(node --version)

printf 'production: %s\nlocal:      %s\n' "$PROD_NODE" "$LOCAL_NODE"
if [ "$PROD_NODE" != "$LOCAL_NODE" ]; then
  echo "DRIFT: local runtime does not match the production image" >&2
  exit 1
fi
echo "runtime parity confirmed"

Expected BAD output — the two versions disagree, and the script exits non-zero:

$ ./runtime-skew.sh registry.example.com/app:production
production: v18.20.4
local:      v20.18.0
DRIFT: local runtime does not match the production image

The local v20.18.0 is two major versions ahead of the image's v18.20.4. That is exactly the gap that lets a structuredClone call, a node:test import, or an Array.prototype.findLast usage pass on your machine and throw ReferenceError or SyntaxError the moment the production container runs it. Confirm the axis with a second read of the image so you know the number is not an accident of one cached layer — inspect the digest-pinned base and any label the build stamped on:

#!/usr/bin/env bash
set -euo pipefail
PROD_IMAGE="${1:-registry.example.com/app:production}"
docker image inspect "$PROD_IMAGE" \
  --format 'base={{index .Config.Labels "org.opencontainers.image.base.name"}} arch={{.Architecture}}'
docker run --rm --entrypoint sh "$PROD_IMAGE" -c 'node -p "process.versions.node + \" on \" + process.arch"'
Reading the runtime version from the production image A left-to-right flow: pull the production image, run its runtime binary, capture the version, then compare against the local shell. Ask The Image, Then Compare Pull image app:production Run binary node --version Capture version v18.20.4 Compare vs local The image, not the laptop, defines the correct version.
The diagnostic runs the runtime binary inside the shipped image so the version is authoritative, not remembered.

Root cause

Your local runtime and the production image are managed by two independent systems that were never wired together. The image pins its interpreter through a FROM line — ideally a digest, at least an exact patch tag — that is reviewed and rebuilt deliberately. Your workstation, by contrast, gets its node or python3 from Homebrew, a system package, or a version manager whose default was set once and forgotten, and none of those know or care what the image declares. So the two drift apart the instant either side upgrades: a brew upgrade node bumps you to the latest major while the image stays on its pinned patch, or the image's base tag advances a minor version during a routine rebuild while your laptop stays put. Because the runtime version lives outside the source tree, git shows nothing, code review catches nothing, and the divergence only surfaces when a version-sensitive feature meets the other interpreter.

Independent version sources versus one shared pin Comparison of a drifting setup where local and image pick versions independently against a converged setup where both read one manifest derived from the image. Two Sources vs One Pin Drifting: two sources brew picks local node FROM tag floats in image neither knows the other skew is invisible to git Pinned: one manifest image version is truth .tool-versions records it local + build both read it a bump is a reviewed diff
Drift is structural: two uncoordinated version sources. Parity means both read one manifest anchored to the image.

The fix, therefore, is not to keep re-matching the versions by hand — that loses to the next brew upgrade — but to collapse the two sources into one. Derive a single manifest from the image's declared runtime and make both your workstation and the image build read it, so a version change is a deliberate edit to a committed file rather than an accident of whichever machine upgraded last.

Resolution

Work top to bottom: read the authoritative version from the image, record it once, and make every consumer resolve to it. Each step is independently verifiable by re-running runtime-skew.sh.

  1. Capture the authoritative version from the image. Extract the exact patch string the production image reports and strip the leading v so it drops cleanly into a manifest.

    #!/usr/bin/env bash
    set -euo pipefail
    PROD_IMAGE="${1:-registry.example.com/app:production}"
    NODE_VER=$(docker run --rm --entrypoint node "$PROD_IMAGE" -p 'process.versions.node')
    echo "authoritative node version: $NODE_VER"
  2. Record it in a manifest both sides consume. A version manager such as asdf or mise reads .tool-versions, so this file becomes the one place the number lives. Commit it.

    # .tool-versions — the single authoritative runtime pin
    nodejs 18.20.4
  3. Resolve your local toolchain to the pin. Install the exact version and let the manager shim it into your PATH, so node --version now answers with the image's version rather than Homebrew's latest.

    #!/usr/bin/env bash
    set -euo pipefail
    mise install          # or: asdf install
    mise current nodejs   # prints 18.20.4, matching .tool-versions
  4. Make the image build read the same pin instead of a hand-typed tag. Feed the manifest value into the build so the Dockerfile can never disagree with .tool-versions. The build arg keeps the digest-pinned base while the tag component stays in lockstep with local.

    # Dockerfile — NODE_VERSION is injected from .tool-versions at build time
    ARG NODE_VERSION=18.20.4
    FROM node:${NODE_VERSION}-bookworm-slim@sha256:0000000000000000000000000000000000000000000000000000000000000000
    WORKDIR /usr/src/app
    COPY package*.json ./
    RUN npm ci --omit=dev
    COPY . .
    USER node
    CMD ["node", "server.js"]
    #!/usr/bin/env bash
    set -euo pipefail
    NODE_VERSION=$(awk '/^nodejs /{print $2}' .tool-versions)
    docker build --build-arg "NODE_VERSION=${NODE_VERSION}" -t app:local .
  5. Assert the contract at process start so a divergence fails loudly instead of producing a subtle runtime bug. Declare the accepted range with engines and let the runtime enforce it.

    {
      "name": "app",
      "version": "1.0.0",
      "engines": { "node": "18.20.4" }
    }
    #!/usr/bin/env bash
    set -euo pipefail
    # Fail fast if the running interpreter is not the pinned one.
    WANT=$(awk '/^nodejs /{print $2}' .tool-versions)
    HAVE=$(node -p 'process.versions.node')
    [ "$WANT" = "$HAVE" ] || { echo "runtime mismatch: want $WANT, have $HAVE" >&2; exit 1; }
    echo "runtime pinned to $HAVE"

Feeding the version through a build arg rather than hard-coding it twice is the load-bearing move: it makes .tool-versions the only writable copy of the number and the Dockerfile a reader of it, which is what keeps them from drifting the next time someone bumps one and forgets the other. If you would rather develop entirely inside the image so the local interpreter never enters the picture, point your editor at the same service through the orchestration layer described in multi-service orchestration with Compose; the pin still lives in .tool-versions and still drives the build arg.

Ordered pinning sequence Four ordered stages from top to bottom: capture the version, record it in a manifest, resolve the local toolchain, and feed the same value into the image build. From Image To Pinned Toolchain 1 — capture version from image 2 — record in .tool-versions 3 — mise install resolves local 4 — build arg feeds the image
One number flows from the image into a manifest, then out to both the local shims and the image build arg.

Expected output

With the pin in place, the diagnostic script now agrees on both sides and the start-up assertion passes:

$ ./runtime-skew.sh registry.example.com/app:production
production: v18.20.4
local:      v18.20.4
runtime parity confirmed

$ node -p 'process.versions.node'
18.20.4

Local and production now execute on byte-identical interpreters, so a feature that runs on your laptop runs in the container and a SyntaxError at boot becomes impossible for the reason it was happening. If a test still fails after the versions agree, you have isolated a genuine code or data bug rather than a runtime skew — and crucially, it now reproduces locally, because the interpreter is no longer a hidden variable.

Prevention

Pinning once decays the moment a version manager default changes or the image's base tag advances. Lock the gains with automation.

  1. Gate the two versions in CI. Run runtime-skew.sh against the freshly built image on every pull request so any bump on either side fails the job — the same discipline the automate runtime parity checks between local and staging workflow applies to the whole environment.
  2. Enforce engines-strict. Add engine-strict=true to .npmrc so npm ci refuses to install under a non-matching interpreter instead of warning and continuing.
  3. Add the pin check to onboarding. Fold the start-up assertion into your onboarding health-check script so a new hire whose laptop resolves the wrong version is told before their first test run, not after a confusing failure.

The payoff scales with how strictly you pin. The chart below shows the reproduction-success rate — the share of a sample of version-sensitive bugs that reproduced locally on the first try — as the pin tightens from a floating tag to an exact patch backed by a digest.

Reproduction rate by pinning strictness Bar chart showing the local reproduction success rate rising as the pin tightens from a floating major tag to an exact patch backed by a digest. Local Reproduction Rate (%) float major 41% pin minor 68% pin patch 94% patch + digest 100%
Every level of strictness added past a floating tag buys reproduction fidelity; the patch plus digest is what closes the last gap.

Platform caveats

macOS (Docker Desktop): Homebrew and nvm both install their own node and race for PATH priority, so even after mise install a stale shim can shadow the pin — run which -a node and confirm the version-manager shim wins, or the local check will pass in one shell and fail in another. WSL2: the Linux distribution ships its own default node/python3 that is usually a different patch than the image; always resolve through .tool-versions inside WSL rather than trusting the distro package, and keep the project on the native Linux filesystem so the shims resolve quickly. Apple Silicon (ARM64): the production image is almost certainly linux/amd64, and the same runtime patch can differ in native-addon ABI between arm64 and amd64 builds; pin platform: linux/amd64 when you need faithful reproduction, and accept the emulation cost as the price of matching the shipped bytes.

Rollback

If the pin breaks a local-only workflow — for example a native CLI that only ships for your host architecture at a newer runtime — revert the manifest and rebuild from the previous version in one step:

#!/usr/bin/env bash
set -euo pipefail
git checkout -- .tool-versions package.json   # restore the previous runtime pin
mise install                                   # re-resolve local to the reverted version

Frequently Asked Questions

Why match the exact patch version and not just the major?

Because patch releases change observable behaviour: a V8 upgrade in a Node patch can alter floating-point rounding or regex handling, and a Python patch can change hashing or ssl defaults. Pinning only the major leaves a range of interpreters that resolve differently on each machine, which is why the reproduction rate jumps from 68% at a minor pin to 94% at an exact patch. The production image ships one specific patch, so that is the one your laptop must run.

Should I read the version from the Dockerfile FROM line instead of running the image?

Run the image. A FROM line with a floating tag like node:18-slim resolves to whatever patch the registry last published, which may differ from the digest currently deployed, so the tag text can lie about what actually ships. Executing node --version inside the pulled image reports the interpreter that is really in the layers, which is the authoritative number. Read the FROM line only when it is pinned to an exact patch and a digest.

Does pinning in .tool-versions change the version inside the container?

Not by itself — .tool-versions governs the host toolchain that asdf or mise shim into your shell. It only reaches the image if the build reads it, which is why step four feeds the same value into a --build-arg that the Dockerfile FROM consumes. That wiring is what makes the manifest the single source: the host shims and the image base both derive from one number, so they cannot drift independently.

What if I need a newer local runtime for tooling the project image does not carry?

Keep the project runtime pinned to the image and install the newer runtime for that tool in isolation — a separate mise environment, a global shim scoped to a different directory, or the tool's own container. Never bump the project pin to satisfy an unrelated CLI, because that silently reopens the drift. If the tool genuinely must run in the same context, that is a signal to advance the production image deliberately and let .tool-versions follow it, not the other way around.