Key takeaways

  • Deploy every release into its own timestamped directory and run the app from a current symlink, so going live and rolling back are the same pointer change.
  • Switch the symlink by creating it under a temporary name and renaming it over the old one with mv -T, because a rename within one filesystem is atomic.
  • Start PM2 apps from the current path with an ecosystem file and reload after every switch, or the process keeps running the old release.
  • Health-check the new release before moving the symlink, and restore the previous release automatically if the live check fails.
  • Rolling back code does not roll back the database, so ship schema changes in expand and contract steps the previous release can live with.
  • Keep three to five releases, and keep .env and uploads in a shared directory linked into each release.

To roll back a bad deploy on a VPS in seconds, every deploy has to land in its own directory, and the live app has to run from a current symlink that points at one of them. Rolling back is then pointing that symlink at the previous release and reloading the process: no rebuild, no git revert under pressure, no guessing what was live yesterday. This guide sets up that layout for a Node app under PM2 and for a static site under Nginx, with a complete deploy script that checks a release before it goes live and puts the old one back if it fails.

Why is rolling back hard without release directories?

The common first deploy script works in place: git pull, npm ci, npm run build, pm2 restart. It has three problems, and all of them show up on the day something goes wrong.

  • While it runs, the running app is serving from a directory whose node_modules and build output are being replaced underneath it. Requests during the build can hit half-written files.
  • If the build fails, the working tree is now broken and the previous build is gone. The app keeps running only until its next restart.
  • Rolling back means checking out the old commit and building it again, which takes as long as a deploy and can fail for new reasons, such as a package version that changed on the registry since.

Release directories fix all three. A new release is installed and built in a fresh directory while the old one keeps serving, untouched. Going live is a pointer change. The previous build is still on disk, complete, so rolling back is the same pointer change in the other direction.

The layout comes from deployment tools such as Capistrano and Deployer, and it works the same way with nothing but bash:

/var/www/myapp/
├── current -> /var/www/myapp/releases/20261008T143000Z
├── releases/
│   ├── 20261007T091500Z/
│   ├── 20261008T101500Z/
│   └── 20261008T143000Z/
└── shared/
    ├── .env
    └── uploads/
PathWhat it holdsWhen it changes
releases/<timestamp>One complete checkout with its own node_modules and build outputCreated once by a deploy, never modified after
currentA symlink to the release that is liveOn every deploy and every rollback
sharedFiles that must survive deploys: .env, uploads, anything written at runtimeOnly when you change them

Everything that runs, PM2 and Nginx included, refers to /var/www/myapp/current, never to a release directory by name. Release names are UTC timestamps, so sorting them alphabetically sorts them by age, and each one holds a REVISION file with the commit it was built from.

The key property is that a release is immutable once it is built. Its node_modules matches its own lockfile, its build output matches its own source, and nothing a later deploy does can change it. That is what makes rolling back to it safe.

The usual idiom is ln -sfn:

ln -sfn /var/www/myapp/releases/20261008T143000Z /var/www/myapp/current

-s makes a symbolic link, -f replaces whatever is there, and -n treats an existing symlink to a directory as a file to replace. Without -n, ln would follow current into the old release and create the new link inside it.

Whether ln -sfn is atomic depends on which ln you have. Traditionally it removes the old link and then creates the new one, two separate system calls, and for a moment current does not exist; a request or a process start that lands in that gap fails. Implementations and versions differ here, and a deploy script should not depend on which one a server happens to run. The version that is atomic everywhere is explicit: create the new link under a temporary name, then rename it over the old one.

ln -sfn /var/www/myapp/releases/20261008T143000Z /var/www/myapp/current.tmp
mv -Tf /var/www/myapp/current.tmp /var/www/myapp/current

A rename within one filesystem is atomic: anything that looks at current sees either the old target or the new one, never nothing. -T tells GNU mv to treat current as the file to replace rather than a directory to move into. Capistrano and Deployer switch releases the same way. readlink -f /var/www/myapp/current tells you which release is live at any moment.

