Deploy Node.js from Git with Pull Build Restart
Ship a Node.js app with a git pull, npm install, build, and restart script so a tagged commit becomes a repeatable Ubuntu VPS deployment path.
Netbay Engineering
Netbay Engineering
On this page
A production VPS is not git pull run from an interactive root shell. That command has no record of which commit is live, it can leave node_modules half-written, and it can restart the unit while npm is still compiling a native addon. A twenty-line script owned by a deploy user is enough: fetch a tag, install from the lockfile, build, then restart the unit. The same script runs from your laptop over SSH and from a CI job. This post writes that script for Ubuntu 24.04 and a systemd-supervised Node app in /srv/app.
The host already has Node 22, a nodeapp user, and a unit. Git is the delivery mechanism, not the process manager.
A deploy user that cannot become root at large
Create a system-ish user with a key and no password. Give it write access to /srv/app and one sudoers rule that can restart a single unit. Do not put deploy in sudo without a command list. Do not clone as root.
sudo useradd --create-home --shell /bin/bash --groups nodeapp deploy
sudo mkdir -p /srv/app
sudo chown deploy:nodeapp /srv/app
sudo chmod 2775 /srv/app
sudo -u deploy git clone git@github.com:example/app.git /srv/app
printf '%s\n' 'deploy ALL=(root) NOPASSWD: /bin/systemctl restart nodeapp.service, /bin/systemctl reload nodeapp.service, /bin/systemctl is-active nodeapp.service' | sudo tee /etc/sudoers.d/deploy-nodeapp
sudo chmod 0440 /etc/sudoers.d/deploy-nodeapp
sudo visudo -cThe setgid bit on /srv/app keeps new files in the nodeapp group so the runtime user can read the build output. umask 002 for the deploy user in the script. The sudoers file is a comma-separated command list with full paths; systemctl without a path would let someone wrap a binary.
Read-only deploy keys on the Git host, tagged releases, and a clone that tracks origin. Deploying a random branch named tmp-fix is how you lose the ability to answer which commit is in production.
The script: fetch, lockfile install, build, restart
npm ci is the production installer. It deletes node_modules and installs exactly what the lockfile says. npm install is a developer command that mutates the lockfile. Run ci against the tag you intend to boot, not against whatever happened to be in the working tree.
#!/bin/bash
set -euo pipefail
umask 002
APP=/srv/app
REF=$1
if [ -z "$REF" ]; then
echo "usage: deploy-nodeapp <git-ref>" >&2
exit 2
fi
cd $APP
git fetch --tags --prune origin
git checkout --force --recurse-submodules $REF
git reset --hard $REF
git clean -fdx --exclude node_modules --exclude .env
npm ci --omit=dev
npm run build
sudo /bin/systemctl restart nodeapp.service
sudo /bin/systemctl is-active nodeapp.service
git rev-parse --short HEAD > $APP/REVISION
echo "deployed $REF"Install the script at /usr/local/bin/deploy-nodeapp, owned by root, mode 0755, so the deploy user can run it but cannot edit it. git clean -fdx would delete node_modules and a stray env file; the exclude list keeps the install cache and forbids a committed .env from being the only copy (the real secrets stay in /etc/nodeapp). --omit=dev skips typescript and test runners if they are in devDependencies. If your build needs typescript, either move it to dependencies or run a two-stage install: npm ci, npm run build, then npm prune --omit=dev.
Restart is the cutover. If you enabled a systemd socket unit, the bind stays up while the process is replaced. If you did not, expect a brief 502 from nginx. Check is-active and then curl the loopback health route before you hang up SSH. The REVISION file is for humans and for the health endpoint to print.
What the script must refuse to do
It must not migrate a database without an explicit flag. A schema change rolled out in the same breath as a code restart is how you brick a rollback. It must not run as root. It must not npm publish, ssh to a second host, or pull from HTTPS with an embedded token in the remote URL. Tokens live in ~/.ssh or in a credential helper owned by deploy.
Roll back by running the same script with the previous tag. That only works if the previous tag still builds, which is why you never deploy an untagged SHA from a laptop branch. Keep at least two tags on the origin. High-Speed SSD makes npm ci of a modest app a short wait; if ci is minutes, you have too many dependencies or you are compiling from scratch without a cache. Cache is optional. Correctness of the lockfile is not.
CI can SSH as deploy and call the script with $GITHUB_SHA only if that SHA is also a tag you created in the same workflow. Passing floating main is how Friday's deploy becomes Monday's mystery.
The takeaway: clone once, deploy with a tagged ref, install from the lockfile, build, restart the unit, record the revision. Root interactive pulls are not a release process.
Drop the script on a Netbay Ubuntu 24.04 VPS and ship a tag in under a minute — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance