Thiết Lập VPS Thực Sự Hoạt Động: Node.js, PM2, Nginx, và Deploy Không Downtime
Chính xác thiết lập deploy VPS tôi dùng trong production — hardening Ubuntu, PM2 cluster mode, Nginx reverse proxy, SSL, và script deploy chưa từng làm tôi thất vọng. Không lý thuyết, chỉ những gì hoạt động.
Blog này chạy trên VPS $10/tháng. Không phải Vercel, không phải AWS, không phải cluster Kubernetes được quản lý bởi team sáu người. Một máy Ubuntu đơn lẻ với Nginx, PM2, và script bash deploy trong dưới 30 giây.
Tôi đã thử những con đường khác. Tôi đã dùng Vercel (tuyệt vời cho đến khi bạn cần cron job, WebSocket persistent, hoặc đơn giản là quyền kiểm soát). Tôi đã dùng AWS (tuyệt vời nếu bạn thích dành nửa ngày trong IAM policy). Tôi luôn quay lại VPS.
Nhưng đây là vấn đề: mọi tutorial "deploy lên VPS" trên internet dừng ở happy path. Họ chỉ bạn cách cài Node.js và chạy node server.js rồi gọi đó là production. Rồi server bạn bị brute-force SSH, process chết lúc 3 giờ sáng vì không ai thiết lập process manager, và chứng chỉ SSL hết hạn ba tháng trước.
Đây là hướng dẫn tôi ước mình đã có. Mọi thứ ở đây đã được thử nghiệm thực chiến — chính xác thiết lập này đang phục vụ trang bạn đang đọc ngay bây giờ.
Bắt Đầu Với Bảo Mật, Không Phải Code#
Trước khi nghĩ đến Node.js, hãy khóa chặt máy chủ. Các instance VPS mới là mục tiêu. Bot tự động bắt đầu tấn công port SSH trong vài phút sau khi khởi tạo.
Tạo User Không Phải Root#
adduser deploy
usermod -aG sudo deployThiết Lập Xác Thực SSH Key#
Trên máy local:
ssh-copy-id deploy@your-server-ipSau đó tắt hoàn toàn xác thực bằng mật khẩu:
sudo nano /etc/ssh/sshd_configPasswordAuthentication no
PermitRootLogin nosudo systemctl restart sshdNếu bạn bỏ qua bước này, bạn sẽ thấy hàng nghìn lần đăng nhập thất bại trong auth log chỉ trong vài ngày. Đó không phải hoang tưởng — đó là thứ Ba bình thường trên internet công cộng.
Tường Lửa Với UFW#
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow OpenSSH
sudo ufw allow 'Nginx Full'
sudo ufw enableThế thôi. Bốn rule. Chỉ SSH và lưu lượng web được phép đi qua.
Fail2Ban#
sudo apt install fail2ban -y
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.localSửa /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 fail2banBa lần thử SSH thất bại là bạn bị ban một giờ. Tôi đã thấy Fail2Ban chặn hàng trăm IP trong một ngày. Nó hoạt động.
Cập Nhật Bảo Mật Tự Động#
sudo apt install unattended-upgrades -y
sudo dpkg-reconfigure -plow unattended-upgradesServer của bạn giờ sẽ tự động cài đặt bản vá bảo mật. Bớt một thứ phải nhớ.
Node.js: Dùng NVM, Không Phải apt#
Tôi thấy điều này trong mọi tutorial: sudo apt install nodejs. Đừng làm vậy.
Repo package của Ubuntu ship phiên bản Node.js cổ xưa. Ngay cả PPA NodeSource cũng tụt lại. Và khi bạn cần chuyển đổi giữa Node 20 và Node 22 cho các dự án khác nhau, bạn bị kẹt.
NVM giải quyết điều này:
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.bashrc
nvm install --lts
nvm alias default lts/*Giờ xác minh:
node -v # v22.x.x hoặc bất kỳ LTS hiện tại nào
npm -vMẹo không rõ ràng: khi bạn cài package global với NVM (như PM2), chúng gắn với phiên bản Node đó. Nếu bạn chuyển phiên bản bằng nvm use, global của bạn biến mất. Đặt default và giữ nguyên trên server:
nvm alias default 22Điều này đã cắn tôi đúng một lần. Một lần là đủ.
PM2: Process Manager Xứng Đáng Với Công Sức#
PM2 là sự khác biệt giữa "đã deploy" và "sẵn sàng cho production." Nó xử lý quản lý process, clustering, xoay log, tự khởi động lại khi crash, và script khởi động. Miễn phí.
Cài Đặt và Thiết Lập#
npm install -g pm2File Config Ecosystem#
Đừng khởi động app bằng flag CLI. Dùng file ecosystem.config.js. Nó được version-control, có thể tái tạo, và tự giải thích.
// 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,
},
// Tắt graceful
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,
// Tự khởi động lại khi lỗi
autorestart: true,
max_restarts: 10,
min_uptime: "10s",
// Không watch trong production
watch: false,
},
],
};Hãy để tôi giải thích những lựa chọn quan trọng:
instances: 2 thay vì "max": Trên VPS nhỏ với 1-2 core, "max" nghe thông minh nhưng nó sẽ spawn process tranh giành tài nguyên trong quá trình build. Hai instance cho bạn reload không downtime trong khi vẫn còn dư tài nguyên. Trên máy 4+ core, chắc chắn dùng "max".
exec_mode: "cluster": Đây là thứ cho phép reload không downtime. Không có cluster mode, pm2 reload chỉ là restart bóng bẩy. Với cluster mode, PM2 restart từng instance một — app của bạn không bao giờ hoàn toàn offline.
max_memory_restart: "500M": App Next.js bị memory leak? PM2 sẽ restart nó trước khi nó OOM-kill server. Điều này đã cứu tôi khỏi cảnh báo lúc 2 giờ sáng nhiều hơn một lần.
kill_timeout: 5000: Cho app 5 giây để hoàn thành request đang xử lý trước khi PM2 force-kill. Mặc định (1600ms) quá mạnh tay cho app có kết nối database.
watch: false: Tôi đã thấy người ta để watch: true trong production. PM2 lúc đó restart app mỗi khi file log thay đổi. App rơi vào vòng lặp restart. Đừng.
Script Khởi Động#
Làm PM2 sống sót qua reboot:
pm2 startup systemd
# Sao chép và chạy lệnh nó xuất ra
pm2 saveĐiều này tạo service systemd. Sau khi server reboot, app tự động quay lại. Hãy test — reboot server và xác minh. Đừng giả định.
Xoay Log#
Log sẽ ăn hết ổ đĩa. Cài module xoay:
pm2 install pm2-logrotate
pm2 set pm2-logrotate:max_size 50M
pm2 set pm2-logrotate:retain 7
pm2 set pm2-logrotate:compress trueTối đa 50MB mỗi file, giữ 7 file đã xoay, nén file cũ. Không có cài đặt này, tôi đã thấy /var/log lấp đầy ổ đĩa 25GB trong ba tuần trên app có lưu lượng vừa phải.
Nginx: Reverse Proxy Làm Được Nhiều Hơn Bạn Nghĩ#
"Sao không expose Node.js trực tiếp trên port 80?"
Vì Nginx xử lý những thứ mà Node.js không nên tốn chu kỳ CPU: SSL termination, phục vụ file tĩnh, nén gzip, buffering request, giới hạn kết nối, và xử lý graceful các client chậm. Nó viết bằng C và được xây dựng chuyên biệt cho việc này.
Cài Đặt#
sudo apt install nginx -yCấu Hình#
# /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;
# Chuyển hướng tất cả HTTP sang HTTPS
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name akousa.net www.akousa.net;
# SSL (quản lý bởi Certbot — các dòng này được thêm tự động)
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 bảo mật
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;
# Nén 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;
# Cài đặt 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;
# Hỗ trợ WebSocket (nếu cần)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Timeout — rộng rãi nhưng không vô hạn
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering — để Nginx xử lý client chậm
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 4 32k;
proxy_busy_buffers_size 64k;
}
# Asset tĩnh Next.js — để Nginx phục vụ trực tiếp
location /_next/static/ {
alias /var/www/akousa.net/.next/static/;
expires 365d;
access_log off;
add_header Cache-Control "public, immutable";
}
# File tĩnh public
location /static/ {
alias /var/www/akousa.net/public/static/;
expires 30d;
access_log off;
}
# Chặn truy cập file ẩn
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}Kích hoạt:
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 nginxLuôn chạy nginx -t trước khi reload. Tôi đã từng push config lỗi và sập site vì bỏ qua kiểm tra cú pháp. Năm ký tự nginx -t đã có thể tiết kiệm ba mươi phút debug hoảng loạn.
Những điều hầu hết tutorial bỏ sót trong config này:
Block upstream với keepalive 64: Nginx tái sử dụng kết nối đến backend Node.js thay vì mở kết nối TCP mới cho mỗi request. Điều này quan trọng khi tải cao.
proxy_buffering on: Nginx đọc toàn bộ response từ Node.js vào memory, rồi gửi cho client theo tốc độ client có thể xử lý. Không có điều này, client chậm trên mạng 3G giữ worker Node.js của bạn.
Phục vụ _next/static/ trực tiếp: Đây là asset đã hash, bất biến. Hãy để Nginx phục vụ chúng từ disk với header cache 365 ngày. Process Node.js không nên tốn thời gian cho việc này.
SSL Trong Năm Phút#
Let's Encrypt đã giải quyết SSL. Nếu bạn vẫn trả tiền cho chứng chỉ năm 2026, hãy dừng lại.
sudo apt install certbot python3-certbot-nginx -y
sudo certbot --nginx -d akousa.net -d www.akousa.netCertbot sẽ hỏi email, chấp nhận ToS, và tự động sửa config Nginx để bao gồm các directive SSL. Thế thôi.
Xác Minh Gia Hạn Tự Động#
Certbot cài timer systemd kiểm tra hai lần mỗi ngày và gia hạn chứng chỉ trong vòng 30 ngày trước khi hết hạn:
sudo systemctl list-timers | grep certbotTest gia hạn hoạt động:
sudo certbot renew --dry-runNếu dry run pass, bạn sẽ không bao giờ phải nghĩ về SSL nữa. Nếu thất bại, thường là do port 80 bị chặn (kiểm tra rule UFW) hoặc Nginx không chạy.
Một điều đã bắt tôi: nếu bạn thiết lập Nginx trước khi chạy Certbot, đảm bảo server block đang lắng nghe port 80 không có redirect HTTPS trước. Certbot cần truy cập port 80 cho HTTP-01 challenge. Sau khi Certbot chạy thành công, rồi thêm redirect.
Script Deploy#
Đây là script chạy mỗi khi tôi push lên production. Không nền tảng CI/CD, không GitHub Actions. Chỉ SSH và bash.
#!/bin/bash
# deploy.sh — deploy gần như không 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 "=== Bắt đầu deploy ==="
cd "$APP_DIR"
# Pull code mới nhất
log "Đang pull thay đổi mới nhất..."
git pull origin main 2>&1 | tee -a "$LOG_FILE"
# Cài dependency
log "Đang cài dependency..."
npm install --legacy-peer-deps 2>&1 | tee -a "$LOG_FILE"
# Build
log "Đang build ứng dụng..."
rm -rf .next
npm run build 2>&1 | tee -a "$LOG_FILE"
if [ $? -ne 0 ]; then
log "LỖI: Build thất bại. Hủy deploy."
exit 1
fi
# Reload PM2 (không downtime trong cluster mode)
log "Đang reload PM2..."
pm2 reload "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
pm2 save 2>&1 | tee -a "$LOG_FILE"
# Health check với retry
log "Đang chạy 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 passed (HTTP $HTTP_CODE)"
log "=== Deploy hoàn tất thành công ==="
exit 0
fi
log "Health check lần thử $i/$MAX_RETRIES (HTTP $HTTP_CODE). Thử lại sau ${RETRY_INTERVAL}s..."
sleep $RETRY_INTERVAL
done
log "LỖI: Health check thất bại sau $MAX_RETRIES lần thử"
log "Đang rollback về trạng thái PM2 trước đó..."
pm2 restart "$APP_NAME" 2>&1 | tee -a "$LOG_FILE"
exit 1Cấp quyền thực thi:
chmod +x deploy.shDeploy từ máy local:
ssh root@your-server-ip "bash /var/www/akousa.net/deploy.sh"Các quyết định quan trọng trong script này:
set -euo pipefail: Script thoát ngay lập tức khi có bất kỳ lỗi nào. Không có điều này, npm install thất bại sẽ âm thầm tiếp tục vào bước build, và bạn nhận được lỗi khó hiểu tốn 20 phút debug.
rm -rf .next trước khi build: Next.js có build cache đôi khi tạo output cũ. Tôi đã bị dính một lần — trang hiển thị nội dung cũ dù source code đã cập nhật. Xóa thư mục build thêm khoảng 15 giây nhưng đảm bảo output mới.
pm2 reload thay vì pm2 restart: Đây là phần không downtime. Trong cluster mode, reload thực hiện rolling restart — nó khởi động instance mới với code cập nhật, đợi chúng sẵn sàng, rồi graceful shutdown instance cũ. Không lúc nào zero instance đang chạy.
Health check với retry: Next.js mất vài giây để warm up sau restart. Script đợi tối đa 30 giây (10 lần thử x 3 giây), kiểm tra app phản hồi HTTP 200. Nếu không, có gì đó sai và bạn cần biết ngay — không phải phát hiện từ người dùng.
Rollback khi thất bại: Nếu health check thất bại sau tất cả các lần thử, script restart PM2 (tải trạng thái đã lưu cuối cùng). Không phải rollback hoàn hảo, nhưng tốt hơn để server trong trạng thái hỏng.
Khi Mọi Thứ Hỏng Lúc 2 Giờ Sáng#
Đây là những gì tôi thực sự đã debug trên chính xác thiết lập này:
"Site bị sập"#
Lệnh đầu tiên cần chạy:
pm2 status
pm2 logs akousa --lines 50
sudo systemctl status nginx
sudo tail -50 /var/log/nginx/error.logChín trong mười lần, pm2 logs cho bạn biết ngay điều gì xảy ra. Thiếu biến môi trường, kết nối database thất bại, hoặc unhandled promise rejection.
"Bộ nhớ cứ tăng"#
pm2 monitĐiều này cho bạn dashboard live về CPU và memory mỗi process. Nếu memory tăng đều mà không ổn định, bạn có leak. Cài đặt max_memory_restart trong config ecosystem là lưới an toàn — PM2 sẽ restart process trước khi nó hạ gục server.
Để điều tra sâu hơn:
pm2 describe akousaĐiều này hiển thị uptime, số lần restart, và snapshot memory. Nếu bạn thấy 47 lần restart trong 24 giờ qua, đó là gợi ý.
"Chứng chỉ SSL hết hạn"#
sudo certbot certificatesLiệt kê tất cả chứng chỉ với ngày hết hạn. Nếu gia hạn tự động thất bại:
sudo certbot renew --force-renewal
sudo systemctl reload nginx"Ổ đĩa đầy"#
df -h
du -sh /var/log/*
pm2 flushpm2 flush xóa tất cả file log PM2 ngay lập tức. Nếu bạn không thiết lập xoay log (tôi đã nói rồi), đây là lúc bạn cảm nhận hậu quả.
Lệnh Tôi Chạy Mỗi Sáng#
ssh deploy@akousa.net "pm2 status && df -h / && uptime"Ba thứ trong một dòng: process có đang chạy không, ổ đĩa ổn không, server có quá tải không. Mất hai giây. Phát hiện vấn đề trước người dùng.
Những Điều Hầu Hết Hướng Dẫn Không Nói#
Bước build là điểm yếu lớn nhất. Trên VPS 1GB RAM, npm run build cho app Next.js có thể ngốn 800MB+ memory. Nếu PM2 đang chạy app hai instance trong quá trình build, bạn sẽ OOM. Giải pháp: dùng swap file (ít nhất 2GB), hoặc dừng app trong quá trình build và chấp nhận vài giây downtime. Tôi dùng 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 trong lệnh install là dấu hiệu có vấn đề, không phải giải pháp. Tôi dùng nó vì một số package trong cây dependency chưa cập nhật phạm vi peer dependency. Vài tháng một lần tôi thử bỏ nó. Một ngày nào đó sẽ được. Còn bây giờ, tôi ship.
Test script deploy từ đầu. Clone repo trên server mới và chạy mọi bước thủ công. Số lượng vấn đề "trên máy tôi chạy được" ẩn trong script deploy thật đáng xấu hổ. Tôi tìm ra ba vấn đề khi làm điều này — thiếu package global, sai quyền file, và đường dẫn chỉ tồn tại vì thiết lập thủ công trước đó.
Đặt IP server trong SSH config. Đừng gõ địa chỉ IP nữa:
# ~/.ssh/config
Host akousa
HostName 69.62.66.94
User deploy
IdentityFile ~/.ssh/id_ed25519Giờ ssh akousa là tất cả bạn cần. Những thứ nhỏ tích lũy dần.
Checklist Đầy Đủ#
Trước khi gọi là xong:
- User không phải root với quyền sudo
- Chỉ xác thực SSH key, tắt xác thực mật khẩu
- UFW bật với chỉ các port cần thiết mở
- Fail2Ban bảo vệ SSH
- Cập nhật bảo mật tự động đã bật
- Node.js cài qua NVM
- PM2 chạy app trong cluster mode
- Script khởi động PM2 đã cấu hình (sống sót qua reboot)
- Xoay log PM2 đã cài
- Nginx reverse proxy với header đúng
- SSL qua Let's Encrypt với gia hạn tự động
- Script deploy với health check
- Swap file đã cấu hình (dư tài nguyên cho build)
- Đã test: reboot server và xác minh mọi thứ quay lại
Mục cuối cùng là mục người ta hay bỏ qua. Đừng là người đó. Reboot server, đợi 60 giây, và kiểm tra app có live không. Nếu không, script khởi động bị cấu hình sai và bạn sẽ phát hiện vào lúc tệ nhất có thể.
Đây Có Phải "Enterprise-Grade" Không?#
Không. Và đó chính là điểm mấu chốt.
Thiết lập này phục vụ blog này một cách đáng tin cậy với dưới $10/tháng. Deploy trong 30 giây với một lệnh duy nhất. Tôi hiểu mọi thành phần. Khi có gì hỏng, tôi biết chính xác phải tìm ở đâu.
Tôi có thể dùng Docker không? Chắc chắn. Tôi có thể dùng Kubernetes không? Về kỹ thuật thì được. Tôi có thể thiết lập pipeline CI/CD đầy đủ với staging environment và canary deployment không? Hoàn toàn.
Nhưng tôi đã học được rằng hạ tầng tốt nhất là hạ tầng bạn thực sự hiểu, có thể debug lúc 2 giờ sáng, và không tốn nhiều hơn số tiền dự án kiếm được. Cho trang cá nhân, SaaS MVP, hay startup nhỏ — đây chính là thiết lập đó.
Ship trước. Scale khi cần. Và luôn luôn, luôn luôn, test script deploy trên server mới.