What belongs in the shared directory?

Anything that must outlive a single release:

  • .env and other configuration files with secrets. They do not belong in git, so they cannot arrive with the checkout.
  • User uploads and any other files the app writes at runtime. If they live inside a release, the next deploy starts without them, and a rollback brings back an older set.
  • SQLite databases, if you use one. Rolling back a release must never roll back data.

Each deploy links these into the new release before building. node_modules is deliberately not shared: each release gets its own, so the dependencies always match the code next to them.

For Next.js, note that only files present in public/ at build time are served, so files uploaded later will not appear through Next.js even when public/uploads is a symlink. Serve uploads from the shared directory with an Nginx location /uploads/ block that uses alias /var/www/myapp/shared/uploads/;, and let the app write there.

Step-by-step: set up release-based deploys for a PM2 app

This assumes Ubuntu 24.04 or Debian 12, Node.js 22, PM2 installed globally, a deploy user called deploy with SSH access to the repository, and Nginx proxying a domain to port 3000. If you are starting from a bare server, the Node.js on a VPS guide and the Next.js on a VPS guide cover that part.

Step 1: Create the directory layout (2 minutes)

sudo mkdir -p /var/www/myapp/releases /var/www/myapp/shared/uploads
sudo chown -R deploy:deploy /var/www/myapp

Step 2: Move secrets and uploads into shared (5 minutes)

Copy the production .env and any existing uploads from wherever the app lives today:

cp /home/deploy/myapp/.env /var/www/myapp/shared/.env
chmod 600 /var/www/myapp/shared/.env
cp -a /home/deploy/myapp/uploads/. /var/www/myapp/shared/uploads/

Step 3: Point PM2 at the current path, not at a release (5 minutes)

Commit an ecosystem.config.js to the repository whose cwd is the symlink path:

module.exports = {
  apps: [
    {
      name: 'myapp',
      cwd: '/var/www/myapp/current',
      script: 'dist/server.js', // Next.js: 'node_modules/next/dist/bin/next' with args: 'start'
      exec_mode: 'cluster',
      instances: 2,
      env: { NODE_ENV: 'production', PORT: 3000 },
    },
  ],
};

This file is the difference between a rollback that works and one that silently keeps running the old code. PM2 records the script path and working directory when an app is first started. If they point through current, every reload starts the new process through the symlink and picks up whatever release it points at. If they point at a release directory, every reload restarts that same release forever. The PM2 ecosystem generator produces this file, and the PM2 guide explains the fields.

Cluster mode matters for downtime. In cluster mode, pm2 reload replaces workers one at a time, so the port never stops answering. In fork mode, reload is the same as restart: the process stops, then starts, and requests in between fail. Two instances are enough for the rolling replacement to work on a small server.

You do not start the app by hand: the deploy script in Step 4 runs pm2 startOrReload, which starts the app from this file the first time and reloads it after that. If the app already runs under PM2 from another directory, that old process holds port 3000 and keeps its old paths, so delete it immediately before the first release-based deploy:

pm2 delete myapp && ~/deploy.sh

The site is down for the length of that one build. If the first deploy fails, start the old copy again the way you started it before. Every deploy after that is a reload.

Step 4: Add the deploy script (10 minutes)

Save this as /home/deploy/deploy.sh and make it executable with chmod +x. It expects the app's npm start to start the production server on the port in the PORT environment variable, and the app to answer GET /health with a 200 once it can serve requests.

#!/usr/bin/env bash
# deploy.sh: build a new release, check it, switch to it, put the old one back on failure.
set -Eeuo pipefail

APP=myapp
APP_DIR=/var/www/myapp
REPO=git@github.com:acme/myapp.git
BRANCH=main
LIVE_PORT=3000        # the port Nginx proxies to
CHECK_PORT=3999       # a free port for the check before going live
HEALTH_PATH=/health
KEEP=5                # releases to keep on disk

