Migrating production infrastructure? Get up to $10K in migration credits.

Apply now
Deployment

Pinning Node.js, Python, and Ruby Versions for Reproducible Builds

TL;DR

  • Reproducible builds require locking four levels: the OS or container image, the language interpreter and package manager, direct packages, and indirect dependencies. Write full major.minor.patch versions in your version files and commit your lockfiles to Git.
  • Because pinning exact minor and patch versions turns off automatic updates, pair locked builds with automated CVE scanning and scheduled upgrade pull requests.
  • In Node.js, pin the exact interpreter version in .node-version or .nvmrc, pin your package manager (npm, pnpm, Yarn, or Bun) in package.json, and configure CI and production builds to install strictly from your committed lockfile.
  • In Python, write the full major.minor.patch version in .python-version, commit uv.lock alongside pyproject.toml, and enforce locked installs during deployment so builds fail if the lockfile and manifest disagree.
  • In Ruby, keep .ruby-version, Gemfile, and Gemfile.lock synchronized (Render checks RUBY VERSION in Gemfile.lock first), and configure Bundler to install strictly from Gemfile.lock in production.
  • Unlike serverless platforms that force automatic minor and patch runtime updates, Render runs persistent services that honor your exact version files, Render Blueprint (render.yaml) pins, or Dockerfile images, keeping your local, CI, and production environments aligned.

When your local machine, CI runner, and production server run slightly different versions of a language or library, builds don't always fail loudly. Often, the deploy succeeds and breaks silently in production. Subtle behavior changes corrupt data, break edge-case requests for customers, degrade product reliability, and burn hours of engineering time chasing bugs you can't reproduce locally.

This happens because most projects rely on semantic versioning (major.minor.patch) ranges instead of exact pins. In theory, minor (x.Y.z) and patch (x.y.Z) updates are backward-compatible, so a range like ^20.0.0 or ~> 7.0 should be safe. In practice, minor and patch releases regularly introduce breaking changes. For example, when the Ruby ODM Mongoid released v7.1.0, a minor update changed how .or query criteria combined conditions, silently altering database queries in production for apps that hadn't locked their exact version.

Pinning exact versions prevents these surprise upgrades and speeds up your CI/CD pipeline. When your language version, package manager, and lockfile stay fixed, build caches and container image hashes stay identical across runs unless you intentionally change a version.

Although this guide walks through Node.js, Python, and Ruby, the same principles apply to Java, Go, Rust, and any other language stack.

This article covers:

  • The four levels of version pinning and general do's and don'ts.
  • Pinning Node.js, Python, and Ruby versions.
  • Aligning versions across local dev, GitHub Actions CI, and Render Blueprints (render.yaml) using a Python example.
  • Why cloud platform architecture matters for version pinning.
  • Rolling out upgrades safely and diagnosing silent upgrades when a build still breaks.

What does version pinning actually guarantee?

A reproducible build means that given the same source commit, your local machine, CI runner, and production server assemble the exact same software. One lockfile or one version file isn't enough on its own, because your application runs on four stacked levels:

The four levels of version pinning

  1. OS and container image: The underlying operating system and preinstalled system libraries (such as Linux packages for SSL encryption or database drivers). On managed native runtimes like Render's, the platform maintains the base OS image. When you deploy with Docker, you pin the exact base image yourself.
  2. Language interpreter and package manager: The exact version of the language executing your code (such as Python 3.13.5, Node.js 22.16.0, or Ruby 3.4.4) and the package manager that installs your packages (such as uv, npm, or Bundler). Different package manager versions can resolve dependencies or read lockfiles differently.
  3. Direct packages and libraries: The top-level dependencies your application imports directly, declared in your project manifest (such as pyproject.toml, package.json, or Gemfile).
  4. Indirect (transitive) dependencies: The deeper tree of packages that your direct libraries depend on, recorded in your lockfile (such as uv.lock, package-lock.json, or Gemfile.lock).

Pinning one level doesn't lock the others. If you commit a lockfile (levels 3 and 4) but leave the language interpreter floating (level 2), a new minor language release can still break packages that rely on compiled C or C++ code or change built-in library behavior. And if your CI runner or cloud platform doesn't read the version file you committed, it silently falls back to a system default.

Cross-language version pinning do's and don'ts

The table below uses a Python (uv) project as an example to illustrate each rule, but the same do's and don'ts apply across Node.js, Ruby, Java, Go, and other languages:

Do
Don't
Why it matters
Write full major.minor.patch versions (e.g., 3.13.5 in .python-version).
Don't use floating aliases or partial versions (e.g., latest or 3.13).
Aliases and major/minor-only strings resolve to a moving target whenever a new release ships, breaking build repeatability and invalidating build caches.
Commit lockfiles to Git (e.g., uv.lock alongside pyproject.toml) for every deployable application.
Don't ignore application lockfiles in Git or rely on pyproject.toml version ranges alone.
Manifest ranges like >=2.0 allow indirect dependencies to shift between installs. Only the lockfile freezes the full transitive dependency tree.
Enforce locked installs in CI and production so builds verify the committed lockfile against the manifest.
Don't let deployment builds resolve fresh package versions from the package registry.
Deployment builds should install the exact dependency tree recorded in your lockfile and fail immediately if the lockfile and manifest disagree.
Point local tools, CI, and production at the same version record.
Don't maintain separate hardcoded version numbers across CI workflows and hosting dashboards without a single source of truth.
When CI tests against one patch version and production runs another, silent incompatibilities slip straight to customers.
Automate vulnerability scanning and scheduled upgrades (e.g., Dependabot or Renovate with CI tests).
Don't treat exact pins as "set and forget."
When you pin exact minor and patch versions, the platform or package manager won't auto-install security patches for you. Monitoring CVEs and rolling out updates becomes your responsibility.

How do you pin Node.js versions?

The Node.js ecosystem includes several widely used package managers and runtimes, including npm, Yarn, pnpm, and Bun. Render's native Node.js runtime supports all of them out of the box (see Render's Node.js and Bun documentation for setup details). The examples below use npm and pnpm, but the same principles apply across tools.

Pinning the Node.js interpreter

Put a full major.minor.patch version (such as 22.16.0 in the example below) in a .node-version or .nvmrc file in your repository root:

Local version managers (such as nvm, fnm, and mise) and cloud platforms like Render read both files using the same syntax. Avoid putting aliases like lts or major-only numbers like 22 in either file, because both resolve to newer releases over time.

In package.json, engines.node and devEngines serve two different purposes:

  • For engines.node, npm treats the field as advisory by default, whereas Render uses it as a fallback runtime selector if you haven't set NODE_VERSION, .node-version, or .nvmrc. Avoid unbounded ranges like >=20 in engines.node, because Render resolves them to the latest Node.js release.
  • For devEngines, npm enforces runtime and package manager checks on your project before install, ci, and run commands, failing with an error by default (onFail: "error") when a developer or CI runner uses the wrong version.

Pinning the Node.js package manager

To make sure every developer and CI runner uses the exact same package manager version, you have two practical options:

  1. Enforce the package manager in package.json with devEngines.packageManager: Modern npm checks devEngines.packageManager automatically before running commands and fails immediately if the active installer version doesn't match. For example, add the following block to your package.json:
  1. Use packageManager with Corepack: If your project uses pnpm or yarn, set the packageManager field in package.json (for example, "packageManager": "pnpm@9.15.0"). Because Node.js 25 and later no longer bundle Corepack by default, include an explicit install and enable step in your CI and build scripts so the shim is always present:

Committing and verifying Node.js lockfiles

For deployable applications, always commit your lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock, or bun.lock) to Git. During CI and production builds, verify the lockfile instead of resolving a fresh dependency tree:

The npm ci command deletes any existing node_modules directory and installs the exact dependency tree recorded in package-lock.json. If package-lock.json and package.json don't match, npm ci exits with an error instead of modifying the lockfile, and --omit=dev keeps test and build-only packages out of your production service.

How do you pin Python versions?

Python's packaging ecosystem includes tools such as uv, Poetry, pip, and Conda. Render's native Python runtime supports uv, pip, and Poetry out of the box (if your stack relies on Conda, deploy on Render using a Dockerfile instead). Below, we use uv as the primary example.

Pinning the Python interpreter

Always write the full major.minor.patch version (such as 3.13.5 in the example below) in your .python-version file:

Local version managers (such as uv, pyenv, and mise) and Render read .python-version directly. If you write only 3.13, tools and platforms resolve the latest available 3.13.x patch on every new build. On Render, you can also pin the interpreter with the PYTHON_VERSION environment variable (which requires all three version numbers).

Locking packages and the installer with uv

If you generate requirements.txt by running pip freeze > requirements.txt outside an isolated virtual environment, pip dumps every package installed on your machine (including OS-specific tools or unrelated local packages) into the file. When your cloud runner tries to install that list on a clean Linux server, those host-specific packages fail to install and break the build.

