Node.js as Web Application Runtime

Node.js as Web Application Runtime

Bitnesia Aug 28, 2026 2 ID

PHP-FPM, which we recently connected to Nginx and Apache in Chapter 14, handles each request through a separate worker process pool, a model proven mature for classic PHP applications such as WordPress as well as frameworks like Laravel. Developers in many companies now also frequently ask Sysadmins to install other types of web applications on the same server, ranging from Express-based REST APIs to real-time applications such as chats or dashboards that update data without reloading the page. Such applications are almost always built on top of Node.js, a server-side JavaScript runtime whose execution model is vastly different from PHP-FPM, and it is precisely this difference that makes it suitable for workloads that must handle many concurrent connections.

This chapter begins with the foundational concepts of Node.js, specifically the execution model that allows a single process to serve thousands of connections without spawning a new execution path for each request, in direct contrast to the PHP-FPM worker pool in Chapter 14. Next, we compare four common Node.js installation paths found in the field: the official Ubuntu repository, NodeSource, NVM, and fnm, complete with their respective advantages and pitfalls, before practicing hands-on installation of the Long-Term Support (LTS) version via NodeSource, multi-version installation via NVM and fnm, and enabling pnpm as an alternative package manager to npm across each of these paths. The chapter concludes by running a Node.js application as a systemd service that survives reboots, following the unit file patterns we learned in Chapter 5, while preparing a real backend to be used when discussing reverse proxies and load balancing in Chapter 16.

15.1 Understanding Node.js: Event-Driven and Non-Blocking I/O

Node.js is a server-side JavaScript runtime built on the V8 engine, the same engine that runs JavaScript inside Google Chrome. Its presence allows JavaScript code, which originally lived only in the browser, to run directly on the server to handle requests, access the filesystem, or communicate with databases, exactly like the role of PHP in Chapter 14 or other backend languages.

The fundamental difference between Node.js and PHP-FPM lies in how both handle multiple requests simultaneously. PHP-FPM, as discussed in Section 14.2.2, runs a set of worker processes in a single pool, and each worker handles one request at a time until completion. Node.js, on the other hand, essentially runs as a single process per instance, and that process never stops waiting for one operation to complete before handling another request. This model is called event-driven: every event, from an incoming HTTP request to the completion of a file read, is treated as an event queued for processing, rather than a linear flow that blocks the process.

15.1.1 Single Thread, Many Connections: How the Event Loop Works

At the core of Node.js's event-driven model is the event loop, an endless loop that continuously checks whether there are events ready to be processed. When Node.js code performs an I/O operation such as reading a file, sending a database query, or calling an external API, that operation is handed off to the operating system layer via an internal library called libuv, and the event loop is free to continue with other work instead of idling. Once the I/O operation finishes, its result is returned to the event loop in the form of a callback executed on the next turn. This pattern is called non-blocking I/O, and it is the reason a single Node.js process can serve thousands of concurrent connections without spawning a separate thread for each connection as in some traditional server models.

In practice, this characteristic makes Node.js exceptionally well-suited for applications dominated by I/O operations such as REST APIs, proxies, WebSocket-based real-time applications, or services that spend more time waiting for responses from other services than performing heavy computation. Conversely, CPU-bound processes that heavily load the processor for extended periods, such as high-resolution image processing or large-scale data encryption, can block the event loop and delay all other connections, because there is fundamentally only one main thread running the JavaScript code. Cases like this are usually addressed using Node.js's built-in worker_threads module or by offloading heavy processing to another service, an advanced topic beyond the scope of this chapter.

15.2 Installation Methods: Ubuntu Repository, NodeSource, NVM, and fnm

There are four common paths to install Node.js on Ubuntu Server, each with different trade-offs regarding versions, installation scope, and suitability for production environments. Choosing the right path from the beginning saves Sysadmins from tedious migrations later, especially when developer applications require a specific Node.js version or package manager unavailable through an already used path.

15.2.1 Official Ubuntu Repository

The simplest way is to install the nodejs package directly from the official Ubuntu repository.