RELEASE="$(date -u +%Y%m%dT%H%M%SZ)"
NEW="$APP_DIR/releases/$RELEASE"
PREV="$(readlink -e "$APP_DIR/current" || true)"
SWITCHED=0

# One deploy at a time.
exec 9> "$APP_DIR/.deploy.lock"
flock -n 9 || { echo "Another deploy is running." >&2; exit 1; }

healthy() {   # healthy PORT: up to 30 tries, one second apart
  for _ in $(seq 1 30); do
    curl -fsS --max-time 2 "http://127.0.0.1:$1$HEALTH_PATH" > /dev/null && return 0
    sleep 1
  done
  return 1
}

go_live() {   # go_live DIR: swap the symlink atomically, then reload PM2 from it
  ln -sfn "$1" "$APP_DIR/current.tmp"
  mv -Tf "$APP_DIR/current.tmp" "$APP_DIR/current"
  pm2 startOrReload "$APP_DIR/current/ecosystem.config.js" --update-env
}

on_error() {
  trap - ERR
  pm2 delete "$APP-check" > /dev/null 2>&1 || true
  if [ "$SWITCHED" = 1 ] && [ -n "$PREV" ]; then
    echo "!! $RELEASE failed after going live; restoring $(basename "$PREV")" >&2
    go_live "$PREV"
  elif [ "$SWITCHED" = 1 ]; then
    echo "!! $RELEASE failed after going live, and there is no earlier release to restore" >&2
  fi
  if [ "$(basename "$(readlink -e "$APP_DIR/current" || echo none)")" != "$RELEASE" ]; then
    rm -rf -- "$NEW"
  fi
  echo "!! Deploy of $RELEASE failed." >&2
}
trap on_error ERR

echo "==> 1/6 Fetching $BRANCH into releases/$RELEASE"
mkdir -p "$APP_DIR/releases" "$APP_DIR/shared/uploads"
git clone --quiet --depth 1 --branch "$BRANCH" "$REPO" "$NEW"
git -C "$NEW" rev-parse --short HEAD > "$NEW/REVISION"

echo "==> 2/6 Linking shared files"
ln -sfn "$APP_DIR/shared/.env" "$NEW/.env"
ln -sfn "$APP_DIR/shared/uploads" "$NEW/uploads"

echo "==> 3/6 Installing and building"
cd "$NEW"
npm ci
npm run build

echo "==> 4/6 Checking the release on port $CHECK_PORT before it goes live"
pm2 delete "$APP-check" > /dev/null 2>&1 || true
PORT=$CHECK_PORT NODE_ENV=production pm2 start npm --name "$APP-check" --cwd "$NEW" -- start > /dev/null
if ! healthy "$CHECK_PORT"; then
  pm2 logs "$APP-check" --lines 40 --nostream || true
  false   # hand over to on_error: the live release was never touched
fi
pm2 delete "$APP-check" > /dev/null

echo "==> 5/6 Switching current to $RELEASE"
SWITCHED=1
go_live "$NEW"
healthy "$LIVE_PORT"   # a failure here restores the previous release
pm2 save > /dev/null
trap - ERR

echo "==> 6/6 Keeping the newest $KEEP releases"
LIVE="$(basename "$(readlink -e "$APP_DIR/current")")"
ls -1 "$APP_DIR/releases" | sort | head -n -"$KEEP" | while read -r old; do
  [ "$old" = "$LIVE" ] || rm -rf -- "$APP_DIR/releases/$old"
done

echo "==> Live: $RELEASE ($(cat "$NEW/REVISION"))"

What each stage does:

  1. Clones the branch into a new, timestamped release directory and records the commit in REVISION. A lock file stops two deploys from running at once.
  2. Links .env and the uploads directory from shared into the release.
  3. Installs dependencies and builds. The live release is serving traffic the whole time; if the build fails, the new directory is deleted and nothing else has changed.
  4. Starts the new release as a temporary PM2 process on a spare port and polls its health endpoint. If it never becomes healthy, the script prints that process's logs, removes it and the release, and exits. The live release was never touched.
  5. Swaps current atomically and reloads PM2, then checks the live port. If the app does not come up healthy there, the error handler points current back at the previous release and reloads again. This is the automatic restore.
  6. Deletes all but the newest five releases, never the one that is live, and saves the PM2 process list so it survives a reboot.

