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.
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
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
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
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
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
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:
-
.envis never uploaded -
node_modulesis 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. 🚀**

Top comments (0)