|

Nodenv alternative with Docker

I work in many different projects which use many different Node versions. This can lead to a jungle of node versions. There are tools like nodenv to install multiple Node versions and make them behave. However, I have had problems with this tool from time to time.
There are also specialized tools like ddev. These allow to define the php and node versions used per project. A Docker stack is then set up per project. This has the advantage that no component of a LAMP stack has to be installed locally - everything runs via Docker.
But there is also often the case that I work in pure Node projects. That's why I don't always want to have to set up a Docker stack first. I want to be able to use the node and yarn commands in the console as usual.
So why not just combine both?

This article is intended for Linux users (including WSL2).

Some limitations and info in advance:

  • with Docker Desktop (i.e. install Docker in Windows) the option --net=host is not possible, because the
    Docker network is isolated from Windows and WSL. To make it work in WSL, Docker must be installed directly in WSL.
    [instructions for Ubuntu]. Instructions for Ubuntu.
    Compared to Docker Desktop, this does not allow Windows containers to run.
  • on MacOS you have to expect very bad performance
  • in the article node and yarn are completely removed from the system. The aliases node and yarn can be used normally.
    be used normally. However, this is not necessary. It is possible to assign other aliases. For example dnode
    and dyarn
  • The current directory is always mounted in the container. I.e. the container has no access to files in the
    to files in the parent directory. e.g. node ../index.js would not work.

Remove node and yarn completely from the system (optional)

# Arch
sudo pacman -Rns node yarn 

# Ubuntu
sudo apt-get purge --auto-remove nodejs yarn

# homebrew
brew rm node yarn

Remove global node_modules

rm -rf `~/.config/yarn` 

If nodenv is installed, this can also be removed.

rm -rf `nodenv root` 

Custom node executeable

On the system there is now neither the node command, nor yarn. So that these can be used again
new ones must be created.

The file must be made executable with chmod +x node!

~/.dotfiles/bin/node

#!/bin/zsh
# The variable NODE_VERSION is set to latest. By default always the 
# latest node version is used by default
NODE_VERSION="latest"
# Check if there is a .node-version file in the current directory
if [[ -f ".node-version" ]]
then
  # the content of .node-version is written to LOCAL_VERSION
  LOCAL_VERSION=$( cat .node-version )
  # regex checks if LOCAL_VERSION is a valid node version
  if [[ $LOCAL_VERSION =~ ^([0-9]{1,2})(\.[0-9]{1,2}){0,2}$ ]]
  then 
    # NODE_VERSION is set to the value from .node-version. However
    # only the major version is taken into account.
    NODE_VERSION=$match[1]
  fi
fi

docker run \
# -it = to show output and keep the process open
# --init = to make the container killable with CTRL + C
# --rm = delete container after termination
# --net=host Container shares network with host. I.e. access to e.g. 
# webpack-dev-server is possible from hot normally
-it --init --rm --net=host \
# so that the container creates files with the current user
-u $(id -u ${USER}):$(id -g ${USER}) \
# Docker volume from the home directory. Darin werden globale node_modules und symlinks zu 
# executables gespeichert. Diese sollen persitent sein!
# Es wird ein docker volume genutzt, da bei einem bind mount root der Eigentümer wäre
# und es Rechteprobleme gäbe
-v home_node:/home/node \
# docker-enstrypoint.sh überschreiben.
-v ~/.dotfiles/config/docker-entrypoint.sh:/usr/local/bin/docker-entrypoint.sh \
# aktuelles Arbeitsverzeichnis in den Container mounten
-v "$PWD":/usr/src/app \
# Arbeitsverzeichnis im Container setzen
-w /usr/src/app/ \
# <IMAGE>:<TAG>
# [email protected] sind die Parameter hinter dem Befehl
node:$NODE_VERSION [email protected]

Custom yarn executeable

~/.dotfiles/bin/yarn
Die Datei muss mit chmod +x yarn ausführbar gemacht werden!

#!/bin/zsh
node yarn [email protected]

To make NPM Scrips work in VsCode, the path to the custom files must be specified:

"terminal.integrated.env.linux": {
  "PATH":"${env:HOME}/.dotfiles/bin:${env:PATH}"
},

Add path in ZSH

Also the terminal must be told where the node and yarn file are located. The following code must be
in the ~/.zshrc file:

# Add custom executables to PATH
export PATH=~/.dotfiles/bin:$PATH

# Make an alias for each executable in docker volumne
for NODE_BIN in $( ls $(docker volume inspect home_node | grep -oP '(?<="Mountpoint": ")[^"]*')/.yarn/bin )
do
  alias $NODE_BIN="node $NODE_BIN"
done

The For loop looks which global node_modules have been installed. For each entry the
loop creates an alias for each entry. So cli programs like webpack can be executed normally.
However, after yarn global add webpack the configuration must be reloaded. This is done with

. ~/.zshrc

Custom docker-entrypoint.sh

Installed node-cli programs get a symlink in /usr/local/bin. However, these symlinks are lost
lost on container reboot. However, it is not possible to mount this directory, because
the node executeable is also located in it. The node executeable should always be the one of the current
container tag! Instead the docker-entrypoint.sh is overwritten.
The only change is the line starting with export. This way the container knows that it should also look for executeables in the
specified path it should also look for executeables. So even after the container restart it is possible to run
to start globally installed node-cli programs.

~/.dotfiles/config/docker-entrypoint.sh

#!/bin/sh
set -e

export PATH="$PATH:/home/node/.config/yarn/global/node_modules/.bin"

if [ "${1#-}" != "${1}" ] || [ -z "$(command -v "${1}")" ]; then
  set -- node "[email protected]"
fi

exec "[email protected]"

Info: the .bashrc is not loaded! Therefore the PATH is written to the docker-entrypoint.sh instead.
instead.

in action

Network access

The hello-world node app is reachable from the host normally, because --net=host is used:

reachable from host normally

Version management

Without a .node-version file the latest node version is used. After creating the
.node-version with content 14, the latest image of Node 14 is used:

different node versions

Summary

Advantages:

  • automatic updates
  • no local installation of node and yarn necessary
  • feels like a native installation
  • no nodenv issues and manual installation of missing node versions.

Disadvantages:

  • too lame on macOS
  • more complicated one-time setup. However, the .dotfiles directory can be a private repo.
    Then on a new system just run git clone and everything is ready to go.
  • zshrc must be reloaded after a global node_module is installed.