One caution about the check in stage 4: the temporary process runs your real app with your real .env. If the app starts background jobs, queue consumers or scheduled tasks on boot, they will run for the few seconds the check takes. Guard them behind an environment variable, or skip the pre-switch check and rely on the post-switch check and restore.

Step 5: Add a rollback script (5 minutes)

Save this as /home/deploy/rollback.sh. With no argument it goes back one release from whatever is live; with a release name, it goes to that one.

#!/usr/bin/env bash
# rollback.sh: make the previous release live, or the one you name.
set -Eeuo pipefail

APP_DIR=/var/www/myapp
cd "$APP_DIR/releases"

LIVE="$(basename "$(readlink -e "$APP_DIR/current")")"
TARGET="${1:-$(ls -1 | sort | grep -B1 -x "$LIVE" | head -n 1)}"

[ -d "$APP_DIR/releases/$TARGET" ] || { echo "No release called $TARGET" >&2; exit 1; }
[ "$TARGET" != "$LIVE" ] || { echo "Nothing to roll back to: $TARGET is the live release" >&2; exit 1; }

echo "Rolling back: $LIVE -> $TARGET ($(cat "$TARGET/REVISION" 2>/dev/null || echo unknown))"
ln -sfn "$APP_DIR/releases/$TARGET" "$APP_DIR/current.tmp"
mv -Tf "$APP_DIR/current.tmp" "$APP_DIR/current"
pm2 startOrReload "$APP_DIR/current/ecosystem.config.js" --update-env
pm2 save > /dev/null

To see what you can roll back to, with the commit each release was built from:

for r in /var/www/myapp/releases/*/; do echo "$(basename "$r")  $(cat "$r/REVISION")"; done
readlink -f /var/www/myapp/current

Run a deploy, then a rollback, then a deploy again, on purpose, before you need it for real. A rollback procedure that has never been run is a guess.

How do you roll back a static site?

A static site is the simplest case, because nothing has to be reloaded. Point Nginx's root at the symlink:

server {
    listen 80;
    server_name www.example.com;
    root /var/www/site/current/dist;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

For a single-page app, end try_files with /index.html instead of =404. Deploy by building into a new release directory, checking that dist/index.html exists and is not empty, and swapping current the same atomic way. Nginx resolves the root path when it opens each file, so the next request is served from the new release with no reload. The exception is open_file_cache: if you enabled it, cached lookups can keep serving the old files until they expire, so run sudo systemctl reload nginx after the swap.

One static-site catch: a visitor who loaded the old index.html a minute ago will request the old hashed JavaScript and CSS filenames, and those do not exist in the new release. Keep the previous release's asset files reachable for a while, either by copying them into the new release's assets directory or by serving assets from a shared directory that only ever grows, and prune it separately.

How do you roll back a Docker deployment?

Containers get the same property from image tags instead of directories. Tag every image with the commit it was built from rather than only latest, and keep the last few on the server:

docker build -t myapp:$(git rev-parse --short HEAD) .
docker image ls myapp

With Compose, put the tag in a variable (image: myapp:${APP_TAG}), deploy with APP_TAG=<new commit> docker compose up -d --wait, and roll back with APP_TAG=<old commit> docker compose up -d --no-build --wait. Pruning images with docker image prune -a deletes those older tags along with everything else unused, so prune by age rather than all at once. The Docker Compose deployment guide covers the whole setup, including why the published port must be bound to 127.0.0.1.

Does rolling back code roll back the database?

No. A release directory or an image tag holds code. If the bad release ran a migration that renamed a column, rolling back the code puts back an app that expects the old column name, against a database that no longer has it. That rollback fails harder than the release it was meant to fix.

The way out is to make every migration compatible with the release before it, so rolling back one release is always safe. This is usually called expand and contract, or parallel change:

PhaseSchema changeCode that ships with itSafe to roll back to
ExpandAdd the new column or table; nothing is removed or renamedWrites both old and new, still reads oldThe previous release
MigrateBackfill the new column from the old oneReads new, still writes bothThe expand release
ContractDrop the old column, once no release you might roll back to reads itUses only the new columnThe migrate release

Rules that follow from it:

  • Never rename or drop a column in the same deploy as the code that stops using it. Split it across releases.
  • New columns are nullable or have a default, so the previous release can still insert rows without knowing about them.
  • Run additive migrations before the switch: the old release keeps working against the expanded schema while the new one is checked.
  • Treat down migrations as a last resort. Many tools have none, and the ones that do can discard data written since the migration ran. Rolling forward with a fix is usually safer.
  • Take a database backup before any deploy that migrates, so the worst case is a restore, not a reconstruction.

Where does a release-based deploy break?

The app still runs the old code after the switch

The symlink moved, but nothing reloaded, or the process was never started through the symlink. A running Node process resolved its working directory and loaded its modules when it started, so it keeps running the old release no matter where current points; only a reload starts processes that read the new target. Check what PM2 recorded:

pm2 describe myapp | grep -E "script path|exec cwd"

Both should start with /var/www/myapp/current. If either names a release directory, the app was started from inside one. cd /var/www/myapp/current && pm2 start dist/server.js does exactly that, because a process's working directory is always the resolved, real path, even when your shell prompt shows the symlink. Delete the app and start it again from the ecosystem file, which names the symlink path explicitly, then pm2 save. Two related traps: without --update-env, a reload keeps the environment from the first start, and some ecosystem changes, such as switching exec_mode, only apply after pm2 delete and a fresh start.

Old releases fill the disk

Each release carries its own node_modules and build output, and for a Next.js app that can easily be hundreds of megabytes. Check with du -sh /var/www/myapp/releases/* and df -h. Keep three to five releases; more than that is rarely needed, because you will not roll back further than a few deploys without also dealing with the database. The script above prunes after every successful deploy and never deletes the live release. Two other places grow quietly: the npm cache in ~/.npm, which npm cache verify trims, and PM2 logs in ~/.pm2/logs, which pm2-logrotate keeps in check.

Prune only after the reload has finished. A Node process that is still running from a deleted release can fail later when it lazily loads a file that is no longer there, which Next.js does for route chunks.

Environment changes do not follow a rollback

shared/.env is shared by every release, so a rollback brings back old code with today's environment. If the bad release renamed a variable and you removed the old name, the release you roll back to cannot find it. Treat environment variables like schema: add the new name, keep the old one until no release on disk reads it, then remove it.

Some values are not read at runtime at all. NEXT_PUBLIC_ variables in Next.js and VITE_ variables in Vite are inlined into the build, so each release carries the values it was built with. Changing one needs a new build, and rolling back restores the old value with the old code. And PM2 keeps the environment from the process's first start unless you reload with --update-env, which both scripts above do.

The health check passes, but the release is broken

A /health route that only returns 200 proves the process started. Make it check what the app cannot work without, such as a database query that takes a millisecond, so a release with a bad connection string fails before it goes live. Do not make it depend on third-party APIs, or their outage will fail every deploy and every check. A release that is healthy but wrong, a bug in a page rather than a crash, is what the manual rollback script is for.

Which rollback approach fits your setup?

ApproachTime to roll backExtra costWeak spot
git revert, then redeployA full buildNoneSlowest when you are in a hurry, and the build can fail again
Check out the old commit and rebuild in placeA full buildNoneThe app serves a half-built tree while it runs
Release directories and a symlinkA symlink swap and a reloadDisk for a few releasesSchema and environment changes
Docker image per commitRecreating a containerDisk or a registry for the imagesSchema changes; data lives in volumes
Blue-green, two full copies behind the proxySwitching the proxy upstreamTwice the memoryMore moving parts on a single VPS

On a single VPS, release directories with PM2 in cluster mode give you instant rollback and reloads that do not drop requests, which covers most of what blue-green offers with far less setup.

How Shipown handles releases and rollback

Shipown is a desktop app for Windows and Linux that deploys to your own server over SSH, with no agent installed on it, and it uses the pattern in this guide:

  • Each deploy lands in its own release directory, and the live pointer moves only after the build and the health check pass.
  • A failed deploy restores the previous release by itself.
  • Rolling back later is picking a known-good release from the app, not reconstructing one.
  • Node apps, including Next.js, Nuxt, Remix, SvelteKit, NestJS, Fastify and Express, run under PM2 with the port, the health check and the restart policy set for you.
  • Static sites are built once, published to a release directory and served straight from Nginx, so rolling back is switching releases. A site can also be taken offline without deleting it.
  • Docker deploys label each image with the commit it came from, keep the previous container until the new one passes its health check, and can roll back to an earlier image or release.

Around that, it shows the commit each deployment is actually running against the head of its branch, and how many commits are waiting (branch watching). Deploys and rollbacks land in the live feed, and the build output you watch is streamed from the real install and build. The deployments docs walk through the screens.

What stays yours: schema changes still need the expand and contract discipline above, because switching releases moves code, not your database. The same goes for renaming environment variables. Before a deploy that migrates, Shipown's database backups can take an on-demand backup of a MySQL, MariaDB, PostgreSQL or MongoDB database on the server. And it does not deploy on push: it tells you a branch has moved, and a person presses deploy.

What should you do after your first release-based deploy?

  • Run a rollback on purpose, on a quiet day, and time it. Then deploy forward again.
  • Write down, or script, how to find the release a given commit is in. The REVISION file makes that a one-liner.
  • Watch disk usage for the first week to choose how many releases to keep.
  • Add an external uptime check on the public URL; your health endpoint only proves the app is up from inside the server.
  • Review your next migration against the expand and contract table before you write it, not after the deploy fails.

Frequently asked questions

What is a rollback deployment?

A rollback deployment returns production to a previous, known-good version of the application after a release goes wrong. On a VPS the fastest way is to keep each release in its own directory, switch a current symlink back to the previous one and reload the app. It takes seconds because nothing is rebuilt.

Is ln -sfn atomic?

Not reliably. Traditionally it removes the old link and then creates the new one, leaving a brief moment with no link, and implementations differ. For a switch that is atomic everywhere, create the link under a temporary name and rename it over the old one with mv -T, because a rename within one filesystem is atomic.

Why does PM2 keep running the old release after I switch the symlink?

A running Node process loaded its code from the release the symlink pointed at when it started, so it needs a reload to pick up the new target. If a reload still runs old code, PM2 recorded a release directory instead of the symlink path. Check the script path and exec cwd in pm2 describe, then delete the app and start it from an ecosystem file whose cwd is the current path.

How many releases should I keep on the server?

Three to five is enough for most apps. Each release carries its own node_modules and build output, so more releases mean more disk, and rolling back further than a few deploys usually runs into database changes anyway.

Does rolling back the code roll back database migrations?

No. Release directories and image tags hold code, not schema. Make every migration compatible with the previous release by adding before removing, the expand and contract pattern, and take a database backup before any deploy that migrates.

Can I get zero-downtime deploys on a single VPS?

Yes, for most Node apps. Run the app in PM2 cluster mode with at least two instances and use pm2 reload after the release switch; PM2 then replaces workers one at a time. In fork mode a reload is a restart, so a short gap is unavoidable.

How do I roll back a Docker Compose deployment?

Tag each image with the commit it was built from and reference the tag through a variable in compose.yaml. Rolling back is running docker compose up -d --no-build with the previous tag, as long as that image has not been pruned.