feat: PM2 + Docker deployment config (MySQL container, PM2 for backend+frontend proxy)

This commit is contained in:
2026-07-06 10:12:05 +08:00
parent a568da160c
commit 2ce73b8fc3
7 changed files with 217 additions and 37 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# 恭学教育 — 生产环境配置
# 复制为 .env 并修改密码
# MySQL 数据库
MYSQL_ROOT_PASSWORD=change-me-to-a-strong-password
# 后端
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USERNAME=root
DB_DATABASE=gongxue
DB_SYNCHRONIZE=false
JWT_SECRET=change-me-to-a-random-string-at-least-32-chars
JWT_EXPIRES_IN=24h
PORT=3000

View File

@@ -1,11 +1,28 @@
server {
listen 80;
server_name localhost;
server_name _;
root /usr/share/nginx/html;
index index.html;
# gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
gzip_min_length 256;
# security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# static assets with long cache
location /assets/ {
expires 7d;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
location /api/ {
@@ -13,5 +30,7 @@ server {
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_read_timeout 60s;
}
}

View File

@@ -108,7 +108,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
password: config.get('DB_PASSWORD', ''),
database: config.get('DB_DATABASE', 'dorm_billing'),
entities: allEntities,
synchronize: true,
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
charset: 'utf8mb4',
};
}
@@ -116,7 +116,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
type: 'better-sqlite3' as const,
database: config.get('DB_DATABASE', 'dorm_billing.db'),
entities: allEntities,
synchronize: true,
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
};
},
}),

59
deploy.sh Executable file
View File

@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# 恭学教育 — PM2 部署脚本
# 用法: ./deploy.sh [ssh_host]
# ssh_host: SSH 主机别名,默认 tencent
set -euo pipefail
SSH_HOST="${1:-tencent}"
REMOTE_DIR="/opt/gongxue"
echo "=== 1/5 本地构建后端 ==="
npm run build -w @gongxue/server
echo "=== 2/5 本地构建前端 ==="
npm run build -w @gongxue/admin
echo "=== 3/5 同步到 ${SSH_HOST} ==="
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.git' \
--exclude='*.db' \
--exclude='.DS_Store' \
--exclude='logs/' \
--exclude='.turbo/' \
./ "${SSH_HOST}:${REMOTE_DIR}/"
echo "=== 4/5 启动 MySQL ==="
ssh "${SSH_HOST}" "
cd ${REMOTE_DIR}
docker compose up -d mysql
mkdir -p logs
# 等 MySQL 就绪
for i in \$(seq 1 30); do
docker compose exec -T mysql mysqladmin ping -h localhost --silent 2>/dev/null && break
echo '等待 MySQL...'
sleep 2
done
# 首次部署需安装依赖,后续跳过
if [ ! -d node_modules ]; then
echo '首次部署,安装依赖...'
npm ci --omit=dev
fi
"
echo "=== 5/5 PM2 重载 ==="
ssh "${SSH_HOST}" "
cd ${REMOTE_DIR}
pm2 startOrReload ecosystem.config.cjs --update-env
pm2 save
pm2 status
"
echo ""
echo "部署完成!"
echo "访问: http://$(ssh "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')"
echo ""
echo "首次部署还需在服务器执行:"
echo " ssh ${SSH_HOST} && cd ${REMOTE_DIR}"
echo " cp .env.example .env && vim .env # 修改密码和 JWT_SECRET"
echo " sudo setcap 'cap_net_bind_service=+ep' \$(which node) # PM2 绑 80 端口"

View File

