feat: 新增 Docker Compose 部署方案

- Dockerfile 单阶段 Alpine 镜像,使用国内镜像(npmmirror / Prisma 引擎 / apk)加速
- entrypoint 首次复制内置图章素材到 uploads volume,自动执行 prisma migrate deploy
- docker-compose.yml 绑定 127.0.0.1:3001,强制走外部 Nginx 反代
- Express 在 production 下同时托管 packages/web/dist 及 SPA fallback
- Prisma schema 增加 linux-musl 二进制目标,支持 Alpine 运行
- 新增 DEPLOY.md 部署指南,含 .env 模板与 Nginx 反代示例

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-19 17:04:25 +08:00
parent 0319557723
commit 711f422558
8 changed files with 307 additions and 1 deletions

18
.dockerignore Normal file
View File

@@ -0,0 +1,18 @@
node_modules
**/node_modules
**/dist
.git
.gitignore
.env
.env.local
.env.*
!.env.production.example
prisma/dev.db
prisma/dev.db-journal
*.log
.DS_Store
# 原始素材(镜像只需要 packages/server/uploads/stamps 里的成品)
assets/
# Docker 构建上下文不需要旧的数据目录
data/
uploads/

12
.env.production.example Normal file
View File

@@ -0,0 +1,12 @@
# 部署前复制为 .env 并修改以下值
# cp .env.production.example .env
# JWT 签名密钥32+ 字符随机串)
JWT_SECRET=please-replace-with-a-strong-random-secret
# 管理后台访问密钥(通过 X-Admin-Key 请求头传入)
ADMIN_API_KEY=please-replace-with-a-strong-random-admin-key
# 对外访问的站点根 URL用于生成 QR 码中的收集链接)
# 必须与你在 Nginx 上配置的域名一致
SITE_URL=https://stamp.example.com

181
DEPLOY.md Normal file
View File

