mrkaluzny homepage
Get in touch
mrkaluzny homepage
Tools & Tech

How to update Node on a Laravel Forge server

Oct 30, 2026

Laravel Forge installs the latest Node LTS when it provisions a server. When I wrote my Remix deployment post, the server came with an older Node than the app needed, and a server provisioned in 2024 probably runs Node 20, which reached end of life in April 2026. Forge has a whole screen for switching PHP versions. For Node you get a snippet in the knowledge base.

There are two ways to fix it, and they solve different problems:

  1. Upgrade the system Node. One Forge recipe run and every site on the server moves to the new version.
  2. Install fnm. Each site runs the Node version from its own .nvmrc or .node-version, the same file you already use locally. This is the setup from my nvm to fnm migration, turned into a recipe for servers.

Both recipes below were tested on Ubuntu 24.04 with a forge user, run twice in a row to make sure re-running them is safe.

Check which Node your server runs

SSH into the server as forge and run:

node -v
which node
cat /etc/apt/sources.list.d/nodesource.list

If which node prints /usr/bin/node, you’re on the Node that Forge installed from NodeSource. The last file shows which major version the server pulls from, for example node_20.x.

Why apt upgrade doesn’t get you a new Node version

NodeSource publishes a separate apt repository for each major version. Your server is subscribed to one of them, so apt upgrade only brings in patch releases of that line. Going from 20 to 24 means pointing apt at a different repository first.

Option 1: upgrade the system Node with a Forge recipe

In Forge, go to Recipes → New recipe, name it “Upgrade Node.js”, set the user to root and paste this script. Change NODE_MAJOR to the version you want, then run it on your servers from the recipe’s dropdown.

# Switch the server's Node.js to another major version. Run as root.
set -euo pipefail

NODE_MAJOR=24

apt-get update --allow-releaseinfo-change
apt-get install -y ca-certificates curl gnupg
mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" > /etc/apt/sources.list.d/nodesource.list
apt-get update --allow-releaseinfo-change
apt-get install -y nodejs

echo "Node $(node -v), npm $(npm -v)"

This is the snippet from the Forge docs with three changes. It runs as root, so there’s no sudo. set -euo pipefail stops the recipe at the first failed step instead of carrying on. And gpg --yes overwrites the key file when you run the recipe again. Without it, gpg tries to ask whether to overwrite, finds no terminal to ask on, and prints gpg: cannot open '/dev/tty'.

Global npm packages survive the upgrade, because NodeSource always installs into /usr/lib/node_modules. Native modules are a different story. Redeploy each site so npm ci rebuilds them against the new version, then restart your daemons.

It’s the system Node, so every site on the server moves at once. If one app isn’t ready for Node 24, it will break.

Option 2: fnm, a Node version per site

fnm is a Node version manager written in Rust (😍). I moved to it on my Mac because nvm added over half a second to every new shell. On a server, startup time matters less. What sells it there is that fnm is a single binary. With nvm, every deploy script has to source a 4,600-line shell script before it can switch versions. With fnm it’s one eval line.

The fnm recipe

Create another recipe, name it “Install fnm” and set the user to forge. If you use Forge’s user isolation, run it once for each site user.

# Install fnm and set up Node for the user running this recipe.
# Run it as the user that deploys your sites (usually "forge").
set -euo pipefail

NODE_DEFAULT=24
FNM_DIR="$HOME/.local/share/fnm"

if ! command -v unzip > /dev/null; then
  echo "unzip is missing. Install it with: sudo apt-get install -y unzip" >&2
  exit 1
fi

# 1. Install (or update) the fnm binary. --skip-shell: we write our own block below.
curl -fsSL https://fnm.vercel.app/install | bash -s -- --install-dir "$FNM_DIR" --skip-shell
export PATH="$FNM_DIR:$PATH"
eval "$(fnm env --shell bash)"

# 2. Install a default Node version.
fnm install "$NODE_DEFAULT"
fnm default "$NODE_DEFAULT"

# 3. Shell setup. The block sits between markers, so running the recipe again replaces it.
sed -i '/^# >>> fnm >>>$/,/^# <<< fnm <<<$/d' ~/.bashrc
cat >> ~/.bashrc << 'EOF'
# >>> fnm >>>
# Node — fnm (reads .nvmrc, auto-switches on cd)
export PATH="$HOME/.local/share/fnm:$PATH"
eval "$(fnm env --use-on-cd --version-file-strategy=recursive --shell bash)"

