前端部署与 nginx
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
Wujies 无界平台的宿主前端是一个 Vue 3 + Vite 单页应用(仓库 plus-ui),产物是一组静态文件,由 nginx 托管。同一个 nginx 还要做三件额外的事:反代后端接口(/prod-api/)、提供运行期落位的远程应用产物(/remote-apps/)、反代对象存储(/minio/)。
本页解决三件事:
- 宿主前端用哪种模式构建、产物落在哪个目录;
- 远程应用产物的物理路径在「本机直跑」与「容器」两种形态下分别是什么,怎么和后端
app-market.deploy.frontend-dir、nginx 对齐 —— 这是这一章最容易出错的一环; deploy/docker/frontend/nginx.conf逐段在做什么。
远程应用为什么必须是独立 ESM 产物、宿主怎么在运行期加载它,见 /advanced/remote-app;插件前端工程怎么构建、产物目录名有什么约定,见 /plugin-dev/frontend/。
命名先对齐:三个 wujies-admin、两个 app-market-dist
wujies-admin 这个名字在三处含义不同,凡是路径都要先看清是哪一个:
| 写法 | 是什么 | 判据 |
|---|---|---|
plus-ui/ | 宿主前端仓库(Vue 3 / Vite) | 有 vite.config.ts / src/;它的 package.json 里 name 写的是 wujies-admin,这就是混淆来源 |
wujies-platform/wujies-admin/ | 后端启动模块(Java) | 有 pom.xml / src/main;产物是 wujies-admin.jar |
工作区顶层 wujies-admin/ | 只有 app-market-dist/ —— 前端产物的落位目录 | 里面没有 package.json、没有 src/ |
wujies/wujies-plugins/frontend/<code>-app/ | 远程应用前端工程(源码,pnpm workspace) | 构建配置走 frontend/build/vite.ts 的 defineRemoteApp |
- 后端落位写文件的位置是工作区顶层的
wujies-admin/app-market-dist/remote-apps/(application.yml里frontend-dir的当前值); - 浏览器真正取到 JS 的位置是
plus-ui/app-market-dist/remote-apps/(plus-ui/vite/plugins/appMarketStatic.ts的sourceDir = <plus-ui 根>/app-market-dist)。
两者是独立的物理目录(都不是 junction / symlink),所以「后端落位成功」与「页面能打开」可能对不上。逐目录的实测对照见 /guide/structure。
三种构建模式
| 模式 | 命令(在 plus-ui 下) | base | API 前缀 | 产物目录 | 用途 |
|---|---|---|---|---|---|
| 生产 | npm run build:prod | .env.production 的 VITE_APP_CONTEXT_PATH = /admin/ | .env.production 的 VITE_APP_BASE_API(指向某套线上环境的绝对地址) | plus-ui/dist | 部署在 /admin/ 子路径下、接口在另一个域名的传统部署 |
| 开发构建 | npm run build:dev | .env.development 的 VITE_APP_CONTEXT_PATH = / | /dev-api | plus-ui/dist | 本地联调产物,接口走 dev server 的代理 |
| Docker / 1Panel | vite build --mode docker(package.json 里没有对应的 npm script) | .env.docker 的 VITE_APP_CONTEXT_PATH = / | /prod-api(相对路径) | plus-ui/dist | 容器部署:一个镜像跑在任何域名下 |
三种模式都靠 mode 决定加载哪个 .env.* 文件,plus-ui/vite.config.ts 里 base: env.VITE_APP_CONTEXT_PATH;dev server 的接口代理目标是 http://127.0.0.1:8086(server.port 的默认值,见 wujies-admin/src/main/resources/application.yml)。build-1panel.ps1 用的是第三种:
# build-1panel.ps1 内部(Push-Location 到 plus-ui 之后)
& node 'scripts/gen-runtime-shims.mjs'
& node 'node_modules/vite/bin/vite.js' build --mode docker-SkipFrontend 会跳过这两条命令,复用已有 dist/;无论跳不跳,脚本都会检查 dist/index.html 是否存在,不存在直接抛错。
VITE_APP_BASE_API用相对路径/prod-api,由 nginx 反代到后端容器,不写死任何域名 / IP;VITE_APP_CONTEXT_PATH用/,容器自己就是站点根,不再挂/admin子路径。.env.production里的接口地址指向某套线上环境,绝不能进镜像 —— 这正是要单独有一个.env.docker的原因。
产物目录就是 Vite 默认的 dist
工程里没有配置 build.outDir(plus-ui/vite.config.ts 里没有这一项),所以宿主前端的产物目录是 Vite 的默认值 plus-ui/dist。
plus-ui/vite/plugins/appMarketStatic.ts 在 writeBundle 阶段会把整棵 plus-ui/app-market-dist/(app-runtime/ 垫片 + remote-apps/ 各应用)复制进 dist/。所以 dist/remote-apps/ 里是构建那一刻的产物快照,随后 build-1panel.ps1 又把整棵 dist/ 拷进前端镜像的构建上下文 —— 镜像里因此也有一份 remote-apps/。这一点在下面的「坑 3」里会用到。
远程应用工程自己的产物目录由共享工厂决定,不是各工程手写 outDir(wujies/wujies-plugins/frontend/build/vite.ts 的 resolveOutDir):
- 设了环境变量
WUJIES_FRONTEND_DIST→ 产物落到<该变量>/<appCode>/; - 没设 → 落到该工程自己的
dist/(自包含,可单独验证)。
目录名必须等于应用编码 appCode,这是四处共同的约定:vite 插件的 appCode、wujies/wujies-plugins/package.ps1 的 -FrontendDist 默认值、后端落位目录、GET /app/runtime/remote-apps 拼出的 URL。细节见 /plugin-dev/frontend/。
/remote-apps/ 的物理路径:谁写、谁读
远程应用的入口 URL 形如 /remote-apps/<appCode>/index.js?v=<版本号>,由 AppRuntimeController 拼出。这个前缀在三处约定,两处环境下的物理路径不同:
| 环境 | 后端写到哪 | 谁从哪读 | 对齐情况 |
|---|---|---|---|
本机直跑(后端工作目录 wujies-platform/) | ../wujies-admin/app-market-dist/remote-apps/<appCode>/,即工作区顶层那个 wujies-admin/ | plus-ui dev server 读 plus-ui/app-market-dist/remote-apps/<appCode>/ | 两个目录,直跑时可能「落位成功但页面打不开」 |
| 容器(1Panel / 手工 Docker) | /app/remote-apps/<appCode>/ | nginx 容器读 /var/www/remote-apps/<appCode>/ | 同一个宿主目录 ./data/remote-apps,一致 |
直跑:yml 的当前值与注解兜底值并不一样
# wujies-admin/src/main/resources/application.yml
app-market:
deploy:
# 前端远程应用落位目录,必须与宿主 /remote-apps/ 的物理路径一致
frontend-dir: ../wujies-admin/app-market-dist/remote-apps⚠️ 这是当前真实值,它指向工作区顶层那个只装 app-market-dist/ 的 wujies-admin/。而 Java 注解里的兜底默认值是 ../plus-ui/app-market-dist/remote-apps(AppPackageDeployService、PluginAppInstaller 的 @Value)—— 两者不一致,不要按兜底值理解实际行为。
想让直跑时的落位目录与 dev server 读的目录合一,有两条路:
- 把
frontend-dir改成../plus-ui/app-market-dist/remote-apps—— 属于改配置,改完必须重建wujies-admin.jar(yml 在 jar 里,改文件本身不生效); - 不动配置,让构建直接落到 dev server 读的那份(本地联调常用,见下)。
# 在插件仓库 wujies/wujies-plugins 下执行;路径按自己的仓库位置替换
$env:WUJIES_FRONTEND_DIST = '<宿主前端仓库根>\app-market-dist\remote-apps'
pnpm --filter ./frontend/exam-app run build # 产物直接落到 <该目录>/exam/工厂会自动追加 <appCode> 子目录,所以这一次构建同时满足了「目录名 = appCode」和「落到 dev server 读的目录」。不设这个变量时产物落在插件工程自己的 dist/,dev server 取不到,控制台会报 Failed to fetch dynamically imported module: /remote-apps/<code>/index.js。
容器:靠共享卷把两边接起来
1Panel 应用包内的 docker-compose.yml 用两行挂载把后端与 nginx 接到同一个宿主目录:
# 后端容器:读写同一个宿主目录(compose 里还有 APP_MARKET_DEPLOY_FRONTEND_DIR: /app/remote-apps)
volumes:
- ./data/remote-apps:/app/remote-apps
# 前端容器:只读同一个宿主目录
volumes:
- ./data/remote-apps:/var/www/remote-apps:ro也就是:后端读写、nginx 只读,同一个卷。./data/remote-apps 在实例目录下(1Panel 的实例根是 /opt/1panel/apps/local/<包名>/<实例名>/),卷没挂上时产物只存在于容器文件系统里,容器一重建就丢。
容器里覆盖 frontend-dir 的是环境变量 APP_MARKET_DEPLOY_FRONTEND_DIR=/app/remote-apps(Spring 会把 APP_MARKET_DEPLOY_FRONTEND_DIR 换算成属性名 app-market.deploy.frontend-dir,优先级高于 jar 内 yml)。注意 entrypoint.sh 只显式传了 -Dapp-market.deploy.plugin-dir,没有传 frontend-dir。
| 错在哪 | 表现 |
|---|---|
frontend-dir 与 nginx 的 alias 指向不同目录 | 后端把产物写到了 A,nginx 从 B 找 → /remote-apps/<code>/index.js 404 |
frontend-dir 与容器实际挂载点不同 | 后端在容器文件系统里写了一个没挂出去的目录,容器重建即丢 |
目录名不等于 appCode | 产物在,但 URL 拼出来是 /remote-apps/<code>/index.js,try_files 直接 404 |
| 直跑:落位目录与 dev server 读的目录不是同一个 | 后端报告落位成功,浏览器报「远程视图不可用」 |
排查入口:浏览器控制台里那句 [app-market] 远程应用入口(后端):,加 Network 里那个 entry 请求的状态码。
deploy/docker/frontend/nginx.conf 全文
下面是与镜像构建上下文同目录的文件全文(deploy/docker/frontend/nginx.conf,逐字来自工程):
# ---------------------------------------------------------------------------
# 前端 nginx 配置(作为模板,容器启动时由官方 entrypoint 做 envsubst)
#
# 只替换**环境里真实存在**的变量(nginx 的 $host/$uri 等不受影响):
# ${BACKEND_HOST} / ${BACKEND_PORT} → 后端容器(compose 里给的是 ${CONTAINER_NAME}-backend,
# 这样同一张 1panel-network 上装两套(服务端+客户端)也不会串)
# ${MINIO_HOST} / ${MINIO_PORT} → MinIO(对象存储,安装包/图片)
# ---------------------------------------------------------------------------
server {
listen 80;
server_name _;
client_max_body_size 1000m;
charset utf-8;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_min_length 1k;
gzip_types text/plain text/css application/json application/javascript text/javascript application/xml image/svg+xml;
# 后端接口:/prod-api/xxx → http://backend:8080/xxx
# (与 .env.docker 的 VITE_APP_BASE_API=/prod-api 对应;末尾斜杠保证前缀被剥掉)
location /prod-api/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT}/;
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;
# SSE(消息推送)必须关缓冲、拉长超时,否则前端收不到事件
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# 远程应用前端产物:后端把插件前端释放到共享卷,这里按稳定 URL 提供
# (应用市场运行期按 /remote-apps/<appCode>/index.js?v=<版本> 动态 import)
# ⚠️ `^~` 不可省:末尾那条正则段会抢在普通前缀之前命中(见下面「坑 3」)
location ^~ /remote-apps/ {
alias /var/www/remote-apps/;
add_header Cache-Control "no-cache";
try_files $uri =404;
}
# 对象存储(MinIO)反代:这样 sys_oss_config.domain 可以写成相对路径 /minio,
# 不用把 MinIO 端口暴露到公网、也不用写死域名/IP
location /minio/ {
proxy_pass http://${MINIO_HOST}:${MINIO_PORT}/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# 本地文件存储(sys_oss_config 里选 local 时)——留着备用
location /upload/ {
alias /var/www/upload/;
}
# 前端静态资源:带 hash 的资源长缓存,index.html 不缓存
location ~* \.(js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|eot|ico|webp|map)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
try_files $uri =404;
}
# SPA 回退:其余路径交给前端路由(history 模式)
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}逐段解释
| 段落 | 作用 | 注意 |
|---|---|---|
| 文件头注释 | 声明这是一个模板,容器启动时由官方 nginx 镜像的 entrypoint 做 envsubst | 只有「环境里真实存在」的变量会被替换,$host / $uri 这类 nginx 自己的变量不受影响 |
listen 80 / server_name _ | 容器内单站点,不绑定具体域名 | 一个镜像跑在任何域名下 |
client_max_body_size 1000m | 允许上传大文件(应用包、图片) | 调小会让上架「上传安装包」失败 |
gzip on | 压缩文本类响应 | Docker 模式的构建产物不做预压缩(.env.docker 的 VITE_BUILD_COMPRESS = none),交给 nginx |
location /prod-api/ | 把 /prod-api/xxx 反代到后端的 /xxx | proxy_pass 末尾的 / 保证前缀被剥掉;少了它后端会收到 /prod-api/xxx 而 404 |
proxy_buffering off + 3600s 超时 | SSE 消息推送必需 | 见下节 |
location ^~ /remote-apps/ | 提供运行期落位的远程应用产物 | 用 alias 不是 root;^~ 必需 —— 少了它就是普通前缀,会被末尾的正则段抢先命中,整条失效(见下面的坑 3) |
location /minio/ | 反代对象存储 | 这样 sys_oss_config.domain 可以写成相对路径,不用暴露 MinIO 端口 |
location /upload/ | 本地文件存储(sys_oss_config 选 local) | 对应 compose 里 ./data/upload:/var/www/upload:ro |
| 末尾的静态资源正则段 | 带 hash 的资源长缓存 7 天 | try_files $uri =404:找不到就 404,不要回退到 HTML |
location / | SPA 回退到 index.html(history 模式) | 否则刷新 /appmarket/list 会 404 |
四个必须知道的坑
1. 模板必须放 /etc/nginx/templates/*.template
deploy/docker/frontend/Dockerfile 里这几行是硬要求:
RUN mkdir -p /var/www/remote-apps /var/www/upload
COPY nginx.conf /etc/nginx/templates/default.conf.template
COPY dist/ /usr/share/nginx/html/官方 nginx 镜像的 20-envsubst-on-templates.sh 会把 templates/*.template 用 envsubst 渲染后落到 conf.d/。直接放 conf.d/ 的话 nginx 会把 ${BACKEND_HOST} 当成自己的变量,启动即报:
unknown "backend_host" variable容器起不来(实测踩到过)。
2. /remote-apps/ 用 alias,不是 root
location ^~ /remote-apps/ {
alias /var/www/remote-apps/; # ✅
}(^~ 是必需的,理由见坑 3;少了它这一条会整个失效。)
用 root 的话,请求 /remote-apps/exam/index.js 会被映射到 <root>/remote-apps/exam/index.js,而实际文件在 /var/www/remote-apps/exam/index.js —— 多了一层 remote-apps,全是 404。
3. 静态资源正则段会抢在 /remote-apps/ 之前
nginx 的匹配顺序是「= 精确 → ^~ 前缀 → 正则 → 普通前缀」。配置里 location /remote-apps/ 是普通前缀,而末尾那条 location ~* \.(js|css|png|…)$ 是正则段 —— 所以 /remote-apps/<appCode>/index.js、assets/*.css 这类请求由正则段接走,按 root /usr/share/nginx/html 去找文件,alias /var/www/remote-apps/ 根本不参与。两种表现:
- 构建镜像时
plus-ui/app-market-dist/里已经有这个应用(十个业务应用都在;demo-app是历史遗留的产物目录,已从应用目录去掉,不该再进快照。appMarketStatic.ts会把整棵app-market-dist/复制进dist/,build-1panel.ps1再拷进镜像)→ 命中的是跟随镜像烘进去的那份快照,运行期落位到共享卷的新版本读不到;顺带那条add_header Cache-Control "no-cache"也不生效,改用正则段的 7 天缓存。 - 构建镜像时没有它(例如以后装的第三方应用)→ 同一个 URL 命中的是正则段的
try_files $uri =404,浏览器拿到 404。
⚠️ 2026-10-03 线上实测复现(就是这一条打出来的故障):console.wujies.cn 报
TypeError: Failed to fetch dynamically imported module: https://console.wujies.cn/remote-apps/hello/index.js?v=1.0.0。
证据链:后端卷里 /app/remote-apps/hello/index.js 在(16721 B),nginx 容器也挂着同一个宿主目录
(./data/remote-apps:/var/www/remote-apps:ro),而 location /remote-apps/ 是普通前缀 ⇒ 请求被正则段接走、
按 root /usr/share/nginx/html 去找 ⇒ 404。同一份配置在一次性容器里对照复现:普通前缀 → HTTP/1.1 404;
改成 ^~ → HTTP/1.1 200 + Cache-Control: no-cache,且取到的是共享卷那份(而不是镜像里烘进去的快照)。
修法是给前缀 location 加 ^~(命中即停止,不再看正则),改完重建前端镜像:
location ^~ /remote-apps/ {
alias /var/www/remote-apps/;
add_header Cache-Control "no-cache";
try_files $uri =404;
}复核办法(在装了这套的主机上跑,不需要改任何东西):
# 容器名:前端 = 1Panel 实例名(主服务),后端 = <实例名>-backend
# 镜像里烘进去的那份 vs 卷里运行期落位的那份,比一下目录内容
docker exec <前端容器> ls /usr/share/nginx/html/remote-apps
docker exec <前端容器> ls /var/www/remote-apps
# 看这条请求实际带的是哪个缓存头:no-cache = 走了 /remote-apps/;max-age=604800 = 走了正则段
curl -sI http://<域名>/remote-apps/<code>/index.js | grep -i cache-control4. try_files $uri =404 意味着纯后端应用访问该路径会 404
/remote-apps/<code>/ 下没有产物时(某应用是纯后端应用,清单里没有 assets.frontend),请求这个路径会得到 404。这是正常现象,不是部署漏了文件 —— 那个应用只提供后端接口与菜单,不提供远程视图。宿主也不会把它列进 GET /app/runtime/remote-apps。
只有当「清单声明了 assets.frontend,但目录里确实没有文件」时,才说明落位没成功。
宿主机 nginx(非容器)
不用容器时,把 nginx 的站点根指向前端 dist,并自己保证下面这些(路径按自己的部署根替换):
server {
listen 80;
server_name _;
client_max_body_size 1000m;
charset utf-8;
root /srv/wujies/frontend; # plus-ui/dist 拷到这里
index index.html;
location /prod-api/ {
proxy_pass http://127.0.0.1:8086/; # 直跑后端的监听地址(application.yml 的 server.port)
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_buffering off;
proxy_read_timeout 3600s;
}
location ^~ /remote-apps/ {
# 必须与 app-market.deploy.frontend-dir 指向同一个物理目录
alias /srv/wujies/remote-apps/;
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
}三件事必须自己做:
- 后端配置要对齐:把
app-market.deploy.frontend-dir改成上面alias的那个目录(或者反过来,把 alias 指到frontend-dir)。改 yml 后要重建wujies-admin.jar,因为 yml 在 jar 里。 base必须与访问前缀一致:前端构建时base取自.env.*的VITE_APP_CONTEXT_PATH。站点跑在根路径就用/(即--mode docker);跑在/admin/子路径就用/admin/(即build:prod),同时 nginx 的location也要挂在/admin/下。构建时的 base 与实际的访问前缀不一致,表现是白屏 + 控制台里所有静态资源 404。- 用了
build:prod就顺带处理预压缩产物:VITE_BUILD_COMPRESS = gzip会额外产出.gz(不删原文件)。想用它们要在 nginx 里开gzip_static on;(需要编译进该模块);不打算用就什么也不用做。容器的 nginx 配置没有开它,因为镜像里的产物是--mode docker出来的、本来就没有.gz。
SSE 为什么要关 proxy_buffering
平台的消息推送走 SSE(VITE_APP_SSE = true)。SSE 是一条长连接、持续追加的响应流。nginx 默认开启 proxy_buffering,会把上游的响应攒在缓冲区里攒够一定体积再一起发给浏览器 —— 对普通请求是优化,对 SSE 就是「事件被攒住不发」。
表现是:连接建立成功(200),但前端一直收不到事件,直到缓冲被填满或连接结束才一次性收到。
所以 /prod-api/ 这段必须:
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s; # 默认 60s,长连接会被掐断
proxy_send_timeout 3600s;超时同样重要:默认 proxy_read_timeout 60 秒,没有事件推送时连接会被 nginx 主动关闭,前端表现为「每隔一分钟重连一次」。
相关阅读
- /deploy/ —— 部署总览与四类产物的清单
- /deploy/backend —— 后端 jar / 容器启动与卷挂载
- /deploy/configuration ——
app-market:配置项逐条说明 - /guide/structure —— 两个
app-market-dist的逐目录对照 - /reference/config ——
frontend-dir↔/remote-apps/的通路细节 - /advanced/remote-app —— 远程应用的运行期加载机制
- /plugin-dev/frontend/ —— 插件前端工程与产物目录约定
- /ops/troubleshooting —— 「远程视图不可用」的排查路径