@@ -0,0 +1,181 @@
# 部署指南
基于 Docker Compose 的单容器部署方案。容器内 Express 同时托管:
- `/api/*` — API
- `/uploads/*` — 图章静态资源
- 其余路径 — React SPA`packages/web/dist`
外部通过宿主机 Nginx 反向代理到容器的 3000 端口(仅绑定 127.0.0.1,不直接暴露)。
---
## 1. 前置条件
- Linux 服务器Docker ≥ 24、Docker Compose v2
- 已解析到服务器的域名(例 `stamp.example.com`
- 已签发的 SSL 证书Let's Encrypt / acme.sh 等任选)
---
## 2. 拉取代码并配置环境变量
```bash
git clone <repo-url> citywalk-stamp
cd citywalk-stamp
cp .env.production.example .env
vim .env # 填入 JWT_SECRET / ADMIN_API_KEY / SITE_URL
```
必填项:
| 变量 | 说明 |
|---|---|
| `JWT_SECRET` | 用户 JWT 签名密钥,建议 `openssl rand -hex 32` 生成 |
| `ADMIN_API_KEY` | 管理后台访问密钥,同上 |
| `SITE_URL` | 对外域名,例 `https://stamp.example.com`(影响 QR 码链接) |
---
## 3. 启动容器
```bash
docker compose up -d --build
```
首次启动会自动完成:
- Prisma migrate deploy建表
- 把内置的 16 枚图章素材复制到 `./uploads/stamps/`
查看日志确认启动成功:
```bash
docker compose logs -f app
# 看到 "Server running on http://localhost:3000" 即 OK
```
---
## 4. 首次导入图章数据
容器默认不自动 seed 数据库(避免覆盖后续运营数据)。首次部署需手动执行一次:
```bash
docker compose exec app pnpm db:seed
```
> 再次执行会**清空并重建**所有图章,同时级联删除用户的收集记录。生产环境请勿随意重跑。
---
## 5. 配置 Nginx 反向代理
在宿主机上配置 Nginx
```nginx
server {
listen 80;
server_name stamp.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name stamp.example.com;
ssl_certificate /etc/letsencrypt/live/stamp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/stamp.example.com/privkey.pem;
# 单入口反代:容器里 Express 自己处理 /api、/uploads、SPA 路由
location / {
proxy_pass http://127.0.0.1:3001;
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;
}
# 静态资源缓存(可选)
location ^~ /uploads/ {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
# 上传体积(管理后台上传图章)
client_max_body_size 10m;
}
```
测试并 reload
```bash
sudo nginx -t && sudo systemctl reload nginx
```
访问 `https://stamp.example.com` 即可看到首页。
---
## 6. 数据持久化与备份
容器两个 bind mount
- `./data/` — SQLite 数据库(`prod.db`
- `./uploads/` — 图章图片(含首次填充的 16 枚 + 后续管理后台上传的)
备份:
```bash
# 停机备份最稳妥SQLite 文件锁)
docker compose stop app
tar czf backup-$(date +%F).tgz data/ uploads/ .env
docker compose start app
```
---
## 7. 常用运维命令
```bash
# 查看日志
docker compose logs -f app
# 进入容器
docker compose exec app sh
# 重启
docker compose restart app
# 升级(拉新代码后重建)
git pull
docker compose up -d --build
# 查看当前图章数
docker compose exec app sh -c 'echo "SELECT COUNT(*) FROM Stamp;" | sqlite3 /app/data/prod.db' \
|| docker compose exec app node -e "
import('@prisma/client').then(async ({PrismaClient}) => {
const p = new PrismaClient();
console.log(await p.stamp.count());
await p.\$disconnect();
});
"
# 完全重置(会删除所有用户和收集数据,谨慎)
docker compose down
rm -rf data/ uploads/
docker compose up -d --build
docker compose exec app pnpm db:seed
```
---
## 8. 故障排查
- **容器启动失败 `PrismaClientInitializationError`**:检查 `./data/` 目录权限、`DATABASE_URL`
- **图章图片 404**:进入容器 `ls /app/packages/server/uploads/stamps` 看是否被 seed 进来;若空,手动执行 `cp /app/stamps-seed/*.jpg /app/packages/server/uploads/stamps/`
- **QR 码扫出来是 `localhost`**`.env``SITE_URL` 没改对,修正后 `docker compose restart app`
- **Nginx 502**:确认容器跑着 `docker compose ps`,端口 `ss -tlnp | grep 3000`

44
Dockerfile Normal file
View File

@@ -0,0 +1,44 @@
FROM node:22-alpine
WORKDIR /app
# 国内构建环境加速(仅容器构建期生效,不影响本地/正式 registry
# 1) Alpine apk 源换清华
RUN sed -i 's|https\?://dl-cdn.alpinelinux.org|https://mirrors.tuna.tsinghua.edu.cn|g' /etc/apk/repositories
RUN corepack enable && corepack prepare pnpm@9 --activate \
&& apk add --no-cache openssl
# 2) npm / pnpm registry 换淘宝镜像
# 3) Prisma 下载引擎走淘宝镜像
ENV PRISMA_ENGINES_MIRROR=https://registry.npmmirror.com/-/binary/prisma
RUN npm config set registry https://registry.npmmirror.com/ \
&& pnpm config set registry https://registry.npmmirror.com/
# Copy everything (.dockerignore filters out node_modules, dist, .git, etc.)
COPY . .
# 强制项目级 registry兜底防止项目根 .npmrc 未覆盖)
RUN echo "registry=https://registry.npmmirror.com/" > /app/.npmrc
# Install all deps (tsx is used at runtime via devDependencies)
RUN pnpm install --frozen-lockfile --registry=https://registry.npmmirror.com/
# Generate Prisma client (produces linux-musl binary per schema.prisma)
RUN pnpm exec prisma generate
# Build web SPA; server runs directly from TS via tsx
RUN pnpm --filter @stamp/web build
# Stash initial stamp assets so the uploads volume can be seeded on first run
RUN mkdir -p /app/stamps-seed \
&& cp packages/server/uploads/stamps/*.jpg /app/stamps-seed/ \
&& rm -rf packages/server/uploads/stamps
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENV NODE_ENV=production
EXPOSE 3000
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
app:
build: .
image: citywalk-stamp:latest
container_name: citywalk-stamp
restart: unless-stopped
# Bind to loopback only: the outside world must go through your host Nginx
# 宿主机 3001 -> 容器 30003000 已被宿主其它服务占用,例如 1Panel Gitea
ports:
- "127.0.0.1:3001:3000"
volumes:
- ./data:/app/data
- ./uploads:/app/packages/server/uploads
env_file:
- .env
environment:
NODE_ENV: production
SERVER_PORT: 3000
DATABASE_URL: file:/app/data/prod.db

20
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,20 @@
#!/bin/sh
set -e
cd /app
mkdir -p /app/data
mkdir -p /app/packages/server/uploads/stamps
# Seed stamp assets on first run (idempotent)
if [ -z "$(ls -A /app/packages/server/uploads/stamps 2>/dev/null)" ]; then
echo "→ Seeding stamp assets into uploads volume..."
cp /app/stamps-seed/*.jpg /app/packages/server/uploads/stamps/ 2>/dev/null || true
fi
echo "→ Applying database migrations..."
pnpm exec prisma migrate deploy
echo "→ Starting server on :${SERVER_PORT:-3000}..."
cd /app/packages/server
exec pnpm exec tsx src/index.ts

View File

@@ -31,6 +31,17 @@ app.use("/api/redemption", redemptionRoutes);
// Admin routes // Admin routes
app.use("/api/admin", adminRoutes); app.use("/api/admin", adminRoutes);
// Serve built web frontend in production (single-container deployment)
if (process.env.NODE_ENV === "production") {
const webDist = path.join(__dirname, "../../web/dist");
app.use(express.static(webDist));
app.use((req, res, next) => {
if (req.method !== "GET" && req.method !== "HEAD") return next();
if (req.path.startsWith("/api") || req.path.startsWith("/uploads")) return next();
res.sendFile(path.join(webDist, "index.html"));
});
}
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`); console.log(`Server running on http://localhost:${PORT}`);
}); });

View File

@@ -1,5 +1,6 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
} }
datasource db { datasource db {