@@ -3,48 +3,18 @@ version: '3.8'
services:
mysql:
image: mysql:8.0
container_name: dorm_billing_mysql
container_name: gongxue_mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: dorm_billing_2024
MYSQL_DATABASE: dorm_billing
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024}
MYSQL_DATABASE: ${DB_DATABASE:-gongxue}
MYSQL_CHARSET: utf8mb4
MYSQL_COLLATION: utf8mb4_unicode_ci
ports:
- "3306:3306"
- "127.0.0.1:3306:3306"
volumes:
- mysql_data:/var/lib/mysql
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
backend:
build:
context: .
dockerfile: apps/server/Dockerfile
container_name: dorm_billing_backend
restart: always
ports:
- "3000:3000"
environment:
DB_HOST: mysql
DB_PORT: 3306
DB_USERNAME: root
DB_PASSWORD: dorm_billing_2024
DB_DATABASE: dorm_billing
JWT_SECRET: dorm-billing-jwt-secret-key-2024
JWT_EXPIRES_IN: 24h
depends_on:
- mysql
frontend:
build:
context: .
dockerfile: apps/admin/Dockerfile
container_name: dorm_billing_frontend
restart: always
ports:
- "80:80"
depends_on:
- backend
volumes:
mysql_data:

50
ecosystem.config.cjs Normal file
View File

@@ -0,0 +1,50 @@
// PM2 进程配置 — 恭学教育
// 用法: pm2 start ecosystem.config.cjs
// 前提: 项目部署在 /opt/gongxuedeploy.sh 默认路径)
// .env 文件在 /opt/gongxue/.env
// docker compose up -d 已启动 MySQL
const DEPLOY_DIR = process.env.DEPLOY_DIR || '/opt/gongxue';
module.exports = {
apps: [
{
name: 'gongxue-backend',
cwd: DEPLOY_DIR,
script: 'apps/server/dist/main.js',
// 集群模式2 实例,按 CPU 核数可调)
instances: 2,
exec_mode: 'cluster',
// 环境变量
env: {
NODE_ENV: 'production',
},
// 内存限制
max_memory_restart: '512M',
max_restarts: 10,
restart_delay: 5000,
// 日志
error_file: 'logs/backend-error.log',
out_file: 'logs/backend-out.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss',
autorestart: true,
watch: false,
},
{
name: 'gongxue-frontend',
cwd: DEPLOY_DIR,
script: 'serve-proxy.js',
env: {
NODE_ENV: 'production',
FRONTEND_PORT: 80, // 需 root 或 setcap: sudo setcap 'cap_net_bind_service=+ep' $(which node)
API_TARGET: 'http://127.0.0.1:3000',
},
max_memory_restart: '256M',
max_restarts: 5,
error_file: 'logs/frontend-error.log',
out_file: 'logs/frontend-out.log',
autorestart: true,
watch: false,
},
],
};

67
serve-proxy.js Normal file
View File

@@ -0,0 +1,67 @@
// 轻量静态文件 + API 代理服务器
// PM2 启动: node serve-proxy.js
const http = require('http');
const fs = require('fs');
const path = require('path');
const PORT = process.env.FRONTEND_PORT || 5173;
const API_TARGET = process.env.API_TARGET || 'http://127.0.0.1:3000';
const STATIC_DIR = path.join(__dirname, 'apps/admin/dist');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff2': 'font/woff2',
};
function serveStatic(res, filePath) {
const ext = path.extname(filePath);
const mime = MIME[ext] || 'application/octet-stream';
try {
const content = fs.readFileSync(filePath);
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' });
res.end(content);
} catch {
// SPA fallback: return index.html
const index = fs.readFileSync(path.join(STATIC_DIR, 'index.html'));
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(index);
}
}
const server = http.createServer((req, res) => {
// API 代理
if (req.url.startsWith('/api/')) {
const opts = {
hostname: '127.0.0.1',
port: 3000,
path: req.url,
method: req.method,
headers: { ...req.headers, host: '127.0.0.1:3000' },
};
const proxy = http.request(opts, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
});
proxy.on('error', () => {
res.writeHead(502);
res.end('API unavailable');
});
req.pipe(proxy);
return;
}
// 静态文件
const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, '');
serveStatic(res, path.join(STATIC_DIR, safePath));
});
server.listen(PORT, () => {
console.log(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET}`);
});