写在前面
折腾过自建博客的人大概都懂那种感觉:教程一篇接一篇,版本一茬接一茬,等你照着某篇 2024 年的文章敲完命令,才发现 API 早从 v2 换成了 v3,反向代理少写一行 WebSocket 配置,后台管理页面直接白屏。
这篇文章记录的是我在 1Panel 上从零部署 Mix Space(后端 Core)+ Yohaku(官方前端主题)的完整过程——包括踩过的坑、和坑对应的固定解法。和市面上大多数"跟我做"的教程不同,我把它写成了一份结构化到可以直接喂给 AI Agent 执行的文档:如果你在用 Codex、Claude Code 之类支持长上下文和命令行操作的工具,理论上只需要把这篇文章整篇喂给它,再填好服务器 IP、域名这些基本信息,它就能按部就班地帮你把博客立起来。
如果你只是想读懂部署逻辑、自己动手操作,跳过"执行协议"那部分,直接看架构和分阶段步骤即可;如果你打算交给 AI 全自动完成,请看下一节的说明。
这篇文章怎么用
这篇文章有两种读法:
当作阅读材料:从「整体架构」开始往下看,了解 Mix Space + Yohaku + 1Panel 这套组合是怎么跑起来的,每一步在解决什么问题、踩过什么坑。文中所有命令、配置文件都是实测可用的,你可以照着手动执行。
当作 Codex 的执行脚本:如果你希望让 AI Agent 帮你全自动部署,把整篇文章连同下面这份参数表一起发给它(密码等敏感信息在对话中临时提供,不要写进任何会被提交到仓库的文件):
deployment:
mode: fresh
server_ip: "<SERVER_IP>"
ssh_user: root
ssh_port: 22
ssh_password_or_key: "<SECRET_OR_KEY_PATH>"
domain: "<BLOG_DOMAIN>"
install_root: /opt
backend_port: 2333
frontend_port: 2323
upload_limit_mb: 600
timezone: Asia/Shanghai
panel_url: "http://<SERVER_IP>:<PANEL_PORT>/<PANEL_ENTRY>"
panel_username: "<PANEL_USERNAME>"
panel_password: "<SECRET>"
certificate_already_issued: true
github_deploy_repo: "<OWNER>/yohaku-deploy-action"
github_logged_in: true
panel_logged_in: true
owner:
username: "<LOGIN_USERNAME>"
password: "<SECRET>"
nickname: "<DISPLAY_NAME>"
email: "<VALID_EMAIL>"
introduction: "<SHORT_INTRODUCTION>"
avatar_url: "" # 可留空,后续在后台修改
bootstrap_note:
create: true
title: "欢迎来到<DISPLAY_NAME>"
slug: hello-world
文末的「一次性提示词模板」是我实际用来触发 Codex 执行的完整指令,复制、填好参数、发送即可。
安全声明:本文不保存也不应写入服务器密码、1Panel 密码、GitHub PAT、数据库密码、Cookie 或 Webhook 密钥,文中所有敏感字段均为占位符。
整体架构:一个域名,两个服务
Mix Space 是前后端分离架构:Core 负责数据、API、鉴权、Webhook 这些"看不见"的部分;Yohaku 是官方出的 Next.js 前端主题,负责渲染页面。两者独立部署、独立更新,靠反向代理拼成同一个域名对外服务——访客感知不到背后其实是两个进程在配合。
这套设计里有几个刻意的取舍,值得先说清楚:
- Core 用 Docker Compose 管理,Yohaku 不用。Core 是有状态服务(数据库、缓存),Compose 更适合;Yohaku 是无状态的 Next.js 应用,交给 1Panel 的 Node.js Runtime 管理更轻量,也不需要在服务器上装 Node 编译环境。
- Yohaku 的源码编译放在 GitHub Actions,服务器只负责接收产物。这样服务器不需要装 pnpm、不需要跑长时间的构建任务,发布也天然带上了版本号(
releases/<run_number>),方便回滚。 - 数据库和缓存不暴露公网端口,只在 Docker 内部网络里被 Core 访问。
- 两个服务的本机端口固定:后端
127.0.0.1:2333,前端127.0.0.1:2323,所有对外流量都经 OpenResty 反向代理进来。
固定目录结构长这样:
/opt/mix-space/
docker-compose.yml
.env # 600
data/mx-space/
data/postgres/
data/redis/
/opt/yohaku/
.env # 600
.cache/
releases/<run_number>/
current -> releases/<run_number>/standalone/apps/web
server.js -> current/server.js
/opt/1panel/www/conf.d/<domain>.conf
/opt/1panel/www/sites/<domain>/ssl/fullchain.pem
/opt/1panel/www/sites/<domain>/ssl/privkey.pem
/opt/1panel/runtime/node/yohaku/
路由规则也很直白——除了几个明确的后端路径,其余全部交给前端:
| 路径 | 上游 | 说明 |
|---|---|---|
/ 及其他普通路径 | 127.0.0.1:2323 | Yohaku 前端 |
/api/v3 | 127.0.0.1:2333 | Core API |
/ws/ | 127.0.0.1:2333 | 当前 Core WebSocket |
/socket.io | 127.0.0.1:2333 | 兼容旧客户端 |
/render | 127.0.0.1:2333 | Core 渲染接口 |
/proxy | 127.0.0.1:2333 | Core 管理后台静态代理 |
/qaqdmin | 127.0.0.1:2333/proxy/qaqdmin | 管理后台短路径 |
开始之前:先核对上游资料
Mix Space 和 Yohaku 都在持续更新,教程最怕的就是"当时对、现在错"。所以无论是自己动手还是交给 AI,第一步都不是敲命令,而是先确认以下资料没有过时:
https://mx-space.js.org/llms.txthttps://mx-space.js.org/llms-full.txthttps://mx-space.js.org/docs/deploy/dockerhttps://mx-space.js.org/docs/deploy/reverse-proxyhttps://mx-space.js.org/docs/deploy/sslhttps://mx-space.js.org/agent-skills/mix-space-expert.mdhttps://github.com/innei-dev/yohaku-deploy-actionhttps://github.com/innei-dev/Yohaku(可能为私有仓库,需要GH_PAT)
具体要核对哪些点:
- 官方
docker-compose.yml的服务名、迁移命令和环境变量有没有变。 - 健康检查接口是否仍是
/api/v3/ping。 - Yohaku 的构建命令是否仍为
pnpm --filter @yohaku/web build:ci。 - standalone 子目录是否仍为
apps/web/.next/standalone/apps/web。 - 1Panel 当前生成的 Node runtime 文件结构有没有变化。
任何一项对不上,先更新执行记录再继续——这也是为什么这篇文章更适合当"活文档"用,而不是一次性截图收藏。
如果你在用 AI Agent:这几条是安全底线
这一节是专门写给 Codex(或其他自动化 Agent)看的强约束,人类操作者也建议照做:
- 默认全新安装:本流程默认是
fresh全新部署,不迁移旧站数据。 - 先审计,后修改:发现同名容器、已有
/opt/mix-space数据、已有/opt/yohaku发布目录或端口被占用时,不得直接删除,先判断是否属于本次未完成部署;无法确认时暂停并询问。 - 不动无关资源:不删除、不停止、不改写与本任务无关的容器、网站、证书、端口、目录或 1Panel 数据。
- 凭据零暴露:不在聊天、命令输出、Git 提交、部署文档中打印任何密码、Cookie、PAT 或生成的密钥。
- 临时文件安全:所有临时密钥文件使用
600权限;部署完成后删除本机临时.env、Cookie、API 请求体和 1Panel 数据库副本。 - Nginx 改动流程:所有 OpenResty 修改必须先备份原文件,再执行
nginx -t;只有测试成功才能reload。 - 数据库改动是最后手段:修改 1Panel 数据库前必须创建 SQLite 在线备份并核对表结构;字段不匹配时立即停止。
- 端口固定:后端固定监听
127.0.0.1:2333,前端固定监听127.0.0.1:2323;PostgreSQL 和 Redis 不暴露公网端口。 - 镜像固定:后端使用官方镜像
innei/mx-server:latest,不得使用mx-server-upload-100m或其他自制镜像。 - API 版本固定:当前 Core API 使用
/api/v3,不得沿用旧教程中的/api/v2。 - 源码来源固定:Yohaku 构建源码使用官方
innei-dev/Yohaku;部署仓库可以是用户 fork 的yohaku-deploy-action,但不能把旧的自定义主题仓库当作源码。 - 分阶段验收:每个阶段必须通过验收门后才能进入下一阶段,最终结论必须基于当次新运行的验证命令。
- 留痕:执行期间创建一份不含密钥的进度记录
docs/deployments/YYYY-MM-DD-<domain>.md,按本文阶段更新状态,便于中断后续接。
阶段 A:服务器、DNS、1Panel 与 GitHub 审计
动手之前,先把地基摸清楚——这一步的原则是只看不改。
确认 SSH 主机身份
不要无条件使用 StrictHostKeyChecking=no 连接服务器。先扫描并展示指纹供核对:
ssh-keyscan -p "$SSH_PORT" "$SERVER_IP" >"$KNOWN_HOSTS"
ssh-keygen -lf "$KNOWN_HOSTS"
之后所有 SSH/SCP 使用:
-o UserKnownHostsFile=<KNOWN_HOSTS> -o StrictHostKeyChecking=yes
无修改审计
hostnamectl
cat /etc/os-release
docker --version || true
docker compose version || true
1pctl version || true
docker ps -a
ss -lntp
find /opt -maxdepth 2 -mindepth 1 -type d -print
df -h / /opt
确认这几件事:目标确实是新服务器或未完成的新部署;2323、2333 没有被无关服务占用;磁盘至少预留 10 GB;识别 OpenResty 容器名(1Panel 通常叫 openresty)。
检查 DNS
dig @1.1.1.1 +short A "$DOMAIN"
dig @1.1.1.1 +short AAAA "$DOMAIN"
dig +short NS "${DOMAIN#*.}"
如果用了 Cloudflare,A/AAAA 记录可能返回 Cloudflare 的地址,这是正常的。确认源站配置指向目标服务器即可;HTTPS 配置完成前,访问可能会先看到
525,别慌。
装 Docker(如果还没装)
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
docker --version
docker compose version
确认 GitHub 访问权限
gh auth status
gh repo view "$GITHUB_DEPLOY_REPO"
gh api repos/innei-dev/yohaku --jq '.full_name'
确认当前身份能访问部署 fork 和官方 Yohaku 源码——GH_PAT 至少需要读取官方源码的权限,部署 fork 的 Actions 还需要能正常写入 build_hash。
阶段 B:部署 Mix Space Core
生成密钥
在本机临时目录生成,不打印值:
umask 077
POSTGRES_PASSWORD="$(openssl rand -hex 24)"
JWT_SECRET="$(openssl rand -hex 32)"
ENCRYPT_KEY="$(openssl rand -hex 32)"
WEBHOOK_SECRET="$(openssl rand -hex 32)"
后端 .env:
DOMAIN=<BLOG_DOMAIN>
POSTGRES_PASSWORD=<RANDOM_48_HEX>
JWT_SECRET=<RANDOM_64_HEX>
ENCRYPT_KEY=<RANDOM_64_HEX>
Compose 配置
把 <BLOG_DOMAIN> 替换成实际域名,密钥不要直接写进 Compose 文件,靠 .env 注入:
x-mx-env: &mx-env
TZ: Asia/Shanghai
NODE_ENV: production
REDIS_HOST: redis
PG_HOST: postgres
PG_PORT: "5432"
PG_USER: mx
PG_PASSWORD: ${POSTGRES_PASSWORD}
PG_DATABASE: mx_core
SNOWFLAKE_WORKER_ID: "1"
ALLOWED_ORIGINS: <BLOG_DOMAIN>
JWT_SECRET: ${JWT_SECRET}
ENCRYPT_ENABLE: "true"
ENCRYPT_KEY: ${ENCRYPT_KEY}
TRUST_PROXY: "1"
services:
app:
container_name: mx-server
image: innei/mx-server:latest
environment: *mx-env
volumes:
- ./data/mx-space:/root/.mx-space
ports:
- "127.0.0.1:2333:2333"
depends_on:
mx-migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
networks: [mx-space]
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:2333/api/v3/ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
mx-migrate:
container_name: mx-migrate
image: innei/mx-server:latest
command: ["node", "migrate.mjs"]
environment: *mx-env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks: [mx-space]
restart: "no"
postgres:
container_name: mx-postgres
image: postgres:16-alpine
environment:
POSTGRES_USER: mx
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: mx_core
volumes:
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mx -d mx_core"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
networks: [mx-space]
restart: unless-stopped
redis:
container_name: mx-redis
image: redis:alpine
volumes:
- ./data/redis:/data
healthcheck:
test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
interval: 10s
timeout: 3s
retries: 10
start_period: 10s
networks: [mx-space]
restart: unless-stopped
networks:
mx-space:
name: mx-space
driver: bridge
上传与启动
install -d -m 700 /opt/mix-space
install -d /opt/mix-space/data/mx-space
install -d /opt/mix-space/data/postgres
install -d /opt/mix-space/data/redis
chmod 600 /opt/mix-space/.env
cd /opt/mix-space
docker compose config --quiet
docker compose pull
docker compose up -d --remove-orphans
验收
cd /opt/mix-space
docker compose ps -a
docker inspect mx-server --format '{{.State.Health.Status}}'
docker inspect mx-postgres --format '{{.State.Health.Status}}'
docker inspect mx-redis --format '{{.State.Health.Status}}'
docker inspect mx-migrate --format '{{.State.ExitCode}}'
curl -fsS http://127.0.0.1:2333/api/v3/ping
ss -ltn | grep '127.0.0.1:2333'
期望:三个长期容器为 healthy,mx-migrate 为 Exited (0),ping 返回 pong。mx-migrate 是官方的一次性迁移服务,看到它退出是正常的——不是站点迁移任务,也不需要持续运行。
阶段 C:1Panel 网站、证书与 OpenResty
在 1Panel 创建网站
网站 -> 网站 -> 创建 -> 反向代理:
主域名: <BLOG_DOMAIN>
代理地址: http://127.0.0.1:2323
IPv6: 根据服务器启用
先创建网站记录,再用完整配置文件覆盖 OpenResty 配置。不要把单个 location 片段粘贴到只接受指令片段的输入框里——完整的 server { ... } 块要放进网站配置文件或 1Panel 的"配置文件"编辑器。
证书:正常路径
网站 -> 证书确认证书状态为ready,SAN 覆盖<BLOG_DOMAIN>或*.<ROOT_DOMAIN>。- 在网站 HTTPS 配置中选择该证书。
- 开启 HTTPS 和 HTTP 跳转 HTTPS。
- 开启自动续期。
证书:如果下拉框"无数据"
依次尝试:
- 刷新证书列表,确认域名/SAN 和状态。
- 先创建 HTTP 网站,再进入该网站的 HTTPS 配置绑定证书。
- 刷新 1Panel 页面或重启 1Panel 服务后重试。
- 仍无数据,才动数据库。
动数据库前,先确认 1Panel 2.x 使用的 SQLite 表结构与下面查询匹配:
apt-get update && apt-get install -y sqlite3
DB=/opt/1panel/db/agent.db
BACKUP="/opt/1panel/db/agent.db.before-ssl-link-$(date +%Y%m%d%H%M%S)"
sqlite3 "$DB" ".backup '$BACKUP'"
sqlite3 "$DB" 'PRAGMA table_info(websites);'
sqlite3 "$DB" 'PRAGMA table_info(website_ssls);'
先查询,不猜 ID:
SELECT id, primary_domain, protocol, website_ssl_id, status
FROM websites
WHERE primary_domain = '<BLOG_DOMAIN>';
SELECT id, primary_domain, domains, provider, auto_renew, status, expire_date
FROM website_ssls
WHERE status = 'ready';
确认唯一的 WEBSITE_ID 和 SSL_ID 后:
UPDATE websites
SET protocol = 'HTTPS', website_ssl_id = <SSL_ID>
WHERE id = <WEBSITE_ID> AND primary_domain = '<BLOG_DOMAIN>';
若 1Panel 没有自动写出证书文件,可从同一条记录导出,不要在终端打印内容:
SSL_DIR=/opt/1panel/www/sites/<BLOG_DOMAIN>/ssl
install -d -m 700 "$SSL_DIR"
sqlite3 -batch -noheader "$DB" "SELECT pem FROM website_ssls WHERE id=<SSL_ID>;" >"$SSL_DIR/fullchain.pem"
sqlite3 -batch -noheader "$DB" "SELECT private_key FROM website_ssls WHERE id=<SSL_ID>;" >"$SSL_DIR/privkey.pem"
chmod 644 "$SSL_DIR/fullchain.pem"
chmod 600 "$SSL_DIR/privkey.pem"
openssl x509 -in "$SSL_DIR/fullchain.pem" -noout -subject -issuer -dates -ext subjectAltName
openssl pkey -in "$SSL_DIR/privkey.pem" -noout -check
数据库字段不匹配、查询结果不唯一,或证书私钥校验失败——这三种情况必须停下,不能硬着头皮继续改。
OpenResty 完整配置
这份配置做了一件挺关键的事:把前端(2323)和后端(2333)的路由写进同一个 server 块,用同一个域名对外服务。访客看到的是一个干净的域名,前后端在背后各司其职。
把所有 <BLOG_DOMAIN> 替换为实际域名;Cloudflare IP 段建议每次部署前从官方来源重新核对;不要用普通 envsubst 处理整个文件,否则 $host、$scheme 这些 Nginx 变量会被误替换。
server {
listen 80;
listen [::]:80;
server_name <BLOG_DOMAIN>;
access_log /www/sites/<BLOG_DOMAIN>/log/access.log main;
error_log /www/sites/<BLOG_DOMAIN>/log/error.log;
location ^~ /.well-known/acme-challenge/ {
allow all;
root /usr/share/nginx/html;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name <BLOG_DOMAIN>;
access_log /www/sites/<BLOG_DOMAIN>/log/access.log main;
error_log /www/sites/<BLOG_DOMAIN>/log/error.log;
ssl_certificate /www/sites/<BLOG_DOMAIN>/ssl/fullchain.pem;
ssl_certificate_key /www/sites/<BLOG_DOMAIN>/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 600m;
client_body_timeout 600s;
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
proxy_request_buffering off;
real_ip_recursive on;
real_ip_header CF-Connecting-IP;
set_real_ip_from 127.0.0.1;
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
proxy_http_version 1.1;
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-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
location ~ ^/(\.user\.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README\.md) {
return 404;
}
location ^~ /.well-known/acme-challenge/ {
allow all;
root /usr/share/nginx/html;
}
location /ws/ {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_pass http://127.0.0.1:2333;
}
location /socket.io {
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_pass http://127.0.0.1:2333;
}
location /api/v3 {
proxy_pass http://127.0.0.1:2333;
}
location /render {
proxy_pass http://127.0.0.1:2333;
}
location /proxy {
proxy_pass http://127.0.0.1:2333;
}
location = /qaqdmin/ {
return 301 /qaqdmin;
}
location /qaqdmin {
proxy_pass http://127.0.0.1:2333/proxy/qaqdmin;
}
location / {
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
proxy_pass http://127.0.0.1:2323;
}
error_page 497 https://$host$request_uri;
}
安装配置:
CONF=/opt/1panel/www/conf.d/<BLOG_DOMAIN>.conf
cp "$CONF" "$CONF.before-mix-space-$(date +%Y%m%d%H%M%S)"
# 上传或通过 apply_patch 生成新配置
docker exec openresty nginx -t
docker exec openresty nginx -s reload
这一步做完,即便后端还没初始化,也应该满足:
curl -I http://<BLOG_DOMAIN>/api/v3/ping
curl -fsS https://<BLOG_DOMAIN>/api/v3/ping
curl -fsS -o /dev/null -w '%{http_code}\n' https://<BLOG_DOMAIN>/qaqdmin
根路径此时可能是
502,因为 Yohaku 还没启动——这是阶段性的正常状态,不代表 Core 出了问题。
阶段 D:初始化 Mix Space
这一步有个容易踩的坑:/api/v3/init/* 系列接口只在创建 owner 之前可用,一旦 owner 建好就会被禁用。所以顺序必须是"先配置,后建号"。
URL、SEO 和上传限制
BASE="https://$DOMAIN"
jq -n --arg base "$BASE" '{
ws_url: $base,
web_url: $base,
admin_url: ($base + "/qaqdmin"),
server_url: ($base + "/api/v3")
}' >url.json
curl -fsS -X PATCH -H 'Content-Type: application/json' \
--data-binary @url.json \
"$BASE/api/v3/init/configs/url"
jq -n --arg title "$OWNER_NICKNAME" --arg desc "$OWNER_INTRODUCTION" \
'{title:$title,description:$desc}' >seo.json
curl -fsS -X PATCH -H 'Content-Type: application/json' \
--data-binary @seo.json \
"$BASE/api/v3/init/configs/seo"
jq -n --argjson size "$UPLOAD_LIMIT_MB" '{video_max_size:$size}' >upload.json
curl -fsS -X PATCH -H 'Content-Type: application/json' \
--data-binary @upload.json \
"$BASE/api/v3/init/configs/fileUploadOptions"
有个小细节:URL 路径里的配置 key 是内部名
fileUploadOptions,不是下划线写法的file_upload_options;但请求体字段用的仍是 API 原本的video_max_size。
创建 owner
jq -n \
--arg username "$OWNER_USERNAME" \
--arg password "$OWNER_PASSWORD" \
--arg name "$OWNER_NICKNAME" \
--arg mail "$OWNER_EMAIL" \
--arg url "$BASE" \
--arg introduce "$OWNER_INTRODUCTION" \
'{username:$username,password:$password,name:$name,mail:$mail,url:$url,introduce:$introduce}' \
>owner.json
curl -fsS -X POST -H 'Content-Type: application/json' \
--data-binary @owner.json \
"$BASE/api/v3/init/owner"
登录并保留临时 Cookie
jq -n --arg username "$OWNER_USERNAME" --arg password "$OWNER_PASSWORD" \
'{username:$username,password:$password}' >login.json
curl -fsS -c cookie.txt \
-H "Origin: $BASE" \
-H 'User-Agent: Mozilla/5.0' \
-H 'Content-Type: application/json' \
--data-binary @login.json \
"$BASE/api/v3/auth/sign-in/username"
curl -fsS -b cookie.txt \
-H "Origin: $BASE" -H 'User-Agent: Mozilla/5.0' \
"$BASE/api/v3/auth/session"
Yohaku 配置片段与初始公开笔记
创建公开、启用的 JSON snippet:
jq -n '{path:"theme/yohaku",raw:"{}",type:"json",enable:true,private:false}' >snippet.json
curl -fsS -b cookie.txt -H "Origin: $BASE" -H 'User-Agent: Mozilla/5.0' \
-H 'Content-Type: application/json' --data-binary @snippet.json \
"$BASE/api/v3/snippets"
全新空站点在 Yohaku 聚合查询里可能返回 404。如果 /api/v3/aggregate?theme=yohaku%7Cshiro 是 404,创建一条公开笔记就能解决:
jq -n --arg title "$BOOTSTRAP_TITLE" --arg slug "$BOOTSTRAP_SLUG" \
'{title:$title,slug:$slug,text:"这里是新的开始。"}' >note.json
curl -fsS -b cookie.txt -H "Origin: $BASE" -H 'User-Agent: Mozilla/5.0' \
-H 'Content-Type: application/json' --data-binary @note.json \
"$BASE/api/v3/notes"
验收:
curl -fsS "$BASE/api/v3/aggregate?theme=yohaku%7Cshiro" | jq .
curl -fsS -b cookie.txt -H "Origin: $BASE" -H 'User-Agent: Mozilla/5.0' \
"$BASE/api/v3/options/url" | jq .
curl -fsS -b cookie.txt -H "Origin: $BASE" -H 'User-Agent: Mozilla/5.0' \
"$BASE/api/v3/options/fileUploadOptions" | jq .
阶段 E:用 GitHub Actions 构建官方 Yohaku
前面说过,Yohaku 的源码不在服务器上编译,全部交给 GitHub Actions。这一节配置的是"上游更新 → CI 构建 → 发布到服务器"这条流水线。
运行时环境文件
/opt/yohaku/.env:
NODE_ENV=production
HOSTNAME=0.0.0.0
PORT=2323
BASE_URL=https://<BLOG_DOMAIN>
API_URL=https://<BLOG_DOMAIN>/api/v3
NEXT_PUBLIC_API_URL=https://<BLOG_DOMAIN>/api/v3
NEXT_PUBLIC_GATEWAY_URL=https://<BLOG_DOMAIN>
NEXT_PUBLIC_ADMIN_URL=https://<BLOG_DOMAIN>/qaqdmin
WEBHOOK_SECRET=<RANDOM_64_HEX>
install -d -m 700 /opt/yohaku
install -d /opt/yohaku/releases /opt/yohaku/.cache
chmod 600 /opt/yohaku/.env
部署仓库需要满足的约定
env:
SOURCE_REPO: innei-dev/Yohaku
BUILD_COMMAND: pnpm --filter @yohaku/web build:ci
STANDALONE_SUBPATH: standalone/apps/web
DEPLOY_BASE_DIR: /opt/yohaku
工作流需要做到:
- 支持
workflow_dispatch手动触发。 - 用有权限读取官方 Yohaku 的
GH_PATcheckout 源码和 submodule/LFS。 - 用官方 build 命令生成 Next standalone 产物。
- 上传构建 artifact,再从部署 job 发送到服务器。
- 每次发布到
/opt/yohaku/releases/<run_number>。 - 创建相对软链接,保证
/opt/yohaku挂载到容器/app后仍能解析。 - 保留最近 5 个 release,方便回滚。
- 发布完成后重启 1Panel 的
yohaku容器。
有个小补丁值得一提:官方 Yohaku 的 adminUrlAtom 默认是 null,聚合公开数据也可能不返回 admin_url,导致后台入口点不进去。构建前需要打个小补丁,让它读取环境变量:
perl -0pi -e \
's#atom<string \| null>\(null\)#atom<string | null>(process.env.NEXT_PUBLIC_ADMIN_URL ?? null)#' \
apps/web/src/atoms/url.ts
grep -F 'NEXT_PUBLIC_ADMIN_URL' apps/web/src/atoms/url.ts
如果上游后来原生支持了这个环境变量,补丁会匹配不到——这时候先去看源码确认,别不管三七二十一硬套或者重复打补丁。
GitHub Actions Secrets
| Secret | 值 |
|---|---|
HOST | 服务器 IP |
USER | SSH 用户 |
PORT | SSH 端口 |
PASSWORD | SSH 密码;用密钥登录时要改造 workflow |
GH_PAT | 可读取 innei-dev/Yohaku 的 PAT |
BASE_URL | https://<BLOG_DOMAIN> |
NEXT_PUBLIC_API_URL | https://<BLOG_DOMAIN>/api/v3 |
NEXT_PUBLIC_GATEWAY_URL | https://<BLOG_DOMAIN> |
NEXT_PUBLIC_ADMIN_URL | https://<BLOG_DOMAIN>/qaqdmin |
AFTER_DEPLOY_SCRIPT | 通过 SSH 执行 docker restart yohaku >/dev/null 的完整命令 |
用 stdin 设置 secret,避免值出现在命令输出里:
printf '%s' "$VALUE" | gh secret set SECRET_NAME --repo "$GITHUB_DEPLOY_REPO"
触发并等待结果:
gh workflow run deploy.yml --repo "$GITHUB_DEPLOY_REPO"
gh run list --repo "$GITHUB_DEPLOY_REPO" --limit 5
gh run watch <RUN_ID> --repo "$GITHUB_DEPLOY_REPO" --exit-status
验收标准是 Prepare、Check、Build、Deploy、Store 全部 success,且服务器上出现:
/opt/yohaku/current -> releases/<run_number>/standalone/apps/web
/opt/yohaku/server.js -> current/server.js
阶段 F:1Panel Node 运行环境(这里有个大坑)
这是整个部署过程中最容易卡壳的一步,值得单独展开说。
创建 Node 运行环境
网站 -> 运行环境 -> Node.js -> 创建:
名称: yohaku
Node.js: 最新稳定 Node 22
项目目录: /opt/yohaku
自定义启动命令: node /app/server.js
容器名称: yohaku
安装 node_modules: 关闭
外部映射端口: 2323
应用端口: 2323
端口外部访问: 关闭
最终映射: 127.0.0.1:2323 -> 2323
坑在哪:环境变量没有真正导出
1Panel 2.2.5 生成的 /opt/1panel/runtime/node/yohaku/run.sh 只执行了一行:
source /.env
这行代码会在 shell 里创建变量,但不会把它们导出给后面启动的 node 子进程。表现出来的症状是:Yohaku 容器状态是 running,日志显示实际监听的是默认端口 3000,而 1Panel 映射的是 2323 -> 2323,本机访问 127.0.0.1:2323 直接 connection reset。
修复很简单,加两行 set -a / set +a 把变量导出:
set -a
source /.env
set +a
同时把 /opt/yohaku/.env 里的应用变量合并进 1Panel runtime 的 .env,权限改成 600。合并时要先去重,避免同一个 key 出现两次:
RUNTIME_DIR=/opt/1panel/runtime/node/yohaku
APP_ENV=/opt/yohaku/.env
RUNTIME_ENV="$RUNTIME_DIR/.env"
cp "$RUNTIME_ENV" "$RUNTIME_ENV.before-app-env-$(date +%Y%m%d%H%M%S)"
chmod 600 "$RUNTIME_ENV" "$RUNTIME_ENV".before-app-env-*
cp "$RUNTIME_DIR/run.sh" "$RUNTIME_DIR/run.sh.before-env-export-$(date +%Y%m%d%H%M%S)"
# 用 apply_patch 在 source /.env 前后加入 set -a / set +a
bash -n "$RUNTIME_DIR/run.sh"
TMP_ENV="$(mktemp "$RUNTIME_DIR/.env.merge.XXXXXX")"
awk -F= '
NR == FNR {
if ($0 ~ /^[A-Za-z_][A-Za-z0-9_]*=/) app_key[$1] = 1
next
}
$0 ~ /^[A-Za-z_][A-Za-z0-9_]*=/ && ($1 in app_key) { next }
{ print }
' "$APP_ENV" "$RUNTIME_ENV" >"$TMP_ENV"
printf '\n' >>"$TMP_ENV"
cat "$APP_ENV" >>"$TMP_ENV"
install -m 600 "$TMP_ENV" "$RUNTIME_ENV"
unlink "$TMP_ENV"
cd "$RUNTIME_DIR"
docker compose up -d --force-recreate
这里有个反直觉的地方:Yohaku release 里那个
.env软链接本身解决不了问题,Next standalone 不会自动把它变成已导出的进程环境变量。而且每次在 1Panel 面板里编辑这个 runtime,1Panel 可能会重新生成run.sh和.env,把你的补丁冲掉——所以每次编辑后都要重新核对一遍。
验收
docker ps --filter name='^/yohaku$'
docker logs --tail 100 yohaku
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:2323/
ss -ltn | grep '127.0.0.1:2323'
日志必须显示 Next 监听在 2323,本机 HTTP 返回 200,这一步才算真正过关。
阶段 G:Yohaku Webhook 与缓存
Yohaku 会缓存页面数据,改了头像、站点名之后页面不会立刻刷新,需要靠 Webhook 主动通知它失效缓存。
用 /opt/yohaku/.env 里同一个 WEBHOOK_SECRET,不要重新生成:
jq -n --arg secret "$WEBHOOK_SECRET" --arg base "$BASE" '{
payloadUrl:($base + "/api/webhook"),
events:["all"],
enabled:true,
secret:$secret,
scope:7
}' >webhook.json
curl -fsS -o webhook-response.json -w '%{http_code}\n' \
-b cookie.txt -H "Origin: $BASE" -H 'User-Agent: Mozilla/5.0' \
-H 'Content-Type: application/json' --data-binary @webhook.json \
"$BASE/api/v3/webhooks"
当前 Core 创建 webhook 成功时可能只返回 204 空响应,别以为失败了,接着查一下:
curl -fsS -b cookie.txt -H "Origin: $BASE" -H 'User-Agent: Mozilla/5.0' \
"$BASE/api/v3/webhooks" | jq .
健康检查事件应该满足:
event: health_check
status: 200
success: true
如果头像、站点名或主题配置在前端一直不更新,按这个顺序排查:
/api/v3/aggregate?theme=yohaku%7Cshiro是否已经返回新值。- webhook 是否启用、endpoint 是否为
/api/webhook、最近事件是否200。 /opt/yohaku/.env与 Core 里的 webhook secret 是否一致。- 重启
yohaku容器,再检查页面。 - 实在不行才清理
/opt/yohaku/.cache——别先清空 Redis 或数据库,这两个跟缓存刷新没关系。
最终验收:六个维度逐一过一遍
部署跑完不代表就结束了,下面这套验收清单是我踩坑之后总结出来的——很多"看起来正常"的部署,其实是漏了某一项。
1. 服务与端口
cd /opt/mix-space && docker compose ps -a
for c in mx-server mx-postgres mx-redis yohaku openresty; do
docker inspect "$c" --format '{{.Name}} {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}'
done
ss -ltn
docker exec openresty nginx -t
期望:mx-server、mx-postgres、mx-redis 为 healthy;mx-migrate 退出码为 0;yohaku 和 openresty 为 running;只暴露 127.0.0.1:2323 和 127.0.0.1:2333,不该出现 0.0.0.0:2323/2333;80/443 由 OpenResty 监听。
2. 公网 HTTP
| URL | 预期 |
|---|---|
http://<domain>/ | 301 到 HTTPS |
https://<domain>/ | 200,包含站点名 |
https://<domain>/api/v3/ping | 200,pong |
https://<domain>/api/v3/aggregate?theme=yohaku%7Cshiro | 200 |
https://<domain>/qaqdmin | 200 |
https://<domain>/qaqdmin/ | 301 到 /qaqdmin |
/friends、/projects、/says、/thinking、/timeline | 200 |
/notes/1 | 可能 308 到日期/slug 正规地址,跟随后为 200 |
光看管理后台 HTML 返回 200 还不够保险,最好再抽取一个 JS 资源验证也是 200,避免看到的其实是空壳页面。
3. WebSocket
curl --http1.1 -sS -D ws-headers.txt -o /dev/null --max-time 3 \
-H 'Connection: Upgrade' \
-H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: SGVsbG9NaXhTcGFjZTEyMw==' \
"https://$DOMAIN/ws/web" || true
grep '101 Switching Protocols' ws-headers.txt
命令超时是因为连接已经升级并保持打开状态,属于正常现象;真正的判断依据是有没有出现
101 Switching Protocols。
4. 证书与上传限制
openssl x509 -in /opt/1panel/www/sites/<domain>/ssl/fullchain.pem \
-noout -subject -issuer -dates -ext subjectAltName
grep -n 'client_max_body_size 600m' /opt/1panel/www/conf.d/<domain>.conf
再通过登录后的 API 验证:
curl -fsS -b cookie.txt -H "Origin: $BASE" -H 'User-Agent: Mozilla/5.0' \
"$BASE/api/v3/options/fileUploadOptions" | jq '.data.video_max_size'
期望是 600。上传限制这里要注意,OpenResty 的 600m 和 Core 的 600 MB 两层必须同时满足,只改一层照样会上传失败。
5. 账号与 GitHub Actions
- 用新 Cookie 登录返回
200。 - session 里的 username、name、role 分别与输入一致,role 为
owner。 - webhook 数量为 1,health check 是
200/success=true。 - GitHub Actions 最新一次部署的 jobs 全部
success。 /opt/yohaku/current、server.js、release.env和.next/cache链接在容器挂载路径里都能正常解析。
6. 用浏览器再走一遍
用已登录的 Chrome:打开首页确认昵称和初始笔记出现;打开"更多"里的友链、项目、一言页面;打开 /qaqdmin 确认后台资源能加载;控制台不应该有持续的 404/500 或静态资源加载失败。
(小提示:Yohaku 左上角头像单击一般返回首页,owner 已登录时要双击头像才进后台——但更稳妥的入口始终是直接访问 /qaqdmin。)
常见问题
部署过程中遇到的坑,整理成 FAQ 更方便查——遇到类似症状直接搜关键词就行。
Q:API 到底该用 /api/v2 还是 /api/v3?
旧教程或旧 Agent Skill 里的信息可能已经过时了。以官方 Compose 的健康检查和实际跑起来的 Core 为准——当前版本用的是 /api/v3。
Q:日志里出现 mxspace-new-migrate,是要迁移旧站数据吗?
不是。这是把一次性数据库迁移服务误认成了站点迁移任务。全新部署只会用到官方 mx-migrate,它跑完退出码是 0 就是正常状态。
Q:为什么之前的方案要自制 mx-server-upload-100m 镜像?
那是老方案为了绕过上传限制才自制的镜像,不推荐。改回官方 innei/mx-server:latest,上传限制通过 Core 的配置项 + Nginx 的 client_max_body_size 两边一起设置就够了。
Q:Nginx 报 location directive is not allowed here?
大概率是把 location 片段粘进了另一个 location 里,或者粘进了错误的 1Panel 配置输入框。解决办法是直接编辑完整的网站配置文件,确保 location 只出现在 server 块里。
Q:Cloudflare 返回 525 怎么办?
说明源站网站没绑定证书、证书文件缺失,或者 OpenResty 没加载成功。先在 1Panel 里修好 HTTPS 记录和证书文件,nginx -t 通过之后再 reload。
Q:1Panel 建站时证书下拉框显示"无数据"? 证书记录其实存在,只是建站对话框没加载出来。先建一个 HTTP 站点,再进去绑定证书;还是不行的话,走前面「证书:如果下拉框无数据」那套数据库修复流程。
Q:Yohaku 容器状态是 running,但访问 127.0.0.1:2323 却 connection reset?
这就是前面「阶段 F」里讲的那个大坑——Next 实际监听在默认的 3000 端口,因为 1Panel 的 source /.env 没有真正导出变量。按阶段 F 的修复方法合并环境变量、给 run.sh 加上 set -a/source/set +a,然后重建容器。
Q:根路径访问是 502,但 API 是正常的?
说明前端 release 或者 Node runtime 还没启动。等 GitHub Actions 和 1Panel runtime 都跑完了,再来验收根路径。
Q:页面提示 error.api_fetchError 或 Not found?
可能是 API URL 还在用 v2、反向代理漏掉了 /api/v3,或者空站点的 aggregate 接口返回了 404。分别检查并改成 v3、修正路由、创建一条公开的初始笔记就能解决。
Q:改了头像或站点名,前端页面一直不刷新?
Yohaku 有缓存机制,没被 Webhook 通知失效就不会更新。配置好同一个 secret 的 /api/webhook,确认 health check 是 200,然后重启前端。
Q:点击头像进不了后台?
Yohaku 的单击/双击行为和 admin URL 的注入方式共同决定了这个交互。构建时注入 NEXT_PUBLIC_ADMIN_URL,并且始终用稳定地址 /qaqdmin 作为后台入口就不会有问题。
Q:友链、项目这类页面打不开? 通常是这些普通页面被错误转发去了 Core,或者前端根本没启动。检查路由配置,确保只有明确列出的后端路径转发到 2333,其余统一转发到 2323,然后逐页验证是不是 200。
Q:/notes/1 返回 308?
这是正常行为,Yohaku 会把它重定向到带日期和 slug 的正规地址。用 curl -L 跟着跳转验证最终是不是 200 就行。
Q:GitHub Actions push 之后没有立即触发构建?
可能是 build_hash 和上游相同,或者更新提交被 workflow 的判断逻辑跳过了。用 workflow_dispatch 手动触发一次,顺便检查 Check job 有没有被 canceled。
Q:Actions 已经发布成功,前端却还是旧版本?
说明只切换了 release 软链接,没有重启常驻的 Node 进程。给 workflow 配上 AFTER_DEPLOY_SCRIPT,让它在发布后执行 docker restart yohaku。
Q:为什么修好之后过几天又复发了?
大概率是又在 1Panel 面板里编辑过这个 runtime,1Panel 重新生成了 run.sh 或 .env,把之前的补丁覆盖掉了。养成习惯:每次编辑 runtime 之后,重新检查一遍导出补丁、环境变量、文件权限,以及 2323 端口有没有正常监听。
备份与回滚
后端
重大更新前先备份:
cd /opt/mix-space
docker exec mx-postgres pg_dump -U mx -d mx_core -Fc >"backup-$(date +%Y%m%d%H%M%S).dump"
cp docker-compose.yml "docker-compose.yml.backup-$(date +%Y%m%d%H%M%S)"
cp .env ".env.backup-$(date +%Y%m%d%H%M%S)"
chmod 600 .env.backup-*
千万别执行
docker compose down -v,那会把数据卷里的持久数据一起删掉。
OpenResty 与 1Panel
- 每次替换网站配置前先复制一份备份。
- 修改 1Panel 数据库前用 SQLite 的
.backup,别只复制主 DB 而漏掉 WAL 文件。 - 回滚之后先
nginx -t,测试通过再 reload。
Yohaku
发布流水线会保留最近 5 个 release,回滚只需要切换软链接、重启容器:
cd /opt/yohaku
ln -sfn "releases/<OLD_RUN>/standalone/apps/web" current
ln -sfn "current/server.js" server.js
docker restart yohaku
curl -fsS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:2323/
清理清单
部署成功之后,记得删掉这些临时文件:本机临时的后端 .env、本机临时的 Yohaku .env、登录 Cookie 文件、包含 owner 密码的 JSON、包含 webhook secret 的 JSON、临时下载的 1Panel 数据库/WAL/SHM、/tmp/yohaku-upload-* 发布分片、临时的证书私钥副本。
服务器上则要保留下面这几个文件,并且权限设为 600:
/opt/mix-space/.env
/opt/yohaku/.env
/opt/1panel/runtime/node/yohaku/.env
/opt/1panel/www/sites/<domain>/ssl/privkey.pem
交付内容
部署收尾时,建议至少确认并记录这些信息:博客首页和后台 URL、owner 用户名和昵称(密码只需要确认"已按要求设置",不要写出来)、后端和前端的安装目录、1Panel runtime 名称和 Node 版本、上传限制两层的验证结果、容器健康状态、迁移退出码、Nginx 检查结果、HTTPS/SAN/自动续期状态、WebSocket 101 和 webhook health check 200 的证据、GitHub Actions run 的 URL 和结论、是否创建了初始公开笔记,以及任何还没完成的事项和潜在风险。
一次性提示词模板
这是我实际发给 Codex 用来触发整个流程的指令,复制、填好第二节的 YAML 参数、发送即可:
请读取并严格执行以下文档:
<粘贴本文完整内容,或指向已保存的文档路径>
这是一次 fresh 全新安装,不迁移旧服务器数据。先读取文档列出的官方资料并做版本漂移检查,再按阶段执行;不要删除任何无法确认归属的资源。每个阶段通过验收门后再继续,最终完成整体验证和临时密钥清理。
部署参数如下:
<粘贴填好的 YAML 参数,但不要把密码写进持久文件>
自动化边界
在这些外部前提都满足的情况下,Codex 理论上可以按本文自动完成部署:DNS 已指向源站、SSH 可连接、1Panel 可访问、证书已签发或 ACME 可用、GitHub 身份能访问官方 Yohaku、服务器资源充足。
但它没法在这些情况下保证继续下去:未知的 1Panel 数据库结构、Mix Space / Yohaku 上游出现重大改版、DNS / Cloudflare 配置有误、GitHub 权限失效、证书签发失败。这些情况下,我更希望 Codex 停在明确的验收门,把现场保留下来,报告实际卡在哪一步——比起盲目往下覆盖,这样更适合一份需要反复执行的部署手册。