"Kode yang cuma jalan di laptopmu bukan produk. Produk itu kode yang jalan di server, jam 3 pagi, tanpa kamu di depan laptop."
Tentang Artikel Ini
Ada dua cara deploy backend Node.js ke VPS: pakai Docker (sudah dibahas di artikel #05), atau jalankan langsung di server dengan process manager. Artikel ini membahas jalur kedua — deploy manual dengan PM2, tanpa container.
Ini bukan cara yang "lebih usang" dibanding Docker. Untuk aplikasi tunggal di satu VPS kecil, deploy langsung sering lebih sederhana: tidak ada layer container untuk di-debug, tidak ada image untuk di-build, tidak ada Docker daemon untuk dikelola. Kamu SSH, git pull, restart process, selesai. Banyak tim kecil dan solo developer di Indonesia jalankan production mereka persis seperti ini — dan itu keputusan yang sah, bukan kompromi.
Kita akan setup toko-api (project yang sama dari artikel-artikel sebelumnya) dari VPS kosong sampai bisa diakses lewat domain dengan HTTPS asli.
Setelah selesai, kamu akan bisa:
- Setup VPS Ubuntu dari nol dengan konfigurasi keamanan dasar yang benar
- Menjalankan aplikasi Node.js sebagai service production dengan PM2
- Konfigurasi Nginx sebagai reverse proxy
- Mendapatkan sertifikat SSL asli dan gratis dengan Let's Encrypt
- Membuat script deploy yang aman dan berulang
Prasyarat: Sudah punya REST API yang siap deploy (artikel #02). Familiar dengan terminal dan SSH (artikel #00 — Linux & Terminal). Sudah punya VPS (DigitalOcean, Vultr, Niagahoster, atau provider lain) dan domain yang bisa kamu atur DNS-nya.
Daftar Isi
- Setup VPS dari Nol
- Install Node.js dan Tooling Dasar
- Setup PostgreSQL di VPS
- Clone dan Konfigurasi Aplikasi
- PM2: Process Manager Production
- Nginx sebagai Reverse Proxy
- SSL Gratis dengan Let's Encrypt
- Automated Deployment
Bab 1: Setup VPS dari Nol
Login Pertama dan User Non-Root
VPS baru biasanya cuma punya user root. Jangan pernah jalankan aplikasi production sebagai root — kalau ada celah keamanan di aplikasimu, penyerang langsung dapat akses penuh ke server.
# Login pertama kali sebagai root
ssh root@<IP_VPS>
# Buat user baru
adduser deploy
# Beri akses sudo
usermod -aG sudo deploy
# Pindah ke user baru untuk sisa setup
su - deploySSH Key: Matikan Login Password
Password bisa di-brute-force. SSH key (asimetris, ribuan karakter) praktis tidak bisa.
# Di komputer lokal — kalau belum punya key
ssh-keygen -t ed25519 -C "email@kamu.com"
# Copy public key ke VPS
ssh-copy-id deploy@<IP_VPS>
# Test login pakai key (tanpa diminta password)
ssh deploy@<IP_VPS>Setelah yakin login dengan key berhasil, matikan login password sepenuhnya:
# Di VPS
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin no
sudo systemctl restart sshSebelum logout, buka terminal baru dan test SSH lagi. Kalau config salah dan kamu terlanjur logout dari sesi lama, kamu bisa terkunci total dari server. Selalu punya sesi cadangan yang masih terbuka saat mengubah config SSH.
Firewall: Tutup Semua Port Kecuali yang Dibutuhkan
# UFW (Uncomplicated Firewall) — sudah terinstall default di Ubuntu
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp # HTTP
sudo ufw allow 443/tcp # HTTPS
sudo ufw enable
# Cek status
sudo ufw statusStatus: active
To Action From
-- ------ ----
OpenSSH ALLOW Anywhere
80/tcp ALLOW Anywhere
443/tcp ALLOW Anywhere
Perhatikan: port aplikasi Node.js (3000) tidak dibuka ke publik. Aplikasi hanya diakses lewat Nginx di port 80/443 — pola ini dibahas lebih detail di Bab 6.
fail2ban: Blokir IP yang Mencoba Brute-Force
sudo apt update
sudo apt install -y fail2ban
sudo systemctl enable --now fail2banfail2ban memantau log SSH dan otomatis memblokir IP yang gagal login berkali-kali dalam waktu singkat — lapisan pertahanan tambahan di atas SSH key.
Swap File: Wajib untuk VPS RAM Kecil
VPS entry-level sering cuma punya 512MB–1GB RAM. npm install di project dengan banyak dependencies bisa memakan RAM lebih dari itu sesaat — tanpa swap, proses langsung di-kill oleh Linux OOM killer di tengah jalan, biasanya dengan pesan error yang tidak jelas kaitannya dengan RAM sama sekali.
# Cek apakah sudah ada swap
sudo swapon --show
# Kalau kosong, buat swap file 2GB
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
# Permanen — supaya aktif lagi setelah reboot
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
# Verifikasi
free -hIni penyebab paling umum dari
npm installyang "hang" atau gagal tanpa pesan error yang jelas di VPS murah. Kalau kamu pernah dengar cerita "npm install macet terus di VPS tapi lancar di laptop", curigai RAM habis duluan sebelum swap.
Bab 2: Install Node.js dan Tooling Dasar
Node.js lewat NodeSource
Jangan pakai apt install nodejs langsung — versi di repository Ubuntu biasanya jauh tertinggal. Gunakan NodeSource untuk versi LTS terbaru:
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
node --version # v20.x.x
npm --versionGit
sudo apt install -y git
# Generate SSH key khusus untuk deploy (kalau clone dari private repo)
ssh-keygen -t ed25519 -C "deploy@toko-api" -f ~/.ssh/deploy_key
cat ~/.ssh/deploy_key.pub
# Tambahkan sebagai Deploy Key di GitHub repo settings (read-only cukup)Nginx
sudo apt install -y nginx
sudo systemctl enable --now nginx
# Test — buka http://<IP_VPS> di browser, harus muncul halaman default NginxBab 3: Setup PostgreSQL di VPS
Ada dua pilihan: install PostgreSQL langsung di VPS, atau pakai managed database (lebih mudah di-maintain, ada backup otomatis, tapi berbayar terpisah). Untuk VPS kecil dengan traffic rendah, install langsung masih masuk akal.
sudo apt install -y postgresql postgresql-contrib
sudo -u postgres psql-- Di dalam psql prompt
CREATE DATABASE toko_db;
CREATE USER toko_user WITH ENCRYPTED PASSWORD 'ganti-dengan-password-kuat';
GRANT ALL PRIVILEGES ON DATABASE toko_db TO toko_user;
\q# Jalankan migration (schema dari artikel #02)
psql -U toko_user -d toko_db -h localhost -f migrations/001_create_categories.sql
psql -U toko_user -d toko_db -h localhost -f migrations/002_create_products.sqlPenting: Secara default PostgreSQL di Ubuntu hanya menerima koneksi dari
localhost— itu yang kita inginkan. Aplikasi Node.js jalan di server yang sama, jadi tidak perlu expose port 5432 ke publik sama sekali. Jangan buka port database di firewall.
Bab 4: Clone dan Konfigurasi Aplikasi
# Clone project ke server
cd ~
git clone git@github.com:username/toko-api.git
cd toko-api
npm ci --only=productionEnvironment Variables Production
# .env — JANGAN commit ke git, buat langsung di server
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://toko_user:password-kuat@localhost:5432/toko_db
JWT_SECRET=random-string-panjang-minimal-32-karakter-beda-dari-development# Generate JWT_SECRET yang aman
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"Jangan pernah pakai
JWT_SECRETyang sama antara development dan production. Kalau.envdevelopment pernah bocor (commit tidak sengaja, screenshot, dll), production tetap aman selama secret-nya berbeda.
Bab 5: PM2: Process Manager Production
Kenapa Tidak Cukup node src/server.js
Jalankan node src/server.js langsung di terminal SSH berhenti begitu kamu logout. Dan kalau aplikasi crash karena error tak tertangani, tidak ada yang me-restart-nya. PM2 menyelesaikan kedua masalah ini: berjalan di background, auto-restart saat crash, dan bisa start otomatis saat server reboot.
sudo npm install -g pm2Ecosystem File
// ecosystem.config.js
export default {
apps: [
{
name: 'toko-api',
script: './src/server.js',
instances: 'max', // cluster mode — satu process per CPU core
exec_mode: 'cluster',
env: {
NODE_ENV: 'production',
},
max_memory_restart: '300M', // restart otomatis kalau memory leak
error_file: './logs/error.log',
out_file: './logs/out.log',
time: true,
},
],
};pm2 start ecosystem.config.js
# Lihat status semua process
pm2 status
# Lihat log real-time
pm2 logs toko-api
# Restart (zero-downtime di cluster mode)
pm2 reload toko-api
# Stop
pm2 stop toko-apiCluster Mode: Manfaatkan Semua CPU Core
Node.js secara default single-threaded — satu process hanya pakai satu CPU core. instances: 'max' menyuruh PM2 menjalankan satu process per core yang tersedia, semuanya berbagi port yang sama lewat load balancing internal PM2.
# Cek berapa core yang dipakai
pm2 status
# ┌────┬──────────────┬─────────┬─────────┬──────────┐
# │ id │ name │ mode │ ↺ │ status │
# ├────┼──────────────┼─────────┼─────────┼──────────┤
# │ 0 │ toko-api │ cluster │ 0 │ online │
# │ 1 │ toko-api │ cluster │ 0 │ online │
# └────┴──────────────┴─────────┴─────────┴──────────┘Kalau aplikasimu menyimpan state di memory (misalnya rate limiter yang menyimpan counter di variable biasa, bukan Redis), cluster mode akan membuat state itu tidak konsisten antar process — tiap process punya memory sendiri. Untuk
toko-api, semua state penting sudah di PostgreSQL, jadi aman. Kalau kamu menambahkan sesuatu yang stateful in-memory, pindahkan ke Redis dulu sebelum pakai cluster mode.
Start Otomatis Saat Server Reboot
pm2 startup
# Jalankan perintah yang di-print (biasanya perlu sudo, sekali saja)
pm2 save
# Simpan daftar process yang sedang jalan — ini yang akan di-restore saat bootLog Rotation
Tanpa rotasi, file log di ./logs/out.log dan ./logs/error.log tumbuh tanpa batas — bisa memenuhi disk VPS kecil dalam hitungan minggu untuk aplikasi dengan traffic lumayan.
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 7 # simpan 7 file rotasi terakhir
pm2 set pm2-logrotate:compress trueBab 6: Nginx sebagai Reverse Proxy
Kenapa Tidak Expose Node.js Langsung ke Internet
Nginx di depan aplikasi Node.js memberi beberapa hal yang tidak ingin kamu implementasikan sendiri: terminasi SSL, serving file statis yang efisien, dan buffer request yang melindungi Node.js dari slow client. Node.js fokus jadi application server, Nginx yang menghadap internet.
# /etc/nginx/sites-available/toko-api
server {
listen 80;
server_name api.tokokamu.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
}
location /health {
proxy_pass http://localhost:3000;
access_log off; # jangan penuhi log dengan health check tiap beberapa detik
}
}# Aktifkan konfigurasi
sudo ln -s /etc/nginx/sites-available/toko-api /etc/nginx/sites-enabled/
# Hapus default config (kalau masih ada)
sudo rm /etc/nginx/sites-enabled/default
# Test config sebelum reload — jangan skip ini
sudo nginx -t
sudo systemctl reload nginxDNS
Sebelum lanjut ke SSL, arahkan domain ke IP VPS lewat DNS provider-mu (Cloudflare, Niagahoster, Domainesia, dll):
Type: A
Name: api (untuk api.tokokamu.com) atau @ (untuk tokokamu.com)
Value: <IP_VPS>
TTL: Auto / 3600
Tunggu propagasi DNS (biasanya beberapa menit, kadang sampai 24 jam) sebelum lanjut — Certbot di bab berikutnya akan gagal kalau domain belum benar-benar mengarah ke server ini.
# Verifikasi DNS sudah propagasi
dig api.tokokamu.com +short
# Harus mengembalikan IP VPS kamuBab 7: SSL Gratis dengan Let's Encrypt
Install Certbot
sudo apt install -y certbot python3-certbot-nginxDapatkan Sertifikat
sudo certbot --nginx -d api.tokokamu.comCertbot akan bertanya email (untuk notifikasi expiry) dan menawarkan redirect HTTP→HTTPS otomatis — pilih ya. Setelah selesai, Certbot otomatis mengubah config Nginx-mu untuk menambahkan blok listen 443 ssl dan path sertifikat:
# /etc/nginx/sites-available/toko-api (setelah Certbot)
server {
listen 443 ssl;
server_name api.tokokamu.com;
ssl_certificate /etc/letsencrypt/live/api.tokokamu.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.tokokamu.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_pass http://localhost:3000;
# ... (sama seperti sebelumnya)
}
}
server {
listen 80;
server_name api.tokokamu.com;
return 301 https://$host$request_uri; # redirect otomatis dari Certbot
}Auto-Renewal
Sertifikat Let's Encrypt berlaku 90 hari. Certbot menginstall systemd timer yang otomatis renew sebelum expired — biasanya tidak perlu diapa-apakan, tapi verifikasi:
# Simulasikan renewal tanpa benar-benar mengubah sertifikat
sudo certbot renew --dry-run
# Cek timer aktif
sudo systemctl status certbot.timerKalau
--dry-rungagal, jangan tunggu sampai sertifikat asli expired untuk mencari tahu kenapa. Penyebab paling umum: port 80 diblokir firewall (Certbot butuh ini untuk verifikasi domain) atau Nginx config yang tidak valid.
Bab 8: Automated Deployment
Deploy Script Sederhana
#!/bin/bash
# scripts/deploy.sh — dijalankan di VPS
set -e # hentikan script kalau ada perintah yang gagal
echo "==> Pull kode terbaru..."
git pull origin main
echo "==> Install dependencies..."
npm ci --only=production
echo "==> Jalankan migration (kalau ada yang baru)..."
# npm run migrate:up # kalau sudah pakai migration tool
echo "==> Reload aplikasi (zero-downtime)..."
pm2 reload toko-api
echo "==> Verifikasi..."
sleep 2
curl -sf http://localhost:3000/health || (echo "❌ Health check gagal!" && exit 1)
echo "✅ Deploy selesai."chmod +x scripts/deploy.sh
./scripts/deploy.shpm2 reload (bukan restart) penting di cluster mode — dia me-restart tiap process satu per satu, jadi selalu ada minimal satu process yang melayani request selama proses reload berlangsung. Tidak ada downtime yang terlihat user.
Error Umum dan Cara Mengatasinya
1. Nginx menampilkan 502 Bad Gateway
Artinya Nginx berhasil menerima request tapi tidak bisa menghubungi aplikasi di localhost:3000. Penyebab paling sering: aplikasi belum jalan, atau crash.
pm2 status # apakah statusnya "online"?
pm2 logs toko-api --lines 50 # lihat error terakhir2. Aplikasi tidak hidup lagi setelah server di-reboot
Biasanya karena pm2 save belum pernah dijalankan setelah pm2 startup, atau dijalankan sebelum aplikasi pertama kali di-start.
pm2 startup # jalankan perintah yang di-print
pm2 start ecosystem.config.js
pm2 save # WAJIB dijalankan lagi setelah start3. Error: connect ECONNREFUSED 127.0.0.1:5432
PostgreSQL belum jalan, atau DATABASE_URL di .env salah.
sudo systemctl status postgresql
psql -U toko_user -d toko_db -h localhost -c "SELECT 1" # test koneksi manual4. Certbot gagal dengan Timeout during connect
DNS domain belum propagasi ke IP VPS ini, atau port 80 diblokir firewall. Verifikasi kedua hal itu dulu (dig, sudo ufw status) sebelum mencoba Certbot lagi.
Batas Script Manual Ini
Script di atas masih harus kamu jalankan manual lewat SSH setiap kali ada perubahan kode — cukup untuk project kecil atau solo developer, tapi gampang lupa dan tidak ada jejak siapa men-deploy apa kapan. Langkah wajar berikutnya: pindahkan proses ini ke pipeline CI/CD yang jalan otomatis setiap git push ke main, dibahas tuntas di artikel: CI/CD untuk Backend Developer dengan GitHub Actions.
Penutup
toko-api sekarang jalan di VPS sungguhan, di belakang Nginx, dengan HTTPS asli dan auto-restart kalau crash. Ringkasan apa yang sudah berdiri:
Arsitektur Final
Internet
│
▼ :443 (HTTPS)
Nginx (reverse proxy + SSL termination)
│
▼ :3000 (localhost only)
PM2 (cluster mode, beberapa process Node.js)
│
▼ :5432 (localhost only)
PostgreSQL
Production Checklist
- Login SSH pakai key, password authentication dimatikan
- Firewall (
ufw) hanya membuka port 22, 80, 443 -
fail2banaktif untuk proteksi brute-force SSH - Aplikasi jalan sebagai user non-root
-
.envproduction punyaJWT_SECRETsendiri, beda dari development - PM2 di cluster mode,
pm2 startup+pm2 savesudah dijalankan - Port aplikasi (3000) dan database (5432) tidak di-expose ke publik
- Nginx meneruskan
X-Forwarded-*headers dengan benar - SSL aktif, redirect HTTP→HTTPS otomatis
-
certbot renew --dry-runberhasil (auto-renewal terjamin) - Deploy script pakai
pm2 reload, bukanrestart, untuk zero-downtime
Langkah selanjutnya:
- Otomatiskan deployment — jalankan
deploy.shotomatis lewat GitHub Actions setiap push kemain(artikel cicd-github-actions) - Monitoring — tahu kapan aplikasi down atau lambat sebelum user yang mengabari kamu duluan (artikel monitoring-observability)
Kalau kamu memilih jalur Docker sebagai gantinya, artikel #05 (Docker untuk Backend Developer) membahas pattern deployment yang setara dengan container.