Independent marketplace for cloud provider accounts across 22 providers

Developer Workflows

Deploying a Node.js App on a VPS: A Repeatable Workflow

A fixed six-step sequence for deploying a Node.js application to any virtual private server: harden the machine, install Node from NodeSource or nvm, ship the code, supervise it with PM2 or systemd, front it with Nginx and TLS, and capture the redeploy in a single script.

Diagram of a Node.js process behind Nginx on a single cloud server with a deploy script arrow from a laptop

One Server, One App, One Repeatable Sequence

Deploying Node.js to a virtual private server is not hard, but it is easy to do differently every time. The result is a machine nobody dares to rebuild. This article lays out a fixed sequence you can run on a fresh Droplet, Vultr instance, Hetzner server or EC2 machine and get the same outcome each time.

The shape never changes: harden the box, install a Node runtime, put the code on disk, hand the process to a supervisor, place a reverse proxy in front, and script the redeploy. Once the script exists, shipping a new version is a single command.

Everything below assumes Ubuntu LTS or Debian, a sudo user called deploy, and an application that reads its port from an environment variable. Adjust package names for other distributions; the order does not change.

Step 1: Prepare a Clean Server

Log in as the initial user your provider gave you (often root), patch the system, and create the account the app will run under.

apt update && apt full-upgrade -y

adduser deploy && usermod -aG sudo deploy

rsync --archive --chown=deploy:deploy ~/.ssh /home/deploy

That last line copies your authorized_keys so the new user accepts the same SSH key. If you have not set one up yet, the SSH key guide covers generation and upload at each provider.

Open only what you need with the uncomplicated firewall, then log out and back in as deploy:

ufw allow OpenSSH && ufw allow 80/tcp && ufw allow 443/tcp && ufw enable

Notice that port 3000, or whatever the app listens on, is not opened. The app will bind to localhost and only Nginx will be reachable from outside.

Step 2: Install a Node Runtime the Right Way

Do not use the Node package that ships with the distribution; it is frequently several major versions behind. Two supported approaches exist, and the choice depends on who needs to run node.

System-wide with the NodeSource repository

The Node.js download page links to the NodeSource Debian and Ubuntu repositories. After adding the repository for the major version you want, a plain sudo apt install -y nodejs installs node and npm for every user. This is the simplest option when a systemd unit will start the app, because the binary sits on the default PATH.

Per-user with nvm

The nvm project installs Node into the home directory of whichever user runs it. As deploy, run the installer from the project README, reload the shell, then:

nvm install --lts && nvm alias default 'lts/*'

Commit a .nvmrc file to the repository so nvm use on the server picks the same version developers run locally. Whichever route you pick, finish with corepack enable if the project uses pnpm or Yarn, and confirm with node -v && npm -v.

Step 3: Get the Code on Disk

Create a predictable location and give the deploy user ownership: sudo mkdir -p /srv/app && sudo chown deploy:deploy /srv/app. Two transfer methods are common.

Cloning from Git keeps history on the server and makes rollbacks a checkout away. Add a read-only deploy key to the repository, then git clone git@github.com:org/api.git /srv/app. Alternatively, build locally or in CI and push only the artifacts: rsync -az --delete --exclude node_modules ./dist ./package.json ./package-lock.json deploy@web1:/srv/app/.

Install dependencies exactly as the lockfile describes and without development packages:

cd /srv/app && npm ci --omit=dev

Configuration belongs in an environment file, never in the repository. Create /srv/app/.env with PORT=3000, NODE_ENV=production, database URLs and API tokens, then lock it down with chmod 600 .env. If the project has a compile step, run npm run build now and check that the output directory exists before moving on.

Step 4: Hand the Process to a Supervisor

Running node server.js in a terminal ends the moment you disconnect. Something has to restart the app after crashes and reboots. PM2 is the most common choice in the Node ecosystem; systemd is the choice if you want no extra tooling.

With PM2

npm install -g pm2

pm2 start dist/server.js --name api -i max

pm2 save && pm2 startup systemd

The -i max flag runs one worker per CPU core in cluster mode, which is what later makes zero-downtime reloads possible. The startup command prints a one-line sudo command; run it, and PM2 will resurrect the saved process list at boot. Add pm2 install pm2-logrotate so ~/.pm2/logs does not fill the disk.

With systemd only

Create /etc/systemd/system/api.service containing a [Service] block with User=deploy, WorkingDirectory=/srv/app, EnvironmentFile=/srv/app/.env, ExecStart=/usr/bin/node dist/server.js and Restart=always, plus WantedBy=multi-user.target under [Install]. Then sudo systemctl enable --now api and read logs with journalctl -u api -f.

Either way, verify from the server itself: curl -s http://127.0.0.1:3000/health should answer before you touch Nginx.

Step 5: Put Nginx and TLS in Front

Install Nginx with sudo apt install -y nginx and create /etc/nginx/sites-available/api with a server block whose location / contains proxy_pass http://127.0.0.1:3000; together with the Host, X-Forwarded-For and X-Forwarded-Proto headers. Symlink it into sites-enabled, run sudo nginx -t, and reload.

The complete block, including WebSocket upgrade headers and static file handling, is written out in the Nginx reverse proxy article. Once port 80 answers, request a certificate with sudo certbot --nginx -d api.example.com; Certbot rewrites the block for HTTPS and installs a renewal timer.

Inside the application, set app.set('trust proxy', 1) in Express or the equivalent option in Fastify so that rate limiters and logs see the real client address rather than 127.0.0.1.

Step 6: Script the Redeploy

Everything so far happens once. Shipping a new version should be one command, so capture the steps in /srv/app/deploy.sh:

#!/usr/bin/env bash
set -euo pipefail
cd /srv/app
git pull --ff-only origin main
npm ci --omit=dev
npm run build
pm2 reload api --update-env

set -euo pipefail stops the script at the first failure, so a broken build never reaches the reload step. pm2 reload restarts cluster workers one at a time; requests keep flowing while the new code starts. With systemd, replace the last line with sudo systemctl restart api and accept a short blip, or run two instances behind Nginx and switch between them.

From a laptop, ssh web1 /srv/app/deploy.sh deploys. From GitHub Actions or GitLab CI, the same line runs after tests pass; the CI/CD pipeline guide shows how to store the SSH key as a pipeline secret and restrict what it can do.

Database migrations deserve their own line before the reload, and they should be written so the previous version of the code still works while the new workers come up.

Deployment Checklist

  • Non-root deploy user with your SSH key; firewall allows 22, 80 and 443 only.
  • Node installed from NodeSource or nvm, version pinned in .nvmrc.
  • Code in /srv/app, dependencies from npm ci --omit=dev, secrets in a 600 permission .env.
  • PM2 cluster or a systemd unit restarts the app on crash and boot.
  • App bound to 127.0.0.1; Nginx terminates TLS and forwards headers.
  • deploy.sh pulls, installs, builds and reloads in one run; CI calls it over SSH.

Any developer cloud handles this workload comfortably. Location, snapshot pricing and API tooling are the usual deciding factors, and the DigitalOcean vs Vultr comparison weighs those for two of the most common choices. A Vultr account is a sensible starting point when you want the server close to users in a specific city, since the workflow above runs unchanged on any of its locations.

Questions about a cloud account?

Ask about products, variants, availability or purchasing on either channel.