VPS DeploymentIntermediate 15 min Lesson 18 of 30

Deploying Node.js and Next.js

A complete deployment: release directories, building, a systemd service, Nginx in front, and switching versions atomically.

VPS Deployment · Lesson 18 of 30
0/30 done(0%)

What is it? #

Deploying a Node application means getting the code onto the server, installing dependencies, building, and restarting the service behind Nginx.

The structure that makes this reliable is release directories with a current symlink. Each deployment builds into a new directory, and going live is switching one symlink.

That gives you two things: the switch is atomic, so there is no moment where half the files are new, and rolling back is switching the symlink back.

Next.js in particular needs the build to happen before the switch, because a partially built application serves broken pages.

Think of it like this #

Preparing a complete new shop display in the back room, then moving it into place in one motion during a quiet minute.

The alternative — rearranging the shelves while customers browse — means a period where nothing is findable.

Simple example #

A Next.js application deployed to /srv/app with timestamped releases, shared uploads and env file, a systemd service, and Nginx serving static assets directly.

Code #

TEXT
/srv/app/
├── releases/
│   ├── 20260920-101500/
│   ├── 20260921-143000/
│   └── 20260922-090000/      ← the new release
├── shared/
│   ├── .env                  persists across deployments
│   └── uploads/              persists across deployments
└── current -> releases/20260922-090000
BASH
#!/usr/bin/env bash
# /srv/app/deploy.sh — run as the appuser
set -euo pipefail

APP=/srv/app
STAMP=$(date +%Y%m%d-%H%M%S)
RELEASE="<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>r</mi><mi>e</mi><mi>l</mi><mi>e</mi><mi>a</mi><mi>s</mi><mi>e</mi><mi>s</mi><mi mathvariant="normal">/</mi></mrow><annotation encoding="application/x-tex">APP/releases/</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.0197em;">l</span><span class="mord mathnormal">e</span><span class="mord mathnormal">a</span><span class="mord mathnormal">ses</span><span class="mord">/</span></span></span></span>STAMP"

echo "==> fetching code"
git clone --depth 1 --branch main [email protected]:org/app.git "$RELEASE"

echo "==> linking shared files"
ln -s "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>h</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi>d</mi><mi mathvariant="normal">/</mi><mi mathvariant="normal">.</mi><mi>e</mi><mi>n</mi><mi>v</mi><mi mathvariant="normal">&quot;</mi><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">APP/shared/.env&quot; &quot;</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">/.</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal" style="margin-right:0.0359em;">v</span><span class="mord">&quot;&quot;</span></span></span></span>RELEASE/.env"
ln -s "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>s</mi><mi>h</mi><mi>a</mi><mi>r</mi><mi>e</mi><mi>d</mi><mi mathvariant="normal">/</mi><mi>u</mi><mi>p</mi><mi>l</mi><mi>o</mi><mi>a</mi><mi>d</mi><mi>s</mi><mi mathvariant="normal">&quot;</mi><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">APP/shared/uploads&quot; &quot;</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">s</span><span class="mord mathnormal">ha</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">d</span><span class="mord">/</span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.0197em;">pl</span><span class="mord mathnormal">o</span><span class="mord mathnormal">a</span><span class="mord mathnormal">d</span><span class="mord mathnormal">s</span><span class="mord">&quot;&quot;</span></span></span></span>RELEASE/public/uploads"

echo "==> installing and building"
cd "$RELEASE"
npm ci --omit=dev
npm run build                      # must succeed before we switch

echo "==> switching"
ln -sfn "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>R</mi><mi>E</mi><mi>L</mi><mi>E</mi><mi>A</mi><mi>S</mi><mi>E</mi><mi mathvariant="normal">&quot;</mi><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">RELEASE&quot; &quot;</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:0.6944em;"></span><span class="mord mathnormal" style="margin-right:0.0077em;">R</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal">L</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.0576em;">S</span><span class="mord mathnormal" style="margin-right:0.0576em;">E</span><span class="mord">&quot;&quot;</span></span></span></span>APP/current.new"
mv -T "<span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mi>A</mi><mi>P</mi><mi>P</mi><mi mathvariant="normal">/</mi><mi>c</mi><mi>u</mi><mi>r</mi><mi>r</mi><mi>e</mi><mi>n</mi><mi>t</mi><mi mathvariant="normal">.</mi><mi>n</mi><mi>e</mi><mi>w</mi><mi mathvariant="normal">&quot;</mi><mi mathvariant="normal">&quot;</mi></mrow><annotation encoding="application/x-tex">APP/current.new&quot; &quot;</annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="katex-base"><span class="katex-strut" style="height:1em;vertical-align:-0.25em;"></span><span class="mord mathnormal">A</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord mathnormal" style="margin-right:0.1389em;">P</span><span class="mord">/</span><span class="mord mathnormal">c</span><span class="mord mathnormal">u</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal" style="margin-right:0.0278em;">r</span><span class="mord mathnormal">e</span><span class="mord mathnormal">n</span><span class="mord mathnormal">t</span><span class="mord">.</span><span class="mord mathnormal">n</span><span class="mord mathnormal">e</span><span class="mord mathnormal" style="margin-right:0.0269em;">w</span><span class="mord">&quot;&quot;</span></span></span></span>APP/current"      # atomic replace
sudo systemctl restart app