# Latest release of each Node LTS line, marked if installed
node-lts() {
  local installed
  installed=$(fnm ls | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | sort -u)
  fnm ls-remote --lts | awk '{print $1, $2}' | tac | awk '!seen[$2]++' | tac \
    | while read -r ver name; do
        local major=${ver%%.*}
        local have=$(echo "$installed" | grep "^$major\." | tail -1)
        if [ "$have" = "$ver" ]; then
          printf '  \033[32m✓\033[0m %-10s %s\n' "$ver" "$name"
        elif [ -n "$have" ]; then
          printf '  \033[33m↑\033[0m %-10s %s   (installed: %s → fnm install %s)\n' "$ver" "$name" "$have" "${major#v}"
        else
          printf '    %-10s %s\n' "$ver" "$name"
        fi
      done
}

# Update every installed LTS line to its latest patch
node-lts-update() {
  fnm ls | grep -oE 'v[0-9]+\.' | sort -u | while read -r major; do
    fnm install "${major#v}"
  done
}
# <<< fnm <<<
EOF

echo "$(fnm --version) installed, default Node: $(fnm exec --using=default node --version)"

The recipe does three things:

  • Installs fnm into ~/.local/share/fnm with the official install script. Running the recipe again downloads the latest release, so it doubles as the fnm updater.
  • Installs Node 24 and makes it the default. Node 26 became LTS this month, so set NODE_DEFAULT to whatever your apps run on.
  • Adds a block to ~/.bashrc with the same fnm env --use-on-cd line and LTS helpers I use on my Mac. One change for Linux: macOS has tail -r, Ubuntu has tac. The block sits between two marker comments, and the recipe deletes it before writing it again, so you never end up with two copies.

After it runs, SSH in and cd into a site with an .nvmrc. fnm switches to that site’s version and prints Using Node v22.23.4, or whatever the file asks for.

Pin the version in your repo

Commit an .nvmrc to each project:

echo 24 > .nvmrc

That’s the file fnm reads on your laptop and on the server, so local and production can’t drift apart. Netlify and most other hosts read it too.

Update the deploy script

The .bashrc block only helps when you SSH in. Forge runs deploy scripts in a non-interactive shell, and Ubuntu’s default .bashrc exits on its first lines when the shell isn’t interactive. Without the next three lines your deploy finds /usr/bin/node, or node: command not found if the server has no system Node.

This is a deploy script for a site with zero-downtime deployments. On a site without them, put the fnm lines right after cd $FORGE_SITE_PATH and git pull.

$CREATE_RELEASE()

cd $FORGE_RELEASE_DIRECTORY

# Node from the repo's .nvmrc, installed on the first deploy that needs it
export PATH="$HOME/.local/share/fnm:$PATH"
eval "$(fnm env --shell bash)"
fnm use --install-if-missing

npm ci
npm run build

$ACTIVATE_RELEASE()

sudo -S supervisorctl restart daemon-12345:*

fnm use --install-if-missing reads .nvmrc from the release directory and downloads that version on the first deploy that needs it. Bumping Node for a site becomes a one-line commit. Replace 12345 with your background process ID from the site’s “Processes” tab.

Point the daemon at fnm

Node apps that run a server, like Remix, need a background process in Forge. Use these settings:

  • Command: /home/forge/.local/share/fnm/fnm exec --fnm-dir=/home/forge/.local/share/fnm --using=.nvmrc -- npm run start
  • Working directory: /home/forge/example.com/current
  • User: forge

fnm exec --using=.nvmrc resolves the version from the file each time the process starts, so the daemon keeps working after you update to a new patch release. A hardcoded path like ~/.local/share/fnm/node-versions/v24.22.2/... breaks as soon as you do.

Don’t skip --fnm-dir. Forge runs background processes through Supervisor, which switches to the forge user but leaves HOME set to /root. fnm then looks for its versions in root’s home folder and the process dies with:

error: Can't list local installed versions: Permission denied (os error 13)

With the flag, fnm knows where to look. If you get Requested version v22.x.x is not currently installed instead, the daemon started before any deploy installed that version. Deploy once and restart it.

Keep every LTS line patched

The helpers from the recipe work over SSH. node-lts lists the newest release of every LTS line and marks the ones you have:

forge@server:~$ node-lts
    v20.20.2   (Iron)
  ✓ v22.23.4   (Jod)
  ✓ v24.22.2   (Krypton)
    v26.13.0   (Lithium)

When a line shows ↑, node-lts-update installs the latest patch for every major version on the server. Restart the daemons afterwards so they pick it up. Old patch versions stay in ~/.local/share/fnm/node-versions until you remove them with fnm uninstall.

Which one should you use?

If the server runs one Node app, or every app is happy on the same version, use the NodeSource recipe. It’s the setup Forge expects, and it takes two minutes.

If the server hosts several Node apps, use fnm. I’d pick it for any new server too. The Node version lives in the repo next to the code that depends on it, and upgrading a site is a pull request instead of an SSH session.