sudo apt update
sudo apt install nodejs npm

The nodejs package in Ubuntu Server 26.04 resides in the universe component, not main, and provides Node.js version 22.x, which at the time of writing this module holds Maintenance LTS status rather than being the newest version. Contrary to typical package behavior, npm here is not a mandatory dependency but merely recommended, so it must be explicitly specified in the apt install command as shown in the example above, or the nodejs package alone will be installed without npm at all. The advantage of this path is its simplicity and full integration with Ubuntu's update cycle, including unattended-upgrades from Chapter 6, but the Node.js version obtained almost always lags behind the latest official release, and only one version can be installed system-wide at a time.

The nodejs package in this path also includes the node-corepack dependency, which is Corepack, the official Node.js utility bridging to alternative package managers such as pnpm and Yarn without separate installation. Because Node.js in this path is installed system-wide in /usr/bin, enabling pnpm still requires sudo.

sudo corepack enable pnpm
pnpm -v

15.2.2 NodeSource as a Third-Party Repository

NodeSource is a third-party APT repository provider specifically distributing official Node.js packages from nodejs.org, following official releases much faster than the Ubuntu repository. NodeSource explicitly provides major version choices, such as 22.x for the Maintenance LTS path or 24.x for the Active LTS path, allowing Sysadmins to select the exact version required by developer applications. Just like the Ubuntu repository, the resulting installation remains system-wide via apt, placed in standard paths like /usr/bin/node, and its nodejs package automatically includes npm without requiring separate installation. This combination of up-to-date versions and consistent file paths makes NodeSource the most common choice for production servers and serves as the path we practice in Section 15.3. Node.js packages from NodeSource also carry the same Corepack as the Ubuntu repository, enabling pnpm to be activated in a similar manner, fully demonstrated in Section 15.3.3.

15.2.3 NVM as a Per-User Version Manager

NVM (Node Version Manager) takes a completely different approach by not installing Node.js via apt, but instead downloading Node.js binaries directly into the .nvm directory inside the user's home directory. A single user can install multiple Node.js versions simultaneously and switch between them at any time, even configuring different versions automatically per project via a .nvmrc file. This pattern is ideal for developer workstations that must test applications across multiple Node.js versions, but it is less ideal as a foundation for production services because binary paths depend on the active user and version rather than a fixed path like /usr/bin/node, a consequence requiring special handling when connecting to systemd in Section 15.6.2. Corepack remains available in every Node.js version installed via NVM, allowing pnpm to be enabled per version without sudo, practiced in Section 15.4.3.

15.2.4 fnm as a Lightweight Alternative to NVM

fnm (Fast Node Manager) is another Node.js version manager with a concept similar to NVM, installing multiple Node.js versions side-by-side inside a user's home directory, complete with support for .node-version and .nvmrc files to lock versions per project. The primary difference lies in implementation: fnm is written in Rust and distributed as a single binary, making its initialization process in every new shell session significantly faster than NVM, which is written as a collection of shell script functions. This difference is generally unnoticeable for daily use, but becomes apparent on servers with many short SSH sessions opened and closed frequently, or in startup-sensitive CI/CD pipelines. As a trade-off, fnm is less popular than NVM, resulting in fewer community resources and troubleshooting guides. Complete installation steps are practiced in Section 15.5.

15.2.5 Comparison Table and Usage Recommendations

These four installation paths are not mutually exclusive replacements, but rather suit different scenarios.

AspectUbuntu RepositoryNodeSourceNVMfnm
Version ScopeSingle fixed version (22.x, Maintenance LTS)Explicit major version choice (LTS or Current)Multiple versions side-by-side per userMultiple versions side-by-side per user
Installation ScopeSystem-wideSystem-widePer user (home directory)Per user (home directory)
Release SpeedSlow, follows Ubuntu freeze cycleFast, directly from nodejs.orgFast, directly from nodejs.orgFast, directly from nodejs.org
Binary PathFixed (/usr/bin/node)Fixed (/usr/bin/node)Changes based on active version and userChanges based on active version and user
pnpm Activation via CorepackRequires sudoRequires sudoWithout sudoWithout sudo
Systemd/Production SuitabilityFair, with version limitationsMost suitableRequires explicit path adjustmentRequires explicit path adjustment

