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 #
/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
#!/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">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/shared/.env" "</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">""</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">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/shared/uploads" "</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">""</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">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">RELEASE" "</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">""</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">"</mi><mi mathvariant="normal">"</mi></mrow><annotation encoding="application/x-tex">APP/current.new" "</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">""</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"
# /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
# 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.