DEV Community

Cover image for How I Deploy My Backend to a VPS (Zero Downtime + Instant Rollbacks,Guide Included)
Vishw Patel
Vishw Patel

Posted on

How I Deploy My Backend to a VPS (Zero Downtime + Instant Rollbacks,Guide Included)

Deploying a backend to a VPS is easy.

Deploying it properly, with versioning, rollback support, persistent storage, and zero downtime, is what makes a system production-ready.

In this article, I'll show the exact deployment workflow I use for file converter project ConvertX.

https://convertx.vizzv.com

This setup gives me:

  • Versioned deployments
  • Instant rollback
  • Persistent database storage
  • Zero-downtime deployment
  • Clean release history

And the best part: it works with any backend.


Who This Article Is Perfect For

  • Indie hackers
  • SaaS founders
  • Junior backend developers
  • Developers moving from localhost to production
  • People renting their first VPS

If you want production reliability without Kubernetes-level complexity, this approach is for you.


The Core Idea

Instead of deploying directly into one folder like this:

/var/www/app

It overwrites the same folder every time.This is dangerous.

Instead, I use a release-based deployment structure. Each deployment creates a new immutable release, and the active version is controlled using a symlink.

A symlink (also called a symbolic link) is a type of file in Linux that points to another file or a folder on your computer. Symlinks are similar to shortcuts in Windows.

This simple idea changes everything.


Why Direct Folder Deployments Are Dangerous

When you deploy directly into one folder:

  • Files get partially overwritten
  • Old and new code can mix
  • Rollbacks are painful
  • Failed uploads leave broken states
  • Debugging becomes difficult

If deployment fails mid-upload, your app might be:

  • Half old version
  • Half new version
  • Completely broken

And you won't even know which files changed. That's how production outages happen.


Why Immutable Releases Matter

An immutable release means:

  • Once deployed, it is never modified
  • Each version lives in its own folder
  • Releases are timestamped
  • Old versions stay untouched

Benefits:

  • You can inspect any past version
  • Rollback is instant
  • Debugging becomes easier
  • No accidental file mutation

This pattern is used by professional deployment tools like Capistrano.


How This Mimics Capistrano-Style Deployments

Tools like Capistrano popularized this structure years ago:

app/
├── releases/
├── shared/
└── current -> releases/version
Enter fullscreen mode Exit fullscreen mode

I simply implement the same architecture manually using:

  • rsync
  • symlinks
  • PM2

No heavy tooling required.


Server Directory Structure

/root/convertx
├── current → releases/20260117_2120
├── releases
│   ├── 20260104_2031
│   └── 20260117_2120
└── shared
    ├── .env
    └── data
        ├── mydb.sqlite
        ├── uploads
        └── output
Enter fullscreen mode Exit fullscreen mode

Folder Purpose

Folder Purpose
releases Immutable deployments
current Active running version(symlink)
shared Persistent data
.env Environment variables
data Database & uploads

PM2 always runs the application from current.


Why Symlink Switching Is Atomic in Linux

This is the magic.

When you run:
ln -sfn releases/NEW_VERSION current

Linux replaces the symlink atomically.
That means:

  • There is no intermediate broken state
  • The pointer changes instantly
  • The filesystem guarantees consistency

Symlink example

From the OS perspective, the switch happens in one operation.
That's how zero-downtime switching works.


Deployment Flow

The deployment process looks like this:

Local Machine
    ↓
Build project
    ↓
Create release folder
    ↓
Upload via rsync
    ↓
Link shared files
    ↓
Switch "current" symlink
    ↓
Restart PM2
Enter fullscreen mode Exit fullscreen mode

I use pm2 reload to avoid downtime. The new version becomes live immediately. The previous version still exists inside releases/.

This allows a new version to go live instantly.


What Happens If Deployment Fails Mid-Upload?

Nothing breaks.

Why?
Because:

  • Upload happens inside a new release folder
  • current is not touched during upload
  • The live app keeps running

If upload fails:

  • Delete the incomplete release
  • Redeploy

Your live application is never affected.

Rollbacks in Seconds

If something goes wrong after switching:

ln -sfn /root/convertx/releases/OLD_VERSION /root/convertx/current
pm2 restart convertx
Enter fullscreen mode Exit fullscreen mode

Rollback time: under 5 seconds.

  • No rebuilding.
  • No reinstalling.
  • No panic.

Just switch the symlink.


Tech Stack Used

For this deployment workflow I use:

  • Bun runtime
  • PM2
  • Nginx reverse proxy
  • SQLite
  • rsync for fast deployments

This stack is simple, cheap, and extremely reliable.


Security Practices

Some important security rules I follow:

  • .env is never uploaded
  • node_modules is never uploaded
  • data/ stays persistent
  • SSH keys only (no password login)
  • HTTPS handled via Nginx

This keeps secrets and data safe during deployments.


Automatic File Cleanup

ConvertX processes uploaded files, so temporary files must be removed periodically.

I implemented two cleanup systems:

  • Internal scheduler in the application
  • System cron fallback

This ensures storage never grows uncontrollably.


Why Not Docker?

Docker is great.

For small single-service VPS deployments, Docker may not provide enough benefit to justify its operational overhead.

  • Adds complexity
  • Requires image builds
  • Adds extra layer of debugging
  • Consumes more memory