The most common recommendation in the field is NodeSource for production servers running applications as services, whereas NVM and fnm are equally suitable for development or staging machines requiring rapid version switching, depending on preference for shell execution speed or community maturity. Subsequent sections demonstrate installation via NodeSource, NVM, and fnm sequentially, as the Ubuntu repository path has been sufficiently practiced in Section 15.2.1.

15.3 Node.js LTS Installation via NodeSource

This section installs Node.js via the NodeSource repository using version 24.x, which holds Active LTS status at the time of writing this module. Important note: Active LTS version numbers change approximately every year according to the official Node.js release schedule, so re-check the official release page at nodejs.org to ensure the relevant version number before executing the following steps on actual servers.

15.3.1 Adding NodeSource Repository and GPG Key

NodeSource distributes a setup script that handles GPG key addition and repository registration simultaneously, significantly reducing manual steps required by Sysadmins compared to manually adding third-party repositories.

Practical Steps

  1. Install dependencies required for the installation process.
    sudo apt update
    sudo apt install -y ca-certificates curl gnupg
  2. Download the setup script to a local file first rather than executing directly via a pipe from curl, allowing contents to be inspected before running with root privileges, following the same practice as the Composer installer verification in Section 14.5.1.
    curl -fsSL https://deb.nodesource.com/setup_24.x -o nodesource_setup.sh
  3. Run the setup script using sudo -E. The -E option preserves user environment variables, required by this script to detect network proxy configurations if present.
    sudo -E bash nodesource_setup.sh
    This script adds the NodeSource GPG key to /etc/apt/keyrings/nodesource.gpg, registers the new repository in /etc/apt/sources.list.d/nodesource.list, and automatically runs apt update under the hood.
  4. Verify that the repository is properly registered.
    cat /etc/apt/sources.list.d/nodesource.list

Verification and Troubleshooting

  • To install another major version, such as 22.x with Maintenance LTS status, change the number in the setup script URL to setup_22.x. Avoid using setup_lts.x for production servers because that alias tracks the latest LTS, which can switch to a new major version without explicit notice each time the script is re-run, unlike explicitly specifying version numbers which yields consistent results.
  • Errors related to GPG or invalid signatures usually indicate the script download process was interrupted mid-way. Remove nodesource_setup.sh and repeat the download process from the beginning.

15.3.2 Node.js Installation and Verification

The NodeSource repository registered in Section 15.3.1 makes the latest nodejs package directly available via apt, replacing old versions from Ubuntu repositories if previously installed.

Practical Steps

  1. Install Node.js.
    sudo apt install -y nodejs
  2. Check installed Node.js and npm versions.
    node -v
    npm -v
    Unlike the nodejs package in Ubuntu repositories in Section 15.2.1, this package from NodeSource includes npm without requiring separate installation.
  3. Run a single line of JavaScript code directly from the terminal as a quick test.
    node -e "console.log('Node.js is ready for use on this server')"
  4. Record the location of the Node.js binary, as this path will be used directly in the systemd configuration in Section 15.6.2.
    which node

Verification and Troubleshooting

  • Output from which node should show /usr/bin/node. If another path appears, such as /usr/local/bin/node, another Node.js installation was likely installed previously on the server, for instance via a manual tarball, creating potential conflicts.
  • The nodejs package from NodeSource updates automatically via unattended-upgrades from Chapter 6 as long as it remains within the same major version, but upgrading to a new major version must be done manually by repeating Section 15.3.1 with the new version number.

15.3.3 Enabling pnpm via Corepack

pnpm is an alternative package manager to npm that stores all dependencies in a centralized content-addressable store and connects them to each project via symlinks, drastically saving disk space and accelerating dependency re-installation compared to npm or classic Yarn, especially on servers running multiple Node.js projects simultaneously. From Node.js 14.19 up to version 25, official Node.js releases include Corepack by default, an official utility bridging Node.js with pnpm or Yarn without requiring manual installation via npm install -g, which risks producing inconsistent versions across servers.