For a uv-managed application, commit both pyproject.toml and uv.lock to Git, pin the uv version itself (for example, with UV_VERSION on Render), and install dependencies during CI and deployment with:

The --locked sync flag fails the build if uv.lock doesn't match pyproject.toml, whereas --frozen installs from uv.lock without checking whether pyproject.toml was edited. A new release on PyPI doesn't mark uv.lock outdated, so your indirect dependencies stay locked until you intentionally run uv lock --upgrade.

The PEP 751 (pylock.toml) specification

PEP 751 defines the pylock.toml specification, Python's new standard lockfile format for tool-agnostic, reproducible installations. On Render, keep uv.lock in your repository root so the native runtime enables uv, and export a pylock.toml file whenever an external security scanner or audit tool requires the standard format:

How do you pin Ruby versions?

In Ruby, your language version, Bundler version, and gem dependencies are recorded across .ruby-version, Gemfile, and Gemfile.lock. If those files disagree, your deployment platform may run a different Ruby version than your local shell.

Keeping .ruby-version, Gemfile, and Gemfile.lock in sync

Inside your Gemfile.lock file, Bundler stores three separate pieces of information:

  • In RUBY VERSION, Bundler records the Ruby interpreter version whenever your Gemfile includes a ruby directive. On Render, this block in Gemfile.lock (or gems.locked) is the first place the platform checks to select your Ruby interpreter, ahead of .ruby-version.
  • In BUNDLED WITH, Bundler records the exact Bundler version that generated the lockfile.
  • In GEM, GIT, and PATH, Bundler records the actual direct and indirect gem versions.

To prevent .ruby-version and Gemfile from drifting apart, put your exact Ruby version (such as 3.4.4) in .ruby-version (which local version managers like rbenv, rvm, and mise read automatically) and tell your Gemfile to read that file directly:

Whenever you upgrade .ruby-version, update the RUBY VERSION block inside Gemfile.lock and commit both files to Git:

Locking installs with Bundler 4

Bundler 4 removes legacy CLI flags like --deployment and --frozen that Bundler previously had to remember across runs, replacing them with explicit configuration settings.

To enforce strict lockfile checks and skip test gems during CI and production builds, configure Bundler before running bundle install:

With deployment enabled, Bundler installs gems from Gemfile.lock and fails immediately if the lockfile is out of sync with your Gemfile.

How do you align versions across local dev, GitHub Actions, and Render?

Pinning versions in your repository only works if your CI runner and production platform read the same records. We use a Python application managed with uv as the example throughout this section, but the same principles apply to Node.js, Ruby, Java, Go, and any other language stack:

Pinning level
Repository source of truth
GitHub Actions (CI)
Render (Production)
1. OS & system libraries
Dockerfile (FROM ...@sha256:...) when custom OS packages are needed
Run job in matching container: image or Ubuntu runner
Managed Debian Bookworm base on native runtimes, or exact image pin with runtime: docker in render.yaml
2. Language interpreter
.python-version (3.13.5)
actions/setup-python (python-version-file: ".python-version")
Read natively from .python-version, or set explicitly via PYTHON_VERSION in render.yaml
2b. Package manager
uv version (0.12.24)
astral-sh/setup-uv (version: "0.12.24")
Enabled automatically by uv.lock and pinned via UV_VERSION in render.yaml
3 & 4. Direct & indirect packages
pyproject.toml and uv.lock committed to Git
uv sync --locked
uv sync --locked in buildCommand inside render.yaml

1. Point GitHub Actions at your repository version files

Instead of hardcoding a Python version inside your CI configuration, point your workflow steps at the version files committed to your repo. For example, the .github/workflows/ci.yml workflow below configures the setup-python action to read .python-version directly, pins uv in setup-uv, and verifies uv.lock before running tests:

Setting python-version-file: ".python-version" explicitly is safer than omitting python-version. Although omitting the input also checks .python-version, if that file is ever renamed or missing, the action silently falls back to whatever Python binary is on PATH.

2. Codify production builds with a Render Blueprint (render.yaml)

When you deploy a Python service on Render, the native runtime checks the PYTHON_VERSION environment variable first (full major.minor.patch required) and then .python-version. Including uv.lock in the service root automatically enables uv, which you can pin with UV_VERSION.

To keep your production build command and package manager pins version-controlled alongside your code, define your service in a Render Blueprint (render.yaml). For example, the render.yaml file below codifies the build command and exact Python and uv versions for a Python web service:

When you need to lock Level 1 (the OS and system libraries) or pin exact versions for languages like Java or Go (where Render's native Go runtime tracks the latest stable 1.x release), set runtime: docker in render.yaml and build from a Dockerfile with a pinned base image.

Why does cloud platform architecture matter for version pinning?

Committing exact version files in Git only protects you if your cloud platform honors them. On Render, your services run on persistent compute that reads your exact major.minor.patch version files (.node-version, .python-version, Gemfile.lock) or render.yaml environment pins directly. Your language interpreter and package manager stay on the exact patch version you tested in CI until you choose to upgrade, and when you also need to freeze the underlying OS packages, you can deploy a Dockerfile on the same platform.

By contrast, serverless function platforms like Vercel only let you select a major Node.js version (such as 24.x or 22.x) and apply minor and patch updates automatically, while Vercel's Ruby runtime ignores patch numbers in Gemfile in favor of the latest 3.3.x. Similarly, buildpack-based platforms like DigitalOcean App Platform tie available Node.js and Python versions to the active Ubuntu buildpack stack.

How do you roll out version upgrades safely?

Because pinning exact minor and patch versions makes you responsible for security updates, treat version upgrades as deliberate, testable changes rather than surprise side effects of a deploy:

  1. Isolate updates in dedicated pull requests: Keep routine production deploys locked to your committed lockfile. Use scheduled automation such as Renovate or Dependabot to open separate pull requests for language and dependency bumps, and configure a minimum release age (such as a 7-day soak period for non-emergency updates) to avoid newly published broken releases.
  2. Verify in preview environments before merging: Test version bumps in isolated Render Preview Environments and check the build logs to confirm the exact interpreter, package manager, and packages selected.
  3. Know what a rollback restores: On Render, a rollback reuses a previous deploy's compiled build artifact, start command, and service environment variables (such as PYTHON_VERSION and UV_VERSION), letting you recover quickly without re-running package resolution from source. Note that rollbacks do not revert platform-level native runtime updates applied since that deploy or roll back persistent disk data.

How do you diagnose silent upgrades?

Even when your lockfiles (levels 3 and 4) haven't changed, updates to the underlying operating system (level 1) or the language interpreter (level 2) can still break a build or cause unexpected runtime errors. Two hidden dependencies cause most of these surprises:

1. Packages that rely on compiled C or C++ code

Many popular Python, Node.js, and Ruby packages aren't written purely in those languages. Libraries for database connections, image processing, data science, or cryptography (such as psycopg2, Pillow, numpy, or bcrypt) wrap lower-level C or C++ code for speed.

Normally, your package manager downloads a precompiled version of that C or C++ code built specifically for your exact language version and operating system. When you upgrade your language interpreter (for example, moving from Python 3.12 to 3.13), the older precompiled binary no longer works with the new interpreter. If the package author hasn't published a precompiled binary for your new language version yet, your package manager tries to compile the C or C++ code from scratch during deployment. If the server is missing the required OS-level system libraries or C compilers, the build fails even though your lockfile never changed.

2. OpenSSL and stricter security defaults

Your application relies on a system security library called OpenSSL to make encrypted HTTPS and database connections. Node.js bundles its own copy of OpenSSL inside the Node binary, while Python and Ruby typically use the OpenSSL library installed on the host operating system.

When a language or OS update upgrades OpenSSL, the new version often enforces stricter security rules and blocks outdated encryption protocols or legacy certificates. If your app suddenly fails to connect to an older external API or database with an SSL/TLS error after a runtime update, check whether the OpenSSL version changed before debugging your application code.

Isolating environment drift in build and runtime logs

To catch these shifts quickly, log your exact language version, package manager version, and OpenSSL version when your service starts up, and route your logs to an observability tool using Render log streams. Comparing the startup versions between a working preview environment and a failing deploy tells you right away whether a runtime or OS library changed underneath your code.

Conclusion

Keeping local development, CI, and production on the exact same versions shouldn't require maintaining custom build pipelines. By locking all four levels of your stack (the OS or container image, the language interpreter and package manager, direct packages, and indirect dependencies), you eliminate silent version drift, keep build caches fast, and make every upgrade a deliberate change tested in a preview environment.

Render makes reproducible builds straightforward. Native runtimes read the version files and lockfiles already in your repo, Blueprints (render.yaml) keep your build commands and version pins in Git, and Docker support lets you lock the full OS layer whenever your stack requires it.

Get started for free on Render

Frequently asked questions