For simple backend services, native runtime + PM2 is often enough.


Why Not CI/CD Pipelines?

CI/CD is powerful.

But for solo builders:

  • Adds setup time
  • Adds moving parts
  • Can obscure what actually happens on the server

Manual release-based deployment is:

  • Transparent
  • Predictable
  • Easy to debug

You can always add CI later.


Why Not Git-Based Deployment?

Some deploy with:

git pull origin main

This has problems:

  • No version isolation
  • Harder rollback
  • Risk of merge conflicts on server
  • Repository becomes mutable production state

Production should not be your working directory. Releases solve this cleanly.


Why This Deployment Strategy Works

Benefits of this approach:

  • Zero-downtime deployment
  • Safe rollbacks
  • Clean release history
  • Persistent storage
  • Easy debugging
  • Production-grade workflow

And it works on any VPS.


Final Thoughts

A lot of developers over-complicate deployments with heavy infrastructure.

But with rsync + symlinks + PM2, you can build a very reliable deployment pipeline on a simple VPS.

Sometimes the best solutions are the simplest ones.

Bonus

This is exact guide, I use for deployment for convertx.

## Server Directory Structure

/`/`/`

/root/convertx
├── current → releases/20260117_2120
├── releases
│   ├── 20260104_2031
│   └── 20260117_2120
└── shared
├── .env
└── data
├── mydb.sqlite
├── uploads
└── output

/`/`/`

### Rules
- `shared/` → persistent files
- `releases/` → immutable deployments
- `current` → active version (symlink)
- PM2 always runs from `current`

---

# FIRST TIME DEPLOYMENT

---

## 1.Build Locally

/`/`/`bash
bun install
bun run build
/`/`/`

(If build is not required, skip.)

---

## 2.Create New Release (Local)

/`/`/`bash
cd ~/deployment/convertX/releases

TS=$(date +%Y%m%d_%H%M)
mkdir $TS
/`/`/`

Copy project files:

/`/`/` bash
rsync -av \
  --exclude node_modules \
  --exclude data \
  --exclude .git \
  ~/opensource/ConvertX/ \
  ~/deployment/convertX/releases/$TS/
/`/`/`

---

## 3.Upload Release to Server

/`/`/`bash
rsync -avz \
  --exclude node_modules \
  --exclude data \
  --exclude .git \
  ~/deployment/convertX/releases/$TS/ \
  root@SERVER_IP:/root/convertx/releases/$TS/
/`/`/`

---

## 4.Link Shared Files (Server)

/`/`/`bash
ssh root@SERVER_IP
/`/`/`

/`/`/`bash
cd /root/convertx/releases/$TS

ln -s ../../shared/.env .env
ln -s ../../shared/data data
/`/`/`

---

## 5.Activate Release

/`/`/`bash
ln -sfn /root/convertx/releases/$TS /root/convertx/current
/`/`/`

---

## 6.Install Dependencies

/`/`/`bash
cd /root/convertx/current
bun install
/`/`/`

---

## 7.Restart Application

/`/`/`bash
pm2 restart convertx
/`/`/`

---

## 8.Verify Deployment

/`/`/`bash
pm2 status
/`/`/`

/`/`/`bash
curl -H "Authorization: Bearer API_TOKEN" \
https://api.com/api/health
/`/`/`

---

# DEPLOYING A NEW RELEASE

Repeat the same flow.

---

## Local

/`/`/`bash
TS=$(date +%Y%m%d_%H%M)

mkdir ~/deployment/convertX/releases/$TS

rsync -av \
  --exclude node_modules \
  --exclude data \
  --exclude .git \
  ~/opensource/ConvertX/ \
  ~/deployment/convertX/releases/$TS/
/`/`/`

---

## Upload

/`/`/`bash
rsync -avz \
  --exclude node_modules \
  --exclude data \
  --exclude .git \
  ~/deployment/convertX/releases/$TS/ \
  root@SERVER_IP:/root/convertx/releases/$TS/
/`/`/`

---

## Server

/`/`/`bash
cd /root/convertx/releases/$TS

ln -s ../../shared/.env .env
ln -s ../../shared/data data

ln -sfn /root/convertx/releases/$TS /root/convertx/current

cd /root/convertx/current
bun install
pm2 restart convertx
/`/`/`

---

# ROLLBACK

If deployment fails:

/`/`/`bash
ln -sfn /root/convertx/releases/OLD_TIMESTAMP /root/convertx/current
pm2 restart convertx
/`/`/`

Rollback time: **< 5 seconds**

---

# CLEAN OLD RELEASES

Keep last 3–5 releases.

/`/`/`bash
cd /root/convertx/releases
ls
rm -rf OLD_TIMESTAMP
/`/`/`

Never delete `current`.

---

# SECURITY NOTES

* Never upload `.env`
* Never upload `data/`
* Never upload `node_modules`

---

# PM2 COMMANDS

/`/`/` bash
pm2 status
pm2 logs convertx
pm2 restart convertx
pm2 save
pm2 startup
/`/`/`

---

**ConvertX deployment is live. 🚀**

Enter fullscreen mode Exit fullscreen mode

Top comments (0)