Practical Steps

  1. Verify Corepack is available alongside the Node.js installation from Section 15.3.2.
    corepack --version
  2. Enable the pnpm shim. Since Node.js from NodeSource is installed system-wide in /usr/bin, activation requires sudo.
    sudo corepack enable pnpm
  3. Verify pnpm can be called.
    pnpm -v

Verification and Troubleshooting

  • The first attempt to run pnpm usually displays a Corepack is about to download... prompt, because Corepack downloads specific pnpm binaries only when actually needed. For non-interactive automation such as CI/CD scripts, set the environment variable COREPACK_ENABLE_DOWNLOAD_PROMPT=0 to allow this process to run without waiting for confirmation.
  • Per-project pnpm versions can be locked via the packageManager field in package.json, for example "packageManager": "[email protected]", configured automatically using the corepack use pnpm@<version> command inside project directories.
  • Node.js 25 and above no longer include Corepack by default. If migrating to those versions, install Corepack manually first via sudo npm install -g corepack before running corepack enable pnpm above.

15.4 Multi-Version Installation via NVM

A common scenario Sysadmins face involves developers needing to test legacy applications bound to Node.js version 22.x, while other projects on the same machine use version 24.x. This section demonstrates installing NVM to satisfy multi-version requirements for regular users, separate from system-wide Node.js installations via NodeSource installed in Section 15.3.

15.4.1 Installing NVM

Practical Steps

  1. Run the official NVM install script as a regular user, not root, as NVM is designed to be installed per user.
    curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.7/install.sh | bash
    This script clones NVM into the ~/.nvm directory and adds loader lines to shell profile files like ~/.bashrc so that the nvm command is available automatically in every new terminal session.
  2. Reload the shell profile without logging out, or open a new terminal session.
    source ~/.bashrc
  3. Verify that the nvm command is recognized.
    command -v nvm

Verification and Troubleshooting

  • If command -v nvm produces no output, check whether NVM loader lines were written to ~/.bashrc, because alternative shells like fish do not use this file and require separate NVM plugins not covered in this chapter.
  • NVM is intentionally not installed with sudo or for the root user. Re-installing as a different user on the same server produces completely separate .nvm directories.

15.4.2 Managing Multiple Node.js Versions with NVM

Practical Steps

  1. View available LTS versions for installation.
    nvm ls-remote --lts
  2. Install two versions simultaneously to simulate multi-version needs: the latest LTS version and version 22.x.
    nvm install --lts
    nvm install 22
  3. View all versions installed via NVM for this user.
    nvm ls
  4. Switch between versions and check the active version each time.
    nvm use 22
    node -v
    nvm use --lts
    node -v
  5. Set a default version that automatically activates in new terminal sessions.
    nvm alias default 22
  6. Create a demo project directory that locks its own Node.js version via .nvmrc, independent of the default version above.
    mkdir ~/demo-project && cd ~/demo-project
    echo "lts/*" > .nvmrc
    nvm use
    The nvm use command without arguments inside a directory containing a .nvmrc file automatically reads file contents and switches to the specified version.

Verification and Troubleshooting

  • Active Node.js binary paths under NVM always follow the selected version and user running it, such as ~/.nvm/versions/node/v22.x.x/bin/node, distinctly different from fixed paths like /usr/bin/node from NodeSource in Section 15.3.2. Check with the following command:
    which node
  • Both Node.js installations, via NodeSource and NVM, can coexist without conflicts on the same server because they use completely different paths and do not overwrite each other.
  • This path difference is why NVM requires special handling when running applications as systemd services, discussed in Section 15.6.2.

15.4.3 Enabling pnpm via Corepack in NVM

Corepack is included with every Node.js version downloaded by NVM in Section 15.4.2, as binaries downloaded by NVM are identical to official distributions from nodejs.org. Unlike Section 15.3.3, activation here does not require sudo at all, because the entire Node.js installation via NVM resides within the user's home directory rather than system directories.

