Setup VPS yang Benar-Benar Berfungsi: Node.js, PM2, Nginx, dan Deploy Tanpa Downtime
Setup deployment VPS yang saya gunakan di production — hardening Ubuntu, PM2 cluster mode, reverse proxy Nginx, SSL, dan script deploy yang belum pernah mengecewakan saya. Tanpa teori, hanya yang berfungsi.
Blog ini berjalan di VPS seharga $10/bulan. Bukan Vercel, bukan AWS, bukan cluster Kubernetes yang dikelola oleh tim enam orang. Satu box Ubuntu dengan Nginx, PM2, dan script bash yang melakukan deploy dalam waktu kurang dari 30 detik.
Saya sudah mencoba jalur lain. Saya pernah menggunakan Vercel (bagus sampai Anda butuh cron job, WebSocket persisten, atau sekadar kontrol). Saya pernah menggunakan AWS (bagus jika Anda suka menghabiskan setengah hari di IAM policy). Saya selalu kembali ke VPS.
Tapi inilah masalahnya: setiap tutorial "deploy ke VPS" di internet berhenti di happy path. Mereka menunjukkan cara install Node.js dan menjalankan node server.js dan menyebutnya production. Lalu server Anda kena brute-force SSH, proses Anda mati jam 3 pagi karena tidak ada yang setup process manager, dan sertifikat SSL Anda expired tiga bulan lalu.
Inilah panduan yang saya harap ada. Semua yang ada di sini sudah teruji — setup yang persis ini melayani halaman yang sedang Anda baca sekarang.
Mulai dengan Keamanan, Bukan Kode#
Sebelum Anda berpikir tentang Node.js, amankan box-nya. Instance VPS baru adalah target. Bot otomatis mulai menghantam port SSH Anda dalam hitungan menit setelah provisioning.
Buat User Non-Root#
adduser deploy
usermod -aG sudo deploySiapkan Autentikasi SSH Key#
Di mesin lokal Anda:
ssh-copy-id deploy@your-server-ipLalu nonaktifkan autentikasi password sepenuhnya:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshdJika Anda melewatkan ini, Anda akan melihat ribuan percobaan login gagal di auth log Anda dalam hitungan hari. Itu bukan paranoia — itu hari biasa di internet publik.
Firewall dengan UFW#
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableItu saja. Empat aturan. Hanya SSH dan traffic web yang bisa masuk.
Fail2Ban#
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.localEdit /etc/fail2ban/jail.local:
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600sudo systemctl enable fail2ban
sudo systemctl start fail2banTiga kali gagal login SSH dan Anda diblokir selama satu jam. Saya pernah melihat Fail2Ban memblokir ratusan IP dalam satu hari. Ini berfungsi.
Update Keamanan Otomatis#
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgradesServer Anda sekarang akan otomatis menginstall patch keamanan. Satu hal yang tidak perlu Anda ingat lagi.
Node.js: Gunakan NVM, Bukan apt#
Saya melihat ini di setiap tutorial: sudo apt install nodejs. Jangan lakukan itu.
Repo paket Ubuntu mengirimkan versi Node.js yang kuno. Bahkan PPA NodeSource tertinggal. Dan ketika Anda perlu berpindah antara Node 20 dan Node 22 untuk proyek yang berbeda, Anda terjebak.
NVM menyelesaikan ini:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*Sekarang verifikasi:
node -v # v22.x.x atau LTS terkini
npm -vTip yang tidak kentara: ketika Anda menginstall paket global dengan NVM (seperti PM2), mereka terikat ke versi Node itu. Jika Anda berpindah versi dengan nvm use, global Anda hilang. Set default Anda dan tetap gunakan itu di server:
nvm alias default 22Ini pernah mengenai saya tepat satu kali. Sekali sudah cukup.
PM2: Process Manager yang Membuktikan Nilainya#
PM2 adalah perbedaan antara "sudah di-deploy" dan "siap production." PM2 menangani manajemen proses, clustering, rotasi log, auto-restart saat crash, dan startup script. Gratis.
Install dan Setup#
npm install -g pm2Konfigurasi Ecosystem#
Jangan jalankan aplikasi dengan flag CLI. Gunakan file ecosystem.config.js. File ini di-version control, reproducible, dan self-documenting.
// ecosystem.config.js
module.exports = {
apps: [
{
name: "akousa",
script: "node_modules/.bin/next",
args: "start -p 3002",
cwd: "/var/www/akousa.net",
instances: 2,
exec_mode: "cluster",
max_memory_restart: "500M",
env: {
NODE_ENV: "production",
PORT: 3002,
},
// Graceful shutdown
kill_timeout: 5000,
listen_timeout: 10000,
wait_ready: false,
// Logging
log_date_format: "YYYY-MM-DD HH:mm:ss Z",
error_file: "/var/log/pm2/akousa-error.log",
out_file: "/var/log/pm2/akousa-out.log",
merge_logs: true,
// Auto-restart saat gagal
autorestart: true,
max_restarts: 10,
min_uptime: "10s",
// Jangan watch di production
watch: false,
},
],
};Izinkan saya menjelaskan pilihan-pilihan yang penting:
instances: 2 alih-alih "max": Di VPS kecil dengan 1-2 core, "max" terdengar pintar tapi akan menjalankan proses yang berebut resource selama build. Dua instance memberi Anda reload tanpa downtime sambil menyisakan ruang. Di mesin 4+ core, silakan gunakan "max".
exec_mode: "cluster": Ini yang mengaktifkan reload tanpa downtime. Tanpa cluster mode, pm2 reload hanyalah restart yang lebih mewah. Dengan cluster mode, PM2 merestart instance satu per satu — aplikasi Anda tidak pernah sepenuhnya offline.
max_memory_restart: "500M": Aplikasi Next.js Anda punya memory leak? PM2 akan merestartnya sebelum ia OOM-kill server Anda. Ini sudah menyelamatkan saya dari alert jam 2 pagi lebih dari sekali.
kill_timeout: 5000: Memberi aplikasi Anda 5 detik untuk menyelesaikan request yang sedang berlangsung sebelum PM2 mematikannya secara paksa. Default (1600ms) terlalu agresif untuk aplikasi dengan koneksi database.
watch: false: Saya pernah melihat orang membiarkan watch: true di production. PM2 lalu merestart aplikasi setiap kali file log berubah. Aplikasi Anda masuk loop restart. Jangan.
Startup Script#
Buat PM2 bertahan saat reboot:
pm2 startup systemd
# Salin dan jalankan perintah yang ditampilkan
pm2 saveIni menghasilkan service systemd. Setelah server reboot, aplikasi Anda kembali secara otomatis. Uji ini — reboot server Anda dan verifikasi. Jangan asumsikan.
Rotasi Log#
Log akan memakan disk Anda pada akhirnya. Install modul rotasi:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true50MB maks per file, simpan 7 file yang dirotasi, kompres yang lama. Tanpa ini, saya pernah melihat /var/log mengisi disk 25GB dalam tiga minggu di aplikasi dengan traffic sedang.
Nginx: Reverse Proxy yang Lebih Banyak dari yang Anda Kira#
"Kenapa tidak langsung ekspos Node.js di port 80?"
Karena Nginx menangani hal-hal yang tidak seharusnya Node.js buang waktu untuk: terminasi SSL, serving file statis, kompresi gzip, buffering request, pembatasan koneksi, dan penanganan yang mulus untuk client yang lambat. Ia ditulis dalam C dan dibuat khusus untuk ini.
Install#
sudo apt install nginx -yKonfigurasi#
# /etc/nginx/sites-available/akousa.net
upstream node_app {
server 127.0.0.1:3002;
keepalive 64;
}
server {
listen 80;
listen [::]:80;
server_name akousa.net www.akousa.net;
# Redirect semua HTTP ke HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name akousa.net www.akousa.net;
# SSL (dikelola oleh Certbot — baris-baris ini ditambahkan secara otomatis)
ssl_certificate /etc/letsencrypt/live/akousa.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/akousa.net/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
# Header keamanan
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Kompresi gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
application/wasm;
# Pengaturan proxy
location / {
proxy_pass http://node_app;
proxy_http_version 1.1;
# Header
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;
# Dukungan WebSocket (jika Anda butuhkan)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeout — cukup besar tapi tidak tanpa batas
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering — biarkan Nginx menangani client yang lambat
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
}
# Aset statis Next.js — biarkan Nginx yang menyajikan langsung
location /_next/static/ {
alias /var/www/akousa.net/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
# File statis publik
location /static/ {
alias /var/www/akousa.net/public/static/;
expires 30d;
access_log off;
}
# Blokir akses ke file dot
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}Aktifkan:
sudo ln -s /etc/nginx/sites-available/akousa.net /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginxSelalu jalankan nginx -t sebelum reload. Saya pernah push konfigurasi yang rusak dan membuat situs down karena melewatkan pengecekan sintaks. Lima karakter nginx -t akan menyelamatkan saya tiga puluh menit debugging yang panik.
Hal-hal yang kebanyakan tutorial lewatkan di konfigurasi ini:
Blok upstream dengan keepalive 64: Nginx menggunakan ulang koneksi ke backend Node.js Anda alih-alih membuka koneksi TCP baru untuk setiap request. Ini penting saat beban tinggi.
proxy_buffering on: Nginx membaca seluruh respons dari Node.js ke memori, lalu mengirimkannya ke client dengan kecepatan berapa pun yang bisa ditangani client. Tanpa ini, client lambat di koneksi 3G mengikat worker Node.js Anda.
Menyajikan _next/static/ secara langsung: Ini adalah aset yang di-hash dan immutable. Biarkan Nginx menyajikannya dari disk dengan header cache 365 hari. Proses Node.js Anda tidak seharusnya membuang waktu untuk ini.
SSL dalam Lima Menit#
Let's Encrypt menyelesaikan masalah SSL. Jika Anda masih membayar untuk sertifikat di 2026, berhentilah.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.netCertbot akan meminta email Anda, menerima ToS, dan secara otomatis memodifikasi konfigurasi Nginx Anda untuk menyertakan direktif SSL. Itu saja.
Verifikasi Auto-Renewal#
Certbot menginstall timer systemd yang memeriksa dua kali sehari dan memperbarui sertifikat dalam 30 hari sebelum kedaluwarsa:
sudo systemctl list-timers | grep certbotUji bahwa renewal berfungsi:
sudo certbot renew --dry-runJika dry run berhasil, Anda tidak perlu memikirkan SSL lagi. Jika gagal, biasanya karena port 80 diblokir (periksa aturan UFW Anda) atau Nginx tidak berjalan.
Satu hal yang pernah mengenai saya: jika Anda setup Nginx sebelum menjalankan Certbot, pastikan server block Anda mendengarkan di port 80 tanpa redirect HTTPS terlebih dahulu. Certbot perlu mencapai port 80 untuk tantangan HTTP-01. Setelah Certbot berhasil berjalan, baru tambahkan redirect.
Script Deploy#
Ini adalah script yang berjalan setiap kali saya push ke production. Tanpa platform CI/CD, tanpa GitHub Actions. Hanya SSH dan bash.
#!/bin/bash
# deploy.sh — deployment hampir tanpa downtime
set -euo pipefail
APP_DIR="/var/www/akousa.net"
APP_NAME="akousa"
LOG_FILE="/var/log/deploy.log"
HEALTH_URL="http://localhost:3002"
MAX_RETRIES=10
RETRY_INTERVAL=3
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
log "=== Deploy dimulai ==="
cd "$APP_DIR"
# Tarik kode terbaru
log "Menarik perubahan terbaru..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
# Install dependensi
log "Menginstall dependensi..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
# Build
log "Membangun aplikasi..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
if [ $? -ne 0 ]; then
log "ERROR: Build gagal. Membatalkan deploy."
exit 1
fi
# Reload PM2 (tanpa downtime di cluster mode)
log "Mereload PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
# Health check dengan retry
log "Menjalankan health check..."
for i in $(seq 1 $MAX_RETRIES); do
HTTP_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$HEALTH_URL" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
log "Health check berhasil (HTTP $HTTP_CODE)"
log "=== Deploy berhasil diselesaikan ==="
exit 0
fi
log "Percobaan health check $i/$MAX_RETRIES (HTTP $HTTP_CODE). Mencoba lagi dalam ${RETRY_INTERVAL}s..."
sleep $RETRY_INTERVAL
done
log "ERROR: Health check gagal setelah $MAX_RETRIES percobaan"
log "Melakukan rollback ke state PM2 sebelumnya..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1Buat executable:
chmod +x deploy.shDeploy dari mesin lokal Anda:
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"Keputusan kunci dalam script ini:
set -euo pipefail: Script langsung keluar pada error apa pun. Tanpa ini, npm install yang gagal diam-diam melanjutkan ke langkah build, dan Anda mendapat error yang membingungkan yang membuang 20 menit untuk debug.
rm -rf .next sebelum build: Next.js punya build cache yang kadang menghasilkan output yang basi. Saya pernah kena ini sekali — halaman menampilkan konten lama meskipun source code sudah diperbarui. Menghapus direktori build menambah mungkin 15 detik ke build tapi menjamin output yang segar.
pm2 reload alih-alih pm2 restart: Ini bagian tanpa downtime-nya. Di cluster mode, reload melakukan rolling restart — ia mengaktifkan instance baru dengan kode yang diperbarui, menunggu mereka siap, lalu mematikan yang lama secara mulus. Tidak pernah ada momen nol instance berjalan.
Health check dengan retry: Next.js butuh beberapa detik untuk warming up setelah restart. Script menunggu hingga 30 detik (10 retry x 3 detik), memeriksa apakah aplikasi merespons dengan HTTP 200. Jika tidak, ada yang salah dan Anda perlu tahu segera — bukan mengetahuinya dari pengguna.
Rollback saat gagal: Jika health check gagal setelah semua retry, script merestart PM2 (yang memuat state tersimpan terakhir). Ini bukan rollback yang sempurna, tapi lebih baik daripada membiarkan server dalam keadaan rusak.
Saat Sesuatu Rusak Jam 2 Pagi#
Berikut yang pernah saya debug di setup yang persis ini:
"Situsnya down"#
Perintah pertama yang harus dijalankan:
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.logSembilan dari sepuluh kali, pm2 logs langsung memberi tahu Anda apa yang terjadi. Environment variable yang hilang, koneksi database yang gagal, atau unhandled promise rejection.
"Memori terus bertambah"#
pm2 monitIni memberi Anda dashboard live CPU dan memori per proses. Jika memori naik terus tanpa leveling off, Anda punya leak. Pengaturan max_memory_restart di konfigurasi ecosystem Anda adalah jaring pengaman — PM2 akan merestart proses sebelum ia menghabiskan server Anda.
Untuk investigasi lebih dalam:
pm2 describe akousaIni menampilkan uptime, jumlah restart, dan snapshot memori. Jika Anda melihat 47 restart dalam 24 jam terakhir, itu petunjuk Anda.
"Sertifikat SSL expired"#
sudo certbot certificatesMenampilkan semua sertifikat beserta tanggal kedaluwarsanya. Jika auto-renewal gagal:
sudo certbot renew --force-renewal
sudo systemctl reload nginx"Disk space penuh"#
df -h
du -sh /var/log/*
pm2 flushpm2 flush membersihkan semua file log PM2 secara langsung. Jika Anda tidak setup rotasi log (sudah saya bilang), di sinilah Anda merasakan sakitnya.
Perintah yang Saya Jalankan Setiap Pagi#
ssh deploy@akousa.net "pm2 status && df -h / && uptime"Tiga hal dalam satu baris: apakah proses saya berjalan, apakah disk saya baik-baik saja, apakah server kelebihan beban. Butuh dua detik. Menangkap masalah sebelum pengguna menyadarinya.
Apa yang Kebanyakan Panduan Tidak Beritahu Anda#
Build step Anda adalah kerentanan terbesar. Di VPS 1GB RAM, npm run build untuk aplikasi Next.js bisa mengonsumsi 800MB+ memori. Jika PM2 menjalankan aplikasi Anda di dua instance selama build, Anda akan OOM. Solusinya: gunakan swap file (minimal 2GB), atau hentikan aplikasi selama build dan terima beberapa detik downtime. Saya menggunakan swap.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab--legacy-peer-deps di perintah install Anda adalah bau kode, bukan solusi. Saya menggunakannya karena beberapa paket di dependency tree saya belum memperbarui range peer dependency mereka. Setiap beberapa bulan saya mencoba menghapusnya. Suatu hari nanti akan berhasil. Sampai saat itu, saya tetap kirim.
Uji script deploy Anda dari awal. Clone repo Anda di server baru dan jalankan setiap langkah secara manual. Jumlah masalah "berfungsi di mesin saya" yang tersembunyi dalam script deploy itu memalukan. Saya menemukan tiga masalah di script saya ketika melakukan ini — paket global yang hilang, permission file yang salah, dan path yang hanya ada karena setup manual sebelumnya.
Masukkan IP server Anda di SSH config. Berhenti mengetik alamat IP:
# ~/.ssh/config
Host akousa
HostName 69.62.66.94
User deploy
IdentityFile ~/.ssh/id_ed25519Sekarang ssh akousa saja yang Anda butuhkan. Hal-hal kecil menumpuk.
Checklist Lengkap#
Sebelum Anda menyebutnya selesai:
- User non-root dengan akses sudo
- Autentikasi SSH key saja, autentikasi password dinonaktifkan
- UFW diaktifkan dengan hanya port yang diperlukan terbuka
- Fail2Ban melindungi SSH
- Upgrade keamanan otomatis diaktifkan
- Node.js diinstall via NVM
- PM2 menjalankan aplikasi Anda dalam cluster mode
- Startup script PM2 dikonfigurasi (bertahan saat reboot)
- Rotasi log PM2 diinstall
- Reverse proxy Nginx dengan header yang benar
- SSL via Let's Encrypt dengan auto-renewal
- Script deploy dengan health check
- Swap file dikonfigurasi (untuk ruang saat build)
- Diuji: reboot server dan verifikasi semua kembali berjalan
Item terakhir itu yang sering dilewatkan orang. Jangan jadi orang itu. Reboot server, tunggu 60 detik, dan periksa apakah aplikasi Anda live. Jika tidak, startup script Anda salah konfigurasi dan Anda akan mengetahuinya di waktu yang paling tidak tepat.
Apakah Ini "Enterprise-Grade"?#
Tidak. Dan itulah intinya.
Setup ini melayani blog ini dengan andal seharga kurang dari $10/bulan. Di-deploy dalam 30 detik dengan satu perintah. Saya memahami setiap bagiannya. Ketika ada yang rusak, saya tahu persis di mana harus mencari.
Bisakah saya menggunakan Docker? Tentu. Bisakah saya menggunakan Kubernetes? Secara teknis bisa. Bisakah saya setup pipeline CI/CD lengkap dengan staging environment dan canary deployment? Tentu saja.
Tapi saya belajar bahwa infrastruktur terbaik adalah yang benar-benar Anda pahami, bisa Anda debug jam 2 pagi, dan tidak lebih mahal dari penghasilan proyeknya. Untuk situs pribadi, MVP SaaS, atau startup kecil — inilah setup itu.
Kirim dulu. Skalakan saat perlu. Dan selalu, selalu, uji script deploy Anda di server baru.