Aplikasi Express berjalan sempurna di laptop, dan sekarang harus hidup di internet. Panduan yang beredar biasanya berhenti di pm2 start app.js, lalu meninggalkan tiga hal yang justru menyebabkan masalah minggu depan: aplikasi tidak hidup lagi setelah server reboot, port 3000 terbuka ke publik, dan sertifikat TLS yang tidak pernah diuji pembaruannya.
Panduan ini mengikuti urutan yang benar-benar dipakai di produksi untuk satu VPS 1–2 vCPU: kunci akses dulu, jalankan aplikasi, letakkan Nginx di depan, baru TLS.
Membuat user deploy non-root dan mengunci akses SSH key-only
Hal pertama setelah VPS menyala, sebelum apa pun yang lain. Server baru dengan SSH password terbuka akan menerima ribuan percobaan login dalam 24 jam pertama; ini bukan perkiraan, itu yang akan kamu lihat di log.
# Sebagai root, dari sesi SSH pertama.
adduser deploy
usermod -aG sudo deploy
# Salin kunci publik dari laptopmu — JANGAN tutup sesi root sebelum ini diuji.
mkdir -p /home/deploy/.ssh
cat >> /home/deploy/.ssh/authorized_keys # tempel isi ~/.ssh/id_ed25519.pub, Ctrl-D
chown -R deploy:deploy /home/deploy/.ssh
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
Dari terminal kedua di laptop, buktikan dulu ssh deploy@ip-server berhasil. Urutan ini penting: mematikan login password sementara kunci belum bekerja adalah cara paling umum mengunci diri sendiri di luar server sendiri.
Setelah terbukti bisa masuk:
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo systemctl reload ssh
# Firewall: hanya SSH, HTTP, HTTPS. Port 3000 tidak pernah dibuka.
sudo ufw allow OpenSSH
sudo ufw allow 80,443/tcp
sudo ufw --force enable
sudo ufw status
Tambahkan fail2ban sekalian — satu perintah, dan log SSH-mu langsung jauh lebih bersih:
sudo apt install -y fail2ban && sudo systemctl enable --now fail2ban
Instal Node LTS via nvm dan pin versi di .nvmrc
Jangan pakai apt install nodejs. Versi di repositori Ubuntu biasanya tertinggal beberapa rilis mayor, dan menaikkan versinya nanti akan bentrok dengan paket sistem.
# Sebagai user deploy, bukan root.
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*
node -v && npm -v
Simpan versinya di repo supaya laptop dan server tidak pernah berbeda:
node -v > .nvmrc # menghasilkan misalnya "v22.11.0"
Lalu ambil kodenya dan siapkan lingkungannya:
cd ~ && git clone git@github.com:akun/proyek.git app && cd app
nvm use # membaca .nvmrc
npm ci --omit=dev # ci, bukan install: menghormati package-lock
cp .env.example .env && nano .env
chmod 600 .env # berisi kredensial database; jangan world-readable
npm ci alih-alih npm install adalah detail yang layak dijadikan kebiasaan: ia menginstal tepat versi yang ada di package-lock.json, sehingga server tidak mendapat versi minor yang berbeda dari yang kamu uji.
Kalau aplikasimu memakai MySQL di server yang sama, buat pengguna dengan hak terbatas — bukan root:
CREATE DATABASE db_app CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'app'@'localhost' IDENTIFIED BY 'kata-sandi-panjang-acak';
GRANT SELECT, INSERT, UPDATE, DELETE ON db_app.* TO 'app'@'localhost';
FLUSH PRIVILEGES;
Perhatikan bahwa DROP dan ALTER tidak diberikan. Kalau kamu punya runner migrasi, jalankan dengan pengguna terpisah yang punya hak DDL, dan hanya saat migrasi.
Menjalankan app dengan pm2 start dan pm2 startup systemd
pm2 menjaga aplikasi tetap hidup setelah crash. Yang sering terlewat adalah membuatnya hidup setelah server reboot.
npm install -g pm2
Definisikan prosesnya di berkas, bukan lewat perintah panjang yang tidak ada di repo:
// ecosystem.config.js
module.exports = {
apps: [{
name: 'web',
script: './bin/www',
instances: 1, // naikkan hanya setelah terbukti perlu
env: { NODE_ENV: 'production', PORT: 3000 },
max_memory_restart: '400M', // jaring untuk kebocoran memori
min_uptime: '30s',
max_restarts: 10, // cegah loop restart tak berujung
error_file: '~/.pm2/logs/web-error.log',
}],
};
pm2 start ecosystem.config.js
pm2 save # simpan daftar proses saat ini
pm2 startup # cetak satu perintah sudo — jalankan itu
# Contoh keluarannya:
# sudo env PATH=$PATH:/home/deploy/.nvm/versions/node/v22.11.0/bin pm2 startup systemd -u deploy --hp /home/deploy
pm2 save dan pm2 startup keduanya diperlukan: yang pertama menyimpan daftar proses, yang kedua membuat unit systemd yang memulihkannya saat boot. Melewatkan salah satu berarti situsmu mati setelah reboot pertama, biasanya reboot yang dilakukan penyedia VPS tanpa pemberitahuan.
Ujilah, jangan berasumsi:
sudo reboot
# tunggu 30 detik, masuk kembali
pm2 list # harus menampilkan 'web' online
curl -I http://127.0.0.1:3000
Pasang juga rotasi log sejak awal. Log pm2 yang tidak dirotasi adalah penyebab umum "disk penuh" pada VPS 25 GB:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 10M
pm2 set pm2-logrotate:retain 14
Nginx reverse proxy ke port 3000 plus header proxy yang benar
Node bisa melayani port 80 langsung, tapi jangan. Nginx memberi TLS, kompresi, pelayanan berkas statis, dan kemampuan mengganti aplikasi tanpa menyentuh port publik.
sudo apt install -y nginx
sudo nano /etc/nginx/sites-available/app
server {
listen 80;
server_name situsmu.com www.situsmu.com;
# Header ini yang membuat req.ip, req.protocol, dan rate limiting bekerja benar.
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-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
# Server-Sent Events / streaming tidak akan jalan tanpa mematikan buffering.
location /stream {
proxy_pass http://127.0.0.1:3000;
proxy_buffering off;
proxy_cache off;
}
client_max_body_size 12M; # sesuaikan dengan batas unggahan aplikasimu
gzip on;
gzip_types text/css application/javascript image/svg+xml application/json;
}
sudo ln -s /etc/nginx/sites-available/app /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
Di sisi Express, beri tahu bahwa ia di belakang satu proxy — tanpa ini, req.ip selalu 127.0.0.1 dan setiap rate limit per IP akan memblokir semua pengunjung bersamaan:
app.set('trust proxy', 'loopback');
Dua kesalahan yang sering terjadi di langkah ini: client_max_body_size default Nginx hanya 1 MB, jadi unggahan gambar gagal dengan 413 yang tidak muncul di log aplikasi; dan lupa menghapus sites-enabled/default, sehingga Nginx menyajikan halaman sambutannya untuk domain yang belum terkonfigurasi.
TLS otomatis dengan certbot dan uji auto-renew
Sertifikat gratis, prosesnya dua menit, dan yang perlu diperhatikan hanya pembaruannya.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d situsmu.com -d www.situsmu.com --redirect --agree-tos -m admin@situsmu.com
Certbot menyunting blok Nginx-mu, menambahkan listen 443 ssl, dan membuat pengalihan dari HTTP. Setelah itu, uji pembaruannya — ini langkah yang hampir selalu dilewati dan baru terasa 90 hari kemudian saat sertifikat kedaluwarsa di hari Sabtu:
sudo certbot renew --dry-run
systemctl list-timers | grep certbot # timer systemd harus aktif
Setelah TLS hidup, barulah pasang HSTS di aplikasi (Strict-Transport-Security). Memasangnya sebelum HTTPS berfungsi di semua subdomain akan membuat browser menolak versi HTTP situsmu tanpa ada versi HTTPS yang bekerja — dan header itu di-cache browser selama berbulan-bulan.
Verifikasi akhir, empat perintah:
curl -I https://situsmu.com # 200, dan header keamananmu
curl -I http://situsmu.com # 301 ke https
sudo ufw status # 3000 TIDAK ada di daftar
curl -I http://ip-server:3000 # harus gagal / timeout dari luar
Perintah terakhir yang paling sering memberi kejutan: aplikasi yang mengikat 0.0.0.0 tetap bisa diakses langsung lewat IP dan port kalau firewall belum menyala, melewati Nginx, TLS, dan semua header keamananmu.
Langkah berikutnya
Setelah live, tiga hal yang layak dikerjakan di minggu pertama: backup database otomatis (mysqldump harian ke luar VPS — VPS yang hilang tanpa backup adalah cerita yang selalu berulang), monitoring uptime eksternal yang memberi tahu sebelum klien yang memberi tahu, dan skrip deploy sederhana supaya pembaruan tidak berupa perintah yang diingat setengah-setengah:
#!/usr/bin/env bash
# deploy.sh — dijalankan sebagai user deploy
set -euo pipefail
cd ~/app
git pull --ff-only
npm ci --omit=dev
npm run build # kalau aset dibangun di server
npm run db:migrate
pm2 reload web # reload, bukan restart: nol downtime untuk satu instance pun
pm2 save
set -euo pipefail di baris kedua adalah satu-satunya alasan skrip ini bisa dipercaya: tanpa itu, git pull yang gagal akan diikuti pm2 reload yang dengan riang menjalankan kode lama dan melaporkan sukses.




