Lompat ke konten
·13 menit membaca

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.

Bagikan:X / TwitterLinkedIn

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#

bash
adduser deploy
usermod -aG sudo deploy

Siapkan Autentikasi SSH Key#

Di mesin lokal Anda:

bash
ssh-copy-id deploy@your-server-ip

Lalu nonaktifkan autentikasi password sepenuhnya:

bash
sudo nano /etc/ssh/sshd_config
bash
PasswordAuthentication no
PermitRootLogin no
bash
sudo systemctl restart sshd

Jika 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#

bash
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enable

Itu saja. Empat aturan. Hanya SSH dan traffic web yang bisa masuk.

Fail2Ban#

bash
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

Edit /etc/fail2ban/jail.local:

ini
[sshd]
enabled = true
port = ssh
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 3600
findtime = 600
bash
sudo systemctl enable fail2ban
sudo systemctl start fail2ban

Tiga 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#

bash
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgrades

Server 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:

bash
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:

bash
node -v  # v22.x.x atau LTS terkini
npm -v

Tip 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:

bash
nvm alias default 22

Ini 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#

bash
npm install -g pm2

Konfigurasi Ecosystem#

Jangan jalankan aplikasi dengan flag CLI. Gunakan file ecosystem.config.js. File ini di-version control, reproducible, dan self-documenting.

javascript
// 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:

bash
pm2 startup systemd
# Salin dan jalankan perintah yang ditampilkan
pm2 save

Ini 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:

bash
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress true

50MB 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#

bash
sudo apt install nginx -y

Konfigurasi#

nginx
# /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:

bash
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 nginx

Selalu 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.

bash
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.net

Certbot 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:

bash
sudo systemctl list-timers | grep certbot

Uji bahwa renewal berfungsi:

bash
sudo certbot renew --dry-run

Jika 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.

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 1

Buat executable:

bash
chmod +x deploy.sh

Deploy dari mesin lokal Anda:

bash
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:

bash
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.log

Sembilan 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"#

bash
pm2 monit

Ini 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:

bash
pm2 describe akousa

Ini menampilkan uptime, jumlah restart, dan snapshot memori. Jika Anda melihat 47 restart dalam 24 jam terakhir, itu petunjuk Anda.

"Sertifikat SSL expired"#

bash
sudo certbot certificates

Menampilkan semua sertifikat beserta tanggal kedaluwarsanya. Jika auto-renewal gagal:

bash
sudo certbot renew --force-renewal
sudo systemctl reload nginx

"Disk space penuh"#

bash
df -h
du -sh /var/log/*
pm2 flush

pm2 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#

bash
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.

bash
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:

bash
# ~/.ssh/config
Host akousa
    HostName 69.62.66.94
    User deploy
    IdentityFile ~/.ssh/id_ed25519

Sekarang 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.