Practical Steps

  1. Ensure the Node.js version for which pnpm should be enabled is currently active.
    nvm current
  2. Enable the pnpm shim for the currently active Node.js version.
    corepack enable pnpm
  3. Verify.
    pnpm -v

Verification and Troubleshooting

  • This pnpm shim is placed inside the bin directory of the active Node.js version when executed, rather than a shared global location. Whenever installing a new Node.js version via nvm install, repeat corepack enable pnpm for that version before use.
  • Notes regarding download prompts and locking pnpm versions via packageManager in package.json from Section 15.3.3 apply identically here.

15.5 Multi-Version Installation via fnm

This section practices fnm as an alternative to NVM whose concepts were introduced in Section 15.2.4, suitable for Sysadmins or Developers prioritizing shell startup speed over NVM's established ecosystem maturity.

15.5.1 Installing fnm

Practical Steps

  1. Run the official fnm install script as a regular user, similar to NVM, as fnm is also designed to be installed per user.
    curl -fsSL https://fnm.vercel.app/install | bash
    This script downloads the fnm binary to ~/.local/share/fnm and adds initialization lines to shell profile files like ~/.bashrc.
  2. Reload the shell profile or open a new terminal session.
    source ~/.bashrc
  3. Verify that the fnm command is recognized.
    fnm --version

Verification and Troubleshooting

  • If fnm --version is not recognized after opening a new terminal, check whether the fnm env initialization line was written to ~/.bashrc, following NVM troubleshooting patterns in Section 15.4.1.
  • fnm and NVM can be installed side-by-side for the same user without conflicts because both store Node.js binaries in completely different directories, but only one should be actively used to avoid confusion regarding active versions.

15.5.2 Installing and Managing Node.js Versions with fnm

Practical Steps

  1. Install Node.js version 24.x via fnm.
    fnm install 24
  2. Check the active version.
    node -v
    Output displays full version numbers such as v24.20.0, matching the latest patch release of the Active LTS path when executed, so exact sub-version numbers may vary.
  3. View all versions installed via fnm.
    fnm list
  4. Set a default version that automatically activates in new terminal sessions, equivalent to nvm alias default in Section 15.4.2.
    fnm default 24
  5. Create a .node-version file in a test project directory to lock its Node.js version, the default format used by fnm.
    mkdir ~/demo-project-fnm && cd ~/demo-project-fnm
    echo "24" > .node-version
    fnm use

Verification and Troubleshooting

  • fnm also reads .nvmrc files if .node-version is missing, so project directories previously created for NVM in Section 15.4.2 remain automatically readable by fnm without modification.
  • Auto-switching versions on directory changes (cd) is disabled by default, activating only when eval "$(fnm env --use-on-cd --shell bash)" is present in shell profile files, typically added automatically by the official installer in Section 15.5.1 for detected shells.
  • Active Node.js binary paths under fnm can be inspected using the same method as NVM.
    which node

15.5.3 Enabling pnpm via Corepack in fnm

Like NVM, Node.js installed via fnm includes built-in Corepack, allowing pnpm to be enabled without sudo.

Practical Steps

  1. Enable the pnpm shim for the active Node.js version.
    corepack enable pnpm
  2. Verify.
    pnpm -v

Verification and Troubleshooting

  • fnm provides a shortcut combining Node.js installation and Corepack activation in a single command using the --corepack-enabled flag when installing new versions.
    fnm install 24 --corepack-enabled
  • Notes regarding pnpm download prompts and locking versions via packageManager in package.json from Section 15.3.3 apply identically here.

15.6 Running Applications as Services with Systemd

Node.js applications launched directly from terminal sessions terminate immediately when SSH sessions close or servers reboot, making direct invocation unsuitable for production environments. This section applies systemd unit file patterns learned in Section 5.2, tailored specifically for Node.js applications installed via NodeSource in Section 15.3, enabling applications to start automatically on boot and auto-recover from crashes.

15.6.1 Creating a Simple Node.js Application

As an example, we create a small HTTP server application using Node.js's built-in http module without external framework dependencies like Express, keeping focus on systemd configuration rather than application development.

