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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name _;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
@@ -13,5 +30,7 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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', ''),
|
password: config.get('DB_PASSWORD', ''),
|
||||||
database: config.get('DB_DATABASE', 'dorm_billing'),
|
database: config.get('DB_DATABASE', 'dorm_billing'),
|
||||||
entities: allEntities,
|
entities: allEntities,
|
||||||
synchronize: true,
|
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
|||||||
type: 'better-sqlite3' as const,
|
type: 'better-sqlite3' as const,
|
||||||
database: config.get('DB_DATABASE', 'dorm_billing.db'),
|
database: config.get('DB_DATABASE', 'dorm_billing.db'),
|
||||||
entities: allEntities,
|
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:
|
services:
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
container_name: dorm_billing_mysql
|
container_name: gongxue_mysql
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: dorm_billing_2024
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024}
|
||||||
MYSQL_DATABASE: dorm_billing
|
MYSQL_DATABASE: ${DB_DATABASE:-gongxue}
|
||||||
MYSQL_CHARSET: utf8mb4
|
MYSQL_CHARSET: utf8mb4
|
||||||
MYSQL_COLLATION: utf8mb4_unicode_ci
|
MYSQL_COLLATION: utf8mb4_unicode_ci
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "127.0.0.1:3306:3306"
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data:/var/lib/mysql
|
||||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
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:
|
volumes:
|
||||||
mysql_data:
|
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