Production Deploy on a VPS: A Practical Path for Your First Project
Shipping your first project to a server is the scariest step for many developers. You know how to write code, but words like "deploy", "nginx", and "systemd" feel like a different world. I have been administering servers for school systems for several years, and I can tell you from that experience: a production deploy is not magic — it is a clear sequence of steps you learn once. Below I walk through that sequence step by step, without unnecessary complexity.
Why a VPS Instead of PaaS?
PaaS platforms like Vercel, Railway, or Render are very convenient: you git push — the site is live. But in the local market, three reasons work in favor of a VPS:
- Price. A $4–8 VPS comfortably hosts several projects, a database, and cron jobs. On PaaS, every service costs money separately, the database costs extra — and as projects multiply, the difference gets sharp.
- Payment reality. Working with services that charge an international card monthly is still not smooth for everyone in Uzbekistan. Local providers accept payment in soums and will even provide a contract if you need paperwork.
- Full control. What to install, which version, how to configure — you decide everything. Working with school systems, this is the deciding factor for me: it must be crystal clear where the data lives and who can access it.
Let's be honest about the downsides too: security, updates, monitoring — all of it is on you. If the server goes down at night, you are the one bringing it back up. If your project is a single landing page and your time is expensive, PaaS may genuinely be the better choice. But for a real project with a backend, a database, and steady traffic, a VPS is worth learning.
A Minimal Security Baseline
The moment a new VPS goes online, bots start hammering it with password guesses — this is not a hypothesis; open the auth logs and see for yourself. So in the first 15 minutes, do four things:
# 1. Upload your SSH key to the server (on your local machine)
ssh-copy-id deploy@server-ip
# 2. Disable root login and password authentication
# in /etc/ssh/sshd_config:
# PermitRootLogin no
# PasswordAuthentication no
sudo systemctl restart sshd
# 3. Firewall: only the necessary ports are open
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw enable
# 4. fail2ban — automatically blocks repeated failed attempts
sudo apt install fail2ban -yThis is not a full security audit, but this exact baseline stops the majority of real-world attacks.
Your Node.js App as a systemd Service
Most people start with pm2, and that is fine. But the server already ships with a professional process manager — systemd. It comes with the OS, requires no separate daemon, collects logs in journald, and brings your service back up on its own after a server reboot. pm2 earns its place when you need cluster mode or its own monitoring interface. For a single app, systemd is simpler and sufficient.
The /etc/systemd/system/myapp.service file:
[Unit]
Description=My Node.js app
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/myapp
ExecStart=/usr/bin/node dist/server.js
Restart=always
RestartSec=5
Environment=NODE_ENV=production
EnvironmentFile=/home/deploy/myapp/.env
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now myapp
journalctl -u myapp -f # watch logs in real timeThanks to Restart=always, if the app crashes it restarts itself in 5 seconds — fewer late-night phone calls.
nginx: Reverse Proxy, gzip, and Static Caching
The app runs on port 3000, while the world sees nginx on 80/443. /etc/nginx/sites-available/myapp:
server {
listen 80;
server_name example.uz;
gzip on;
gzip_types text/css application/javascript application/json;
# nginx serves static files itself — no load on the app
location /static/ {
alias /home/deploy/myapp/public/;
expires 30d;
add_header Cache-Control "public, immutable";
}
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-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}Test and enable: sudo nginx -t && sudo systemctl reload nginx.
HTTPS: certbot in 2 Minutes
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d example.uzcertbot updates the nginx config itself and renews the certificate automatically before it expires. The era of "SSL is expensive and painful" is long gone — today HTTPS is free and a mandatory standard.
Zero-Downtime Updates — the Simplest Way
No complex orchestration needed. A simple deploy script:
#!/usr/bin/env bash
set -e
cd /home/deploy/myapp
git pull origin main
npm ci --omit=dev
npm run build
sudo systemctl restart myapp
# Health check: wait until the app responds
for i in {1..10}; do
sleep 2
curl -fs http://127.0.0.1:3000/health && echo "Deploy OK" && exit 0
done
echo "Deploy FAILED" && exit 1This approach has a 2–3 second gap — for most projects, users will not even notice. If you need true zero-downtime, bring the new version up side by side on port 3001, and once the health check passes, switch the port in proxy_pass and reload — an nginx reload does not drop active connections. Both approaches fit in a single bash script; Kubernetes is not needed at this stage.
Do You Need Docker?
The pragmatic answer: at the start — no. For one Node app and one Postgres, Docker is an extra layer: image builds, volumes, networking — the learning load grows while the benefit is not yet visible. Docker earns its keep when:
- The project has incompatible runtimes (different Node or Python versions)
- The same environment needs to be replicated across several servers
- The team has grown and the "works on my machine" problem starts costing real money
In the school systems, I use Docker precisely for the second reason: one system has to be rolled out identically to dozens of servers. For a single project on a single VPS, systemd + nginx is fully sufficient.
Backup: the Simplest cron + pg_dump
Production without backups is a slow-ticking time bomb. The simplest working scheme:
# /home/deploy/backup.sh
#!/usr/bin/env bash
set -e
DIR=/home/deploy/backups
DATE=$(date +%F)
pg_dump -U myapp mydb | gzip > "$DIR/db-$DATE.sql.gz"
# Delete copies older than 14 days
find "$DIR" -name "db-*.sql.gz" -mtime +14 -delete# crontab -e — every day at 03:00
0 3 * * * /home/deploy/backup.sh >> /home/deploy/backup.log 2>&1The important rule: if the backup lives only on the server itself, it is not a backup. At least once a week, copy it somewhere else — another server, object storage, or even your personal computer. And once or twice a year, actually try a restore: a backup that cannot be restored is worthless.
Conclusion
Deploying on a VPS is a craft you learn once: SSH key, ufw, systemd unit, nginx config, certbot, a simple deploy script, and a cron backup. With these seven steps your project runs stably, at a professional level. When the project grows, you add CI/CD, monitoring, and Docker if needed on top — but the foundation stays the same.