echo "==> health check"
for i in {1..15}; do
    if curl -fsS http://127.0.0.1:3000/api/health > /dev/null; then
        echo "healthy"; break
    fi
    [ "$i" = 15 ] && { echo "FAILED"; exit 1; }
    sleep 2
done

echo "==> cleaning old releases"
ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf   # keep 5
echo "deployed $STAMP"
INI
# /etc/systemd/system/app.service
[Unit]
Description=Next.js application
After=network-online.target

[Service]
Type=simple
User=appuser
WorkingDirectory=/srv/app/current
EnvironmentFile=/srv/app/shared/.env
Environment=NODE_ENV=production PORT=3000
ExecStart=/usr/bin/node /srv/app/current/node_modules/.bin/next start
Restart=always
RestartSec=5
KillSignal=SIGTERM
TimeoutStopSec=30
MemoryMax=1G

[Install]
WantedBy=multi-user.target
NGINX
# Let Nginx serve the built static assets directly
location /_next/static/ {
    alias /srv/app/current/.next/static/;
    expires 1y;
    add_header Cache-Control "public, immutable";
    access_log off;
}

location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host              $host;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
}

How it works #

Each deployment creates a timestamped directory, so releases are independent and nothing is overwritten in place.

Shared files are symlinked in. The env file and uploads live outside the release directories because they must survive deployments and rollbacks.

npm ci --omit=dev installs exactly the lockfile contents and skips development dependencies. The build then runs while the previous release is still serving traffic.

The switch uses ln -sfn to a temporary name followed by mv -T, which replaces the symlink atomically. A plain ln -sfn directly onto an existing symlink is not atomic on all systems and can briefly leave no target.

The health check loop is what makes the deployment trustworthy. It polls until the new process responds, and fails the script if it never does, so a broken deployment is visible immediately rather than discovered by users.

TimeoutStopSec=30 with SIGTERM gives the old process time to finish in-flight requests rather than dropping them.

Serving /_next/static/ from Nginx with a one-year cache is safe because those filenames contain content hashes. It removes a large share of requests from Node entirely.

Keeping five releases means a rollback is available for several deployments back, at the cost of some disk.

Real-world use #

This pattern — releases, shared, current — predates containers and is still the most practical approach on a single VPS. Deployment tools have used it for years.

Rollback becomes trivial: point current at the previous release and restart. That is a ten-second recovery, which changes how confident you can be about deploying.

With one server there is a brief 502 during the restart. Running two application instances on different ports with Nginx balancing between them removes even that.

Database migrations complicate the picture. Migrations should be backwards compatible so the old release still works during the switch, which is covered in the rollback lesson.

In CI, this script becomes the deployment step, triggered on a push to the main branch after tests pass.

Common mistakes #

  • Building in place, so a failed build leaves the running application broken.
  • Keeping uploads or the env file inside the release directory, losing them on deploy.
  • Switching the symlink non-atomically, leaving a moment with no target.
  • No health check, so a failed deployment is discovered by users.
  • Never cleaning old releases until the disk fills.

Practice #

Set up the releases and shared structure for a Node application, write a deployment script with an atomic switch and a health check, and deploy twice. Then roll back to the previous release by switching the symlink and confirm the application still works.

Quick quiz

  1. 1. Why deploy into a new release directory rather than in place?

  2. 2. Why keep the env file and uploads in a shared directory?

  3. 3. What makes the switch atomic?

  4. 4. Why include a health check in the deployment script?

  5. 5. How do you roll back with this structure?

Summary

  • Deploy into timestamped release directories with a current symlink.
  • Keep the env file and uploads in a shared directory outside releases.
  • Build before switching, and switch atomically.
  • Health check after restart and fail the deployment if it does not pass.
  • Rollback is switching the symlink back.