Lewati ke konten utama
Semua artikel
MenengahProduction & Ops

Deploy Backend ke VPS: Ubuntu + Nginx + SSL

Setup VPS dari nol, Nginx sebagai reverse proxy, SSL dengan Let's Encrypt, PM2, dan automated deployment.

13 menit baca

"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

  1. Setup VPS dari Nol
  2. Install Node.js dan Tooling Dasar
  3. Setup PostgreSQL di VPS
  4. Clone dan Konfigurasi Aplikasi
  5. PM2: Process Manager Production
  6. Nginx sebagai Reverse Proxy
  7. SSL Gratis dengan Let's Encrypt
  8. 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 - deploy

SSH 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_config
PasswordAuthentication no
PermitRootLogin no
sudo systemctl restart ssh

Sebelum 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 status
Status: 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 fail2ban

fail2ban 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 -h

Ini penyebab paling umum dari npm install yang "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 --version

Git

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 Nginx

Bab 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.sql

Penting: 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=production

Environment 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_SECRET yang sama antara development dan production. Kalau .env development 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 pm2

Ecosystem 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-api

Cluster 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 boot

Log 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 true

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

DNS

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 kamu

Bab 7: SSL Gratis dengan Let's Encrypt

Install Certbot

sudo apt install -y certbot python3-certbot-nginx

Dapatkan Sertifikat

sudo certbot --nginx -d api.tokokamu.com

Certbot 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.timer

Kalau --dry-run gagal, 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.sh

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

2. 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 start

3. 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 manual

4. 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
  • fail2ban aktif untuk proteksi brute-force SSH
  • Aplikasi jalan sebagai user non-root
  • .env production punya JWT_SECRET sendiri, beda dari development
  • PM2 di cluster mode, pm2 startup + pm2 save sudah 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-run berhasil (auto-renewal terjamin)
  • Deploy script pakai pm2 reload, bukan restart, untuk zero-downtime

Langkah selanjutnya:

  1. Otomatiskan deployment — jalankan deploy.sh otomatis lewat GitHub Actions setiap push ke main (artikel cicd-github-actions)
  2. 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.

Lanjutkan ke

CI/CD untuk Backend Developer: GitHub ActionsSegera
Monitoring & Observability: Logs, Metrics, TracesSegera