Developer Guide
Hướng Dẫn Triển Khai Server Với PM2 (ecosystem.config.cjs)
Tài liệu chuẩn hóa quy trình deploy ứng dụng OpenStory lên máy chủ bằng PM2
Hướng Dẫn Triển Khai OpenStory Lên Server Với PM2
Tài liệu hướng dẫn chi tiết quy trình chuẩn bị môi trường, biên dịch mã nguồn và vận hành hệ thống OpenStory (gồm worker chính và sidecar video export) trên máy chủ sử dụng PM2 thông qua file cấu hình ecosystem.config.cjs.
1. Yêu Cầu Môi Trường (Prerequisites)
Trước khi tiến hành, đảm bảo máy chủ đã cài đặt các công cụ sau:
| Công cụ | Phiên bản yêu cầu | Lệnh kiểm tra |
|---|---|---|
| Node.js | >= 24.x < 25.x | node -v |
| Bun | >= 1.3.9 < 2.x | bun -v |
| PM2 | Mới nhất | pm2 -v |
| Git | Mới nhất | git --version |
Cài đặt nhanh PM2 nếu máy chủ chưa có:
npm install -g pm2
# hoặc
bun add -g pm22. Quy Trình Triển Khai Từng Bước (Step-by-Step)
Bước 1: Tải mã nguồn về máy chủ
cd /path/to/apps
git clone <repository_url> openstory
cd openstoryBước 2: Thiết lập file biến môi trường (.env / .env.local)
Tạo file .env hoặc .env.local tại thư mục gốc của dự án:
cp .env.example .env.localĐiền đầy đủ các biến môi trường quan trọng:
# URL & Port chạy ứng dụng
VITE_APP_URL=http://localhost:3033
BETTER_AUTH_URL=http://localhost:3033
# (Khi chạy production công khai, đổi thành: https://studio.noto.vn hoặc https://openstory.seateklab.vn)
# Khóa bảo mật xác thực (tạo ngẫu nhiên bằng `openssl rand -hex 32`)
BETTER_AUTH_SECRET=your_32_byte_secret_here
API_KEY_ENCRYPTION_KEY=your_32_byte_key_here
# Dịch vụ Video Export Sidecar
VIDEO_EXPORT_DEV_URL=http://localhost:8088
# API Keys AI Providers (Kie AI, Fal, Ollama...)
KIE_API_KEY=ca97914ffaf13cc4669178b70fa4cdad
# FAL_KEY=...
# OPENROUTER_KEY=...
# Cấu hình SSO Seatek (nếu có)
VITE_APP_CODE=openstory
NEXT_PUBLIC_APP_CODE=openstory
VITE_AUTH_APP=https://auth.seateklab.vn
NEXT_PUBLIC_AUTH_APP=https://auth.seateklab.vn
VITE_PUBLIC_API_URL=https://api.ai.seateklab.vn/v1
NEXT_PUBLIC_PUBLIC_API_URL=https://api.ai.seateklab.vn/v1Bước 3: Cài đặt thư viện dependencies
Cài đặt thư viện cho dự án chính (Root):
bun installCài đặt thư viện cho container Video Export:
cd containers/video-export bun install cd ../..
[!IMPORTANT] Phân quyền thư mục trên máy chủ Linux: Nếu gặp lỗi
EACCES: Permission denied: could not create the "node_modules" directory, đó là do thư mục dự án thuộc quyền sở hữu của user khác (roothoặc user khác tạo ra).Hãy cấp lại quyền sở hữu cho user hiện tại:
sudo chown -R $USER:$USER /path/to/openstorySau đó chạy lại
bun installmà không dùngsudo. (Lưu ý: Không chạysudo bun installvìsudokhông nhận PATH của Bun tại~/.bun/bindẫn đến lỗisudo: bun: command not found, đồng thời tạo file dính quyền root gây lỗi vận hành).
Bước 4: Khởi tạo & Migrate Database D1 Local
Chạy các lệnh khởi tạo schema và seed dữ liệu ban đầu cho database local:
# Đảm bảo các secret tối thiểu đã có
bun scripts/ensure-env.ts
# Áp dụng migrations vào SQLite D1 local
bun run db:migrate:local
# Nạp dữ liệu mẫu ban đầu (templates, styles, talent sheets...)
bun run db:seed:localBước 5: Biên dịch mã nguồn (Build)
Vì dự án chạy TanStack Start, Wrangler cần bản bundle hoàn chỉnh từ Vite để resolve toàn bộ các virtual module:
Build ứng dụng chính (OpenStory):
bun run buildKết quả build sẽ được lưu tại
dist/server/vàdist/client/.Build service Video Export:
bun run build:bunny # tương đương: cd containers/video-export && bun run build && cd ../..File thực thi sẽ sinh ra tại
containers/video-export/dist/server.js.
Bước 6: Tạo thư mục chứa Logs
mkdir -p logsBước 7: Kiểm tra cấu hình ecosystem.config.cjs
File ecosystem.config.cjs chịu trách nhiệm quản lý 2 tiến trình:
openstory: Chạy Wrangler dev trỏ vào bản builddist/server/wrangler.jsontrên cổng3033(mặc định, hoặc cấu hình quaAPP_PORT).openstory-export: Chạy Node.js sidecarcontainers/video-export/dist/server.jstrên cổng8088.
Nội dung cấu hình chuẩn:
const fs = require('fs');
const path = require('path');
function parseEnv(filePath) {
if (!fs.existsSync(filePath)) return {};
try {
const content = fs.readFileSync(filePath, 'utf-8');
const env = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIdx = trimmed.indexOf('=');
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
let val = trimmed.slice(eqIdx + 1).trim();
if (
val.length >= 2 &&
((val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'")))
) {
val = val.slice(1, -1);
}
env[key] = val;
}
return env;
} catch {
return {};
}
}
const envMain = parseEnv(path.join(__dirname, '.env'));
const envLocal = parseEnv(path.join(__dirname, '.env.local'));
const fileEnv = {
...envMain,
...envLocal,
};
// Nếu .env cấu hình domain thật, không để localhost trong .env.local ghi đè
if (
envMain.VITE_APP_URL &&
!envMain.VITE_APP_URL.includes('localhost') &&
(!envLocal.VITE_APP_URL || envLocal.VITE_APP_URL.includes('localhost'))
) {
fileEnv.VITE_APP_URL = envMain.VITE_APP_URL;
if (envMain.BETTER_AUTH_URL) {
fileEnv.BETTER_AUTH_URL = envMain.BETTER_AUTH_URL;
}
}
// Biến môi trường hệ thống (process.env) luôn có độ ưu tiên cao nhất
if (process.env.VITE_APP_URL) {
fileEnv.VITE_APP_URL = process.env.VITE_APP_URL;
}
if (process.env.BETTER_AUTH_URL) {
fileEnv.BETTER_AUTH_URL = process.env.BETTER_AUTH_URL;
}
if (fileEnv.KIE_KEY && !fileEnv.KIE_API_KEY) {
fileEnv.KIE_API_KEY = fileEnv.KIE_KEY;
} else if (fileEnv.KIE_API_KEY && !fileEnv.KIE_KEY) {
fileEnv.KIE_KEY = fileEnv.KIE_API_KEY;
}
// Đồng bộ biến môi trường vào .dev.vars cho Cloudflare Workers isolate
try {
const devVarsLines = Object.entries(fileEnv)
.filter(([k]) => !k.startsWith('npm_') && !k.startsWith('__'))
.map(([k, v]) => `${k}="${String(v).replace(/"/g, '\\"')}"`);
fs.writeFileSync(path.join(__dirname, '.dev.vars'), devVarsLines.join('\n'));
const distServer = path.join(__dirname, 'dist/server');
if (fs.existsSync(distServer)) {
fs.writeFileSync(
path.join(distServer, '.dev.vars'),
devVarsLines.join('\n')
);
}
} catch {
// Ignore filesystem write error
}
const appPort = Number(process.env.APP_PORT || fileEnv.APP_PORT || 3033);
module.exports = {
apps: [
{
name: 'openstory',
script: 'node_modules/wrangler/bin/wrangler.js',
args: `dev -c dist/server/wrangler.json --persist-to .wrangler/state --ip 0.0.0.0 --port ${appPort} --show-interactive-dev-session=false`,
cwd: __dirname,
interpreter: 'node',
autorestart: true,
watch: false,
max_memory_restart: '2500M',
kill_timeout: 5000,
treekill: true,
restart_delay: 3000,
env: {
CI: 'true',
NODE_ENV: 'production',
CLOUDFLARE_INCLUDE_PROCESS_ENV: 'true',
VIDEO_EXPORT_DEV_URL: 'http://localhost:8088',
...fileEnv,
},
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: path.join(__dirname, 'logs/openstory-error.log'),
out_file: path.join(__dirname, 'logs/openstory-out.log'),
merge_logs: true,
},
{
name: 'openstory-export',
script: 'dist/server.js',
cwd: path.join(__dirname, 'containers/video-export'),
interpreter: 'node',
autorestart: true,
watch: false,
max_memory_restart: '2500M',
env: {
NODE_ENV: 'production',
PORT: 8088,
LOCAL_APP_URL: `http://127.0.0.1:${appPort}`,
},
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
error_file: path.join(__dirname, 'logs/openstory-export-error.log'),
out_file: path.join(__dirname, 'logs/openstory-export-out.log'),
merge_logs: true,
},
],
};Bước 8: Khởi chạy với PM2 và lưu trạng thái
Khởi động ứng dụng:
pm2 start ecosystem.config.cjsKiểm tra trạng thái tiến trình:
pm2 statusCả 2 dịch vụ
openstoryvàopenstory-exportphải hiển thị trạng tháionline.Lưu danh sách tiến trình vào file dump của PM2: Lệnh này chụp lại toàn bộ danh sách các tiến trình đang chạy (
openstory,openstory-export) và lưu vào~/.pm2/dump.pm2:pm2 saveKích hoạt tự động khởi động cùng hệ điều hành (Auto-resurrect on Boot): Chạy lệnh để PM2 phát hiện hệ thống khởi động (systemd trên Linux, launchd trên macOS):
pm2 startupLệnh trên sẽ in ra một dòng lệnh có chứa
sudo. Bạn chỉ cần copy dòng lệnh đó và chạy lại trong terminal:- Ví dụ trên Linux (Ubuntu/Debian sử dụng systemd):
sudo env PATH=$PATH:/usr/bin /usr/lib/node_modules/pm2/bin/pm2 startup systemd -u <user> --hp /home/<user> - Ví dụ trên macOS (sử dụng launchd):
sudo env PATH=$PATH:/Users/seatek/.nvm/versions/node/v24.18.1/bin /Users/seatek/.bun/install/global/node_modules/pm2/bin/pm2 startup launchd -u seatek --hp /Users/seatek
- Ví dụ trên Linux (Ubuntu/Debian sử dụng systemd):
Kiểm tra cơ chế phục hồi: Sau khi hoàn tất, bất cứ khi nào máy chủ reboot:
- Dịch vụ hệ thống (
systemdhoặclaunchd) sẽ tự động kích hoạtpm2 resurrect. - PM2 sẽ đọc file
~/.pm2/dump.pm2và tự động khởi chạy lại cả 2 tiến trình trongecosystem.config.cjsmà không cần phải ssh vào bật lại thủ công.
- Dịch vụ hệ thống (
3. Cấu Hình Tự Động Xoay Vòng Logs (pm2-logrotate)
Nhằm tránh tình trạng log phình to gây tràn dung lượng ổ cứng:
# Cài đặt module logrotate cho PM2
pm2 install pm2-logrotate
# Giới hạn dung lượng mỗi file log tối đa 10MB
pm2 set pm2-logrotate:max_size 10M
# Tự động nén file log cũ bằng gzip (.gz)
pm2 set pm2-logrotate:compress true
# Giữ lại tối đa 14 file log gần nhất
pm2 set pm2-logrotate:retain 14
# Cắt file log mới vào 00:00 mỗi ngày
pm2 set pm2-logrotate:rotateInterval '0 0 * * *'4. Kiểm Tra & Xác Thực (Verification)
Sau khi start xong, kiểm tra các cổng dịch vụ trên máy chủ:
Kiểm tra OpenStory Web App (Cổng 3033):
curl -I http://127.0.0.1:3033/ # Kết quả mong đợi: HTTP/1.1 200 OKKiểm tra Video Export Service (Cổng 8088):
curl -I http://127.0.0.1:8088/ # Kết quả mong đợi: HTTP/1.1 404 Not Found (đã lắng nghe kết nối)Xem logs thời gian thực:
pm2 logs # hoặc xem trực tiếp từ file log: tail -f logs/openstory-out.log
5. Cấu Hình Nginx Reverse Proxy (Kiến Trúc Máy Chủ Riêng Biệt / Gateway)
Trong môi trường thực tế, dịch vụ Nginx (Gateway xử lý SSL/HTTPS) thường được đặt trên một máy chủ riêng biệt và chuyển tiếp traffic qua mạng nội bộ LAN tới máy chủ chạy ứng dụng OpenStory (ai-svr: IP 172.16.105.167, cổng 3033).
+-----------------------+ Mạng Nội Bộ (LAN) +-----------------------------+
| Máy Chủ Gateway Nginx | -----------------------------------> | Máy Chủ App ai-svr (PM2) |
| (openstory.seateklab) | http://172.16.105.167:3033 | (workerd / wrangler: 3033) |
| Port: 443 (SSL/HTTPS) | | (video-export: 8088) |
+-----------------------+ +-----------------------------+5.1. File Cấu Hình Nginx Đầy Đủ
Tại máy chủ Nginx (ví dụ: /etc/nginx/sites-available/openstory.seateklab.vn):
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name openstory.seateklab.vn;
# SSL Certificates
ssl_certificate /etc/letsencrypt/live/openstory.seateklab.vn/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/openstory.seateklab.vn/privkey.pem;
# Security & General
include nginxconfig/security.conf;
client_max_body_size 100m;
access_log /var/log/nginx/openstory.seateklab.vn.access.log;
error_log /var/log/nginx/openstory.seateklab.vn.error.log;
# 1. Cấu hình chuyên biệt cho Server-Sent Events (SSE) & Realtime Stream
location /api/realtime {
proxy_pass http://172.16.105.167:3033;
proxy_http_version 1.1;
proxy_set_header Connection "";
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;
# Tắt hoàn toàn buffering và cache cho stream
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
# Giữ kết nối stream dài hạn (24h) chống rớt kênh realtime
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# 2. Cấu hình Reverse Proxy chính tới máy chủ ai-svr
location / {
proxy_pass http://172.16.105.167:3033;
proxy_http_version 1.1;
# Proxy headers: Hỗ trợ WebSocket nếu có Upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $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_set_header X-Forwarded-Host $host;
# TĂNG TIMEOUT LÊN 300s: Chống Nginx tự ngắt kết nối khi AI đang xử lý (mặc định 60s)
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# Tối ưu buffer chống nghẽn socket TCP qua mạng LAN giữa 2 máy chủ
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
}
}5.2. Đảm bảo hỗ trợ Upgrade Map trên máy chủ Nginx
Đảm bảo trong file /etc/nginx/nginx.conf (hoặc include tương ứng) có block map này bên trong http { ... }:
http {
...
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
...
}5.3. Kiểm tra cú pháp và nạp lại Nginx
sudo nginx -t && sudo systemctl reload nginx6. Quy Trình Cập Nhật Ứng Dụng (Update / Deployment Flow)
Khi có code mới từ git repository, thực hiện theo thứ tự sau:
# 1. Kéo code mới về
git pull origin main
# 2. Cài đặt thêm dependencies (nếu có cập nhật package.json)
bun install
cd containers/video-export && bun install && cd ../..
# 3. Chạy migrations DB mới (nếu có)
bun run db:migrate:local
# 4. Biên dịch lại toàn bộ dự án
bun run build
bun run build:bunny
# 5. Khởi động lại dịch vụ với biến môi trường mới
pm2 restart ecosystem.config.cjs --update-env
# 6. Lưu lại trạng thái tiến trình
pm2 save7. Xử Lý Sự Cố Thường Gặp (Troubleshooting)
Sự cố 1: Lỗi EADDRINUSE hoặc treo kết nối cổng 3033
Nguyên nhân: Khi PM2 restart, tiến trình con
workerdcủa Wrangler có thể chưa kịp giải phóng socket TCP, gây kẹt cổng.Cách xử lý:
# 1. Dừng toàn bộ PM2 pm2 stop all # 2. Force kill tất cả tiến trình workerd còn sót lại pkill -9 -f workerd # 3. Kiểm tra cổng 3033 đã được giải phóng hoàn toàn chưa lsof -i :3033 # 4. Khởi động lại PM2 pm2 start ecosystem.config.cjs
Sự cố 2: Could not resolve "tanstack-start-manifest:v" trong log
- Nguyên nhân: Wrangler đang đọc cấu hình
wrangler.jsoncgốc thay vì bundle trongdist/server/wrangler.json. - Cách xử lý: Kiểm tra lại ecosystem.config.cjs đảm bảo flag
-c dist/server/wrangler.jsonvà--persist-to .wrangler/stateđã được thiết lập đúng, và nhớ chạybun run buildtrước khi start.
Sự cố 3: KIE_API_KEY is required for Kie AI image generation
- Nguyên nhân: Chưa khai báo
KIE_API_KEYtrong.env/.env.local. - Cách xử lý: Thêm
KIE_API_KEY=your_keyvào.env.local, sau đó chạypm2 restart ecosystem.config.cjs --update-env.
Sự cố 4: EACCES: Permission denied hoặc sudo: bun: command not found khi cài dependencies
- Nguyên nhân: Thư mục dự án đang thuộc quyền sở hữu của user khác (
root, user trước đó clone về...), khiến user hiện tại không có quyền ghi để tạonode_modules. Khi dùngsudo bun install, lệnhsudokhông nhận được đường dẫnPATHcủa Bun (thường cài trong~/.bun/bin). - Cách xử lý: Cấp lại quyền sở hữu thư mục dự án cho user hiện tại, rồi chạy lại bằng user thường (không dùng
sudo):sudo chown -R $USER:$USER /path/to/openstory bun install
Sự cố 5: Error inside ProxyWorker: Network connection lost (Wrangler crash khi tạo media/workflow)
- Nguyên nhân:
- Khi client gửi nhiều request đồng thời hoặc đóng kết nối giữa chừng (aborted request), socket TCP giữa Nginx và Miniflare ProxyWorker bị ngắt đột ngột. Trong phiên bản cũ của Wrangler (
< 4.151.0), lỗi này không được xử lý an toàn dẫn đến crash toàn bộ tiến trình Wrangler dev. - Nginx reverse proxy chưa cấu hình Keep-Alive (
proxy_http_version 1.1;) hoặc thời gian timeout quá ngắn (mặc định 60s) khiến Nginx tự ngắt socket trong khi workflow đang xử lý tác vụ dài (polling AI task).
- Khi client gửi nhiều request đồng thời hoặc đóng kết nối giữa chừng (aborted request), socket TCP giữa Nginx và Miniflare ProxyWorker bị ngắt đột ngột. Trong phiên bản cũ của Wrangler (
- Cách xử lý:
- Nâng cấp Wrangler lên phiên bản mới nhất:
bun add wrangler@latest -d bun run build - Cấu hình Nginx Reverse Proxy chuẩn cho OpenStory:
Xem chi tiết cấu hình đầy đủ tại Mục 5. Đặc biệt lưu ý:
- Nếu Nginx trên server riêng,
proxy_passphải trỏ tới IP LAN của máy chủ app:http://172.16.105.167:3033;. - Nếu Nginx trên cùng máy chủ,
proxy_passdùng:http://127.0.0.1:3033;. - Bắt buộc phải có
proxy_read_timeout 300s;vàproxy_http_version 1.1;để không bị ngắt socket giữa chừng. Sau đó reload lại Nginx:
sudo nginx -t && sudo systemctl reload nginx - Nếu Nginx trên server riêng,
- Nâng cấp Wrangler lên phiên bản mới nhất: