You've hit the bill. Or the cold start. Or the vendor lock that makes multi-region a pricing negotiation. Whatever the trigger, you're moving off Vercel, Railway, Render, or Fly to a VPS you control.
The first deploy works. The second week is where things break — not because the VPS is hard, but because the PaaS was hiding thirty things you didn't know you depended on.
This is the checklist for the things PaaS platforms handle silently that you now own.
Before you touch the VPS
1. Inventory what the PaaS does for you
Open your PaaS dashboard and write down every service it provides. Not what you think it provides — what it actually does. Check:
- DNS management. Does it manage your DNS records, or just point to a CNAME it gave you?
- SSL certificates. Auto-provisioned? Auto-renewed? What domain coverage — apex, www, subdomains?
- Environment variables. Where are they stored? Are any of them platform-specific (like
VERCEL_URLorRAILWAY_STATIC_URL)? - Build pipeline. What runs on deploy?
npm run build? Docker build? What base image? - Deploy rollback. Can you roll back to a previous deploy? How far back? Is it instant or a rebuild?
- Log routing. Where do stdout and stderr go? Is there a log viewer? Log retention?
- Health checks. Does the platform ping your app? What happens when it fails?
- Scaling. Auto-scale? Fixed instances? Sleep on idle?
Write this down. This is your migration scope. Every item on this list is something you need to replace, skip deliberately, or accept you're losing.
2. Export your environment variables
Every PaaS stores env vars differently. Export them now, before you start the migration.
# Railway
railway variables --json > env-export.json
# Vercel
vercel env pull .env.production
# Render
# No CLI export — copy from the dashboard manually
Scan for platform-specific variables. PORT is usually fine. RAILWAY_STATIC_URL, VERCEL_URL, RENDER_INTERNAL_HOSTNAME — these need replacements.
3. Check your DNS situation
If your domain's DNS is managed by the PaaS (Vercel DNS, for example), you need to move DNS management first. This is the step people skip and then wonder why their site goes down for four hours.
- Move DNS to Cloudflare, Route53, or your registrar's DNS.
- Set TTL to 300 seconds (5 minutes) at least 24 hours before the migration.
- Write down every DNS record the PaaS created for you.
The VPS setup checklist
4. Base server hardening
Before deploying your app:
# Create a non-root user
adduser deploy
usermod -aG sudo deploy
# SSH key auth only
cp -r ~/.ssh /home/deploy/.ssh
chown -R deploy:deploy /home/deploy/.ssh
sed -i 's/PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config
systemctl restart sshd
# Firewall
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable
# Automatic security updates
apt install unattended-upgrades -y
dpkg-reconfigure -plow unattended-upgrades
PaaS platforms do all of this invisibly. On a VPS, skipping this means your server is one brute-force attack away from a bad day.
5. SSL certificates
The PaaS auto-provisioned and auto-renewed your certs. Now you need:
# Install certbot
apt install certbot python3-certbot-nginx -y
# Get certificates
certbot --nginx -d yourdomain.com -d www.yourdomain.com
# Verify auto-renewal
certbot renew --dry-run
The trap: certbot's auto-renewal runs via systemd timer, but nginx doesn't reload automatically after renewal. Add a deploy hook:
# /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
#!/bin/bash
nginx -t && systemctl reload nginx
Without this, your cert renews but nginx keeps serving the old one until the next restart.
6. Reverse proxy
Your app runs on port 3000. The internet expects port 443. nginx bridges the gap:
server {
listen 443 ssl http2;
server_name yourdomain.com;
ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:3000;
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;
}
}
Test with nginx -t before reloading. A syntax error in the config takes down every site on the server, not just yours.
7. Process management
PaaS platforms restart your app when it crashes. On a VPS, you need PM2 or systemd:
# PM2
npm install -g pm2
pm2 start npm --name "myapp" -- start
pm2 save
pm2 startup
The pm2 startup command generates a systemd service so PM2 itself restarts after a server reboot. Without it, a reboot means your app stays down until you SSH in and notice.
8. Environment variables
Create a .env file on the server (not in the repo):
# /home/deploy/myapp/.env
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://...
# ... rest of your exported vars, minus the platform-specific ones
Permissions matter:
chmod 600 .env
chown deploy:deploy .env
Don't commit .env to git. Don't put it in a world-readable location. PaaS platforms encrypted these at rest — your plaintext file on disk is now the weakest link in your security chain.
9. Deploy pipeline
The PaaS deployed on git push. You need to replace that:
Option A: Git pull + PM2 restart
# deploy.sh
#!/bin/bash
set -e
cd /home/deploy/myapp
git pull origin main
npm ci --production
npm run build
pm2 restart myapp
Option B: GitHub Actions + SSH
Automate it so git push still triggers a deploy, but through your own pipeline.
The key difference from PaaS: if the build fails, you need to catch it. set -e in your deploy script is the minimum. Without it, a failed npm run build still runs pm2 restart, and PM2 restarts your app with stale build output.
10. Rollback plan
PaaS rollback is one click. VPS rollback is whatever you set up:
# Before deploy, tag the current state
git tag pre-deploy-$(date +%Y%m%d-%H%M%S)
# To rollback
git checkout pre-deploy-20260525-143022
npm ci --production
npm run build
pm2 restart myapp
Or use symlinked releases (the Capistrano pattern):
/home/deploy/myapp/
releases/
20260525-143022/
20260525-160000/
current -> releases/20260525-160000/
Rollback becomes changing a symlink and restarting PM2. No rebuild, no npm install, instant.
11. Logging
PaaS log viewers are gone. Replace them:
# PM2 logs
pm2 logs myapp --lines 100
# Or use journalctl if running as a systemd service
journalctl -u myapp -f
For log retention, PM2's pm2-logrotate module prevents your disk from filling up:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7
12. Health checks and uptime monitoring
The PaaS pinged your app and restarted it on failure. Set up your own:
- PM2 watches the process and restarts on crash (built-in).
- External monitoring (Uptime Kuma, self-hosted; or BetterStack, UptimeRobot) pings your public URL and alerts you when it's down.
Don't skip external monitoring. PM2 restarts your app, but it can't tell you when nginx is misconfigured, when your SSL cert expired, or when your server ran out of disk.
After the migration
13. Verify everything
Run through this checklist after your first deploy:
- App responds on HTTPS (not just HTTP)
- SSL cert covers all expected domains
- Environment variables are loaded correctly
- App restarts automatically after a crash (
pm2 restarttest) - App survives a server reboot
- Deploy script works end-to-end
- Rollback procedure works
- Logs are accessible and rotating
- DNS has propagated (check from multiple locations)
- Old PaaS deployment is stopped (not still serving traffic)
14. Cancel the PaaS
Don't cancel immediately. Keep the PaaS deployment running (but stopped) for at least a week. If something goes wrong on the VPS, you can point DNS back in five minutes instead of rebuilding from scratch.
What you gain, what you lose
You gain: predictable costs, full control, no cold starts, no vendor lock-in, SSH access, the ability to run anything.
You lose: zero-config deploys, automatic scaling, the PaaS team's on-call rotation, and the luxury of not thinking about servers.
That trade is worth it for most apps that have outgrown the free tier. But only if you replace what the PaaS was doing — not just the hosting, but the thirty invisible services around it.