Practical Steps

  1. Prepare the application directory.
    sudo mkdir -p /opt/demo-node
  2. Create the server.js file.
    sudo nano /opt/demo-node/server.js
  3. Insert the following code:
    const http = require('http');
    const os = require('os');
    
    const port = process.env.PORT || 3000;
    
    const server = http.createServer((req, res) => {
      res.writeHead(200, { 'Content-Type': 'text/plain' });
      res.end(`Node.js active on ${os.hostname()}, server time: ${new Date().toISOString()}\n`);
    });
    
    server.listen(port, () => {
      console.log(`Server running on port ${port}`);
    });
    The PORT environment variable is read via process.env rather than hardcoded, allowing port changes later via systemd configuration without modifying application code.
  4. Test running directly from terminal to verify there are no errors before creating a service.
    node /opt/demo-node/server.js
  5. From another terminal session, test with curl.
    curl http://localhost:3000
  6. Stop the manual process using Ctrl+C before proceeding to the next step.

15.6.2 Creating a Systemd Unit for the Node.js Application

Practical Steps

  1. Create a dedicated system user for this application, following least privilege principles from Section 5.2.2.
    sudo useradd --system --no-create-home --shell /usr/sbin/nologin nodeapp
  2. Set ownership for the application directory.
    sudo chown -R nodeapp:nodeapp /opt/demo-node
  3. Create a new unit file.
    sudo nano /etc/systemd/system/demo-node.service
  4. Insert the following configuration:
    [Unit]
    Description=Demo Node.js Application
    After=network-online.target
    Wants=network-online.target
    
    [Service]
    Type=simple
    User=nodeapp
    Group=nodeapp
    WorkingDirectory=/opt/demo-node
    Environment=NODE_ENV=production
    Environment=PORT=3000
    ExecStart=/usr/bin/node /opt/demo-node/server.js
    Restart=on-failure
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target
    The /usr/bin/node path in ExecStart matches the output of which node recorded in Section 15.3.2, providing a fixed system path independent of specific users.
  5. Notify systemd that a new unit file exists.
    sudo systemctl daemon-reload
  6. Enable and start the service simultaneously.
    sudo systemctl enable --now demo-node.service
  7. Re-test using curl.
    curl http://localhost:3000

Verification and Troubleshooting

  • Check service status and recent log entries.
    systemctl status demo-node.service
  • Follow application logs live, including console.log output from Node.js code automatically captured by journalctl without extra logging setup, similar to Python applications in Section 5.2.2.
    journalctl -u demo-node.service -f
  • Address already in use errors in logs typically indicate the manual process from Section 15.6.1 was not stopped prior to activating the service. Check processes occupying the port:
    ss -tlnp | grep 3000
  • The most common error when Node.js applications are installed via NVM or fnm instead of NodeSource is setting ExecStart to just node without a full path, relying on the PATH active in interactive shell sessions. Systemd runs services without loading login shells or nvm/fnm functions from ~/.bashrc, resulting in immediate failures with node: command not found messages even if node works normally when tested manually. If using Node.js from NVM or fnm as a service foundation, ExecStart must specify full paths to specific binary versions, such as /home/deploy/.nvm/versions/node/v22.x.x/bin/node (obtained via nvm which 22 when logged in as that user). This is the primary reason NodeSource is recommended for production services over NVM or fnm, aligning with trade-offs listed in Section 15.2.5.
  • If real applications use third-party npm dependencies installed via pnpm from Section 15.3.3, 15.4.3, or 15.5.3, this unit file configuration requires no changes. Simply run pnpm install --prod in the WorkingDirectory before first running the service or deploying new versions, as ExecStart calls node directly rather than through pnpm.

Node.js running as a systemd service functions as a standard application backend listening on a single port, identical to PHP-FPM in Chapter 14 or other applications hosting their own HTTP servers. Chapter 16 continues with reverse proxies and load balancing, treating backends such as Node.js, PHP-FPM, or other applications as upstream components whose traffic can be distributed and scaled using Nginx.