When you manage multiple active projects each day, small inconsistencies and annoyances can easily add up. In some cases, they can quickly contribute to a lack of confidence in your tooling and infrastructure. Declaring the version of Node.js each project uses in multiple places can easily become an inefficiency that goes beyond mere annoyance and actively erodes your team’s confidence.
What’s the Big Deal?
In any given project we might need to declare a single version of Node.js in one or more the following locations:
.nvmrcfile (often used by developers)
- Configuration files for virtual development environments (e.g. Lando, DDEV, Codespaces, etc.)
- Configuration files for CI workflows in GitHub Actions (e.g. test runners, linters, etc)
- Directly installed in our Docker images
- Configuration file for Node.js-only Tugboat environments (PR preview/staging)
- Directly installed for PHP/Apache Tugboat environments
- Production environment configuration file
That’s a lot of opportunities to fall out of sync with every update to the Node.js version! Keep in mind that anyone on our team may be tasked with updating the Node.js version on a project and that each project may have a different combination of files and services where Node.js is configured; it quickly becomes clear that what may appear to be an inconvenience can easily be a source of chaos.
Declaring Node.js in multiple locations has sometimes led to mismatched versions, causing inconsistent results across environments. For example, in one project we experienced an issue with dependency update (via Dependabot) that passed all tests in GitHub Actions and built without regressions in Tugboat, but which after being approved and deployed to production failed unexpectedly. After some sleuthing, we realized that our production environment was woefully out of sync with every other environment: we had v10 explicitly installed in production, v12 installed in Tugboat and declared in the theme’s
.nvmrc file, and all GitHub Actions used LTS (long-term support, which at the time was v14). Since the new major version of this dependency dropped support for Node.js v10, the theme’s static assets could not be built in production, and only production.
In another project, we had the same version of Node.js declared everywhere but GitHub Actions, which uses LTS (long-term support; learn more about Node.js versions in the nodejs.org releases page). All of our checks ran fine for as long as the version used everywhere happened to also be the current LTS version. However, as soon as a new LTS version was declared and GitHub’s images started getting that new version upstream, checks started to fail out of the blue.
A Line in the Sand
As we continue to grow our project roster and our team, it became clear to me that the status quo is simply unacceptable. Not only is this a technical annoyance, but it actively erodes developer confidence in our infrastructure and tooling: PR previews and local development cannot be trusted to reasonably approximate production and when team members realize the number of places the Node.js version is declared, it makes the whole setup seem brittle. Who’s to say there aren’t other Node.js version declarations we aren’t aware of?
I can stand a few technical annoyances, but I draw the line at eroding my team’s confidence. I have made it my personal mission to solve this issue and define a single, canonical place to declare the Node.js version of any project in our roster.
Constraints & Requirements
I gave myself a simple list of requirements:
- Declare the Node.js version in a single place for every project. Preferably, this would be an
.nvmrcfile at the root of the project because most of our team typically uses nvm to install and switch between versions of Node.js during local development. (Personally, I prefer the
.node-versionfilename and we may eventually transition to that, but right now it is more important to me to keep our developer experience intact as a result of this effort. They use nvm, so
- All environments should always use the version declaration file. Regardless of their nature, any environment that uses Node.js in any way must always respect the
.nvmrcfile. In our case, these include:
- CI environments
- Development workstations
- Virtual development environments (Lando, DDEV, Codespaces, etc)
Ultimately, the solution required a multi-pronged approach and I learned a lot about some of the tools we use on a day-to-day basis in the process.
nvm Is Not a Silver Bullet
My first stab at this problem involved using nvm in the Docker images we use in Tugboat. This made sense at a glance: it’s the tool our developers use locally and our production environments are typically hosted in Platform.sh, which supports nvm. However, I quickly learned that nvm is not a good solution for CI environments where shell commands are run in non-login subshell sessions that may not use a shell program supported by nvm. I found myself having to jump through hoops, like sourcing the
.bashrc file before every Node.js-based command or wrapping those commands in a
bash -lc subshell session. This was all due to the fact that nvm requires being sourced before using it or any Node.js tools installed with it.
Although in the end this first approach “worked”, it violates the principle of separation of concerns. It impacted the way front-end asset building commands were run in a variety of places, including our usher library because of the requirement for nvm to be sourced in every session. The solution was adopted, but I didn’t feel like it was definitive and after a few short weeks we rolled back the changes.
Enter n, a More Robust Node.js Installer!
I was not about to give up, so I started looking at ways to keep using
.nvmrc as the canonical declaration of our Node.js version and learned about n. Contrary to nvm, n lends itself to be used in CI environments:
- It is a shell script that you install anywhere it is convenient within a given system, not a shell function that must be sourced.
- It does not need to be sourced or otherwise referenced in order to use whatever version(s) of Node.js have been installed with the tool.
- It installs Node.js to
/usr/local/bin/nodeby default, but you can configure it to install Node anywhere you need within your system.
- It supports a variety of Node.js version declaration files, including
.nvmrcand the more widely-supported
.node-version, among others. (If your team is using nvm locally, you can use n without disrupting their workflows/environments.)
Using n in Tugboat
One of the first hurdles to managing Node.js versions with a single file was installing the correct version in our QA environments, which run on Tugboat. The first step in our solution was to install n and then run
n lts to install Node LTS by default in the Dockerfile for the Docker images. That ensures that any environment that uses our Docker images will have Node.js LTS installed out of the box and can use n to install whatever version of Node.js they require if LTS is not appropriate. Next up, we updated the Tugboat configuration for each project to invoke
n auto from within the
build step. This command checks the project for a Node.js version declaration file (
.node-version, and others) and, if found, will install the version declared in that file if it is different from the version currently available. We aim to keep our projects updated to use LTS at all times, but this step ensures that when a new LTS is released, our projects are not immediately impacted by any breaking changes that might ship with the new major version.
Installing Node.js in Production
This website is hosted on Platform.sh, whose docs recommend using nvm in your project’s
build hook in
.platform.app.yaml. This works as expected, but has some drawbacks. For example, their recommendation does not account for your project’s
.nvmrc file. Instead, the version of Node.js is defined as an environment variable in
.platform.app.yaml, which goes against our goal of managing this version number from a single place. The recommendation may be adjusted to use
.nvmrc with relative ease, but that leads to the second drawback: nvm does not survive the -e flag. If you use
set -e to abort your Platform.sh deployments in the event of a failed build (as you probably should), using nvm forces you to set it only after you use nvm to install Node.js, which in turn means any errors installing nvm or Node.js do not cause a build failure. A final drawback of the recommended path is the sheer size of the recommended implementation. It includes a lot of logic that typically is not a part of how developers are used to interacting with nvm, and this added complexity makes the implementation feel brittle.
As you might already suspect, I bear good news: you can use n to install Node.js in Platform.sh! All you need to is:
Add n as one of your dependencies:
N_PREFIXenvironment variable to
/app/.global, which is a directory that Platform.sh loads into the PATH. This variable tells n where to install Node.js:
In your build hook, install Node.js and refresh the utility hash:
// Install Node.js.
// Refresh the utility hash in this shell session.
And that’s it! n will install Node.js according to the
.nvmrc file at the root of your project. It should be noted that the
hash -r step is required because otherwise Dash (the shell program used in Platform.sh images) will use the version of Node installed by default in its images.
Using .nvmrc in GitHub Actions
With local development, Tugboat, and production environments all in sync using
.nvmrc as the canonical declaration of Node.js versions, the next piece of the puzzle is CI, which in our case means getting GitHub Actions to respect the
.nvmrc file. Default GH Actions container images typically use the LTS version of Node.js, but a custom version can be declared using actions/setup-node, which thankfully supports using a Node version file by setting its
node-version-file key to the path of your file:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
Virtual Development Environments: The Final Frontier
Virtual development environments, such as those provisioned via local tools like Lando or DDEV and cloud-based services like GitHub Codespaces, remain the final frontier in this Node.js version unification saga. While these tools typically make it a snap to create a Node.js-based service and pass a specific version for it in config, there is no way that I know of to have them defer to a file in your project’s codebase for the version of Node used. The codespaces-examples/node project illustrates one way to use nvm, but the implementation is fairly opinionated. For example, it assumes the end user will use zsh or bash, and I can guarantee you’ll run into issues if your project uses automated tools such as robo or even simple shell scripts to perform everyday front-end development tasks using sub-shells.
Thankfully, the vast majority of our developers do not use Node.js via Lando. Instead, they run front-end-specific commands directly in their command line. However, as we begin to experiment with Codespaces, I can see the need to address this use case in the near future.
In trying to address this problem, I learned how nvm works and, as a result, why it is not the best tool for certain use cases. I discovered n and learned that it is a very robust alternative to nvm that is better suited to provisioning CI/CD environments. I was also very pleased to discover that
actions/setup-node provides a very straightforward mechanism to control the Node.js version from a file in your codebase in GitHub Actions workflows.
Keeping a project’s Node.js version consistent across all environments is not as straightforward as one might initially expect, but with a little bit of work and the right tool, it is an achievable goal. Your team’s needs may vary from ours, and some experimentation may be in order, but there is a lot to be gained from ensuring only one version of Node.js is used on your project.