feat: PM2 + Docker deployment config (MySQL container, PM2 for backend+frontend proxy)
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal 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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
59
deploy.sh
Executable 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 端口"
|
||||
@@ -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
50
ecosystem.config.cjs
Normal file
@@ -0,0 +1,50 @@
|
||||
// PM2 进程配置 — 恭学教育
|
||||
// 用法: pm2 start ecosystem.config.cjs
|
||||
// 前提: 项目部署在 /opt/gongxue(deploy.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
67
serve-proxy.js
Normal 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}`);
|
||||
});
|
||||
Reference in New Issue
Block a user