Files
novalon-website/nginx-static-production.conf
T
zhangxiang a0328a623f chore(qa): 验收台账与证据入库 + 构建/部署配置同步
- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。
- 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。
- 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径;
  next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐
  standalone 产物装配与部署形态。
2026-09-28 10:48:09 +08:00

265 lines
12 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Novalon 生产环境 Nginx 配置(standalone 应用 + 边缘静态)
#
# 架构:
# - 页面、/api/*、/admin/* 一律由 Next.js standalone 运行时应答(`node dist/standalone/server.js`,
# 见 docker-compose.server.yml):ISR/SSR/Draft 需要运行时,静态 HTML 快路径已移除(见下)。
# - 静态资源(/_next/static、字体、图片、/uploads)由 Nginx 直接服务并长期缓存。
#
# 头部分工(唯一来源):
# - 文档级安全头(CSP / X-Frame-Options / X-Content-Type-Options / Referrer-Policy /
# Permissions-Policy / X-XSS-Protection / X-DNS-Prefetch-Control)只在
# next.config.mjs 的 headers() 中声明,随应用一起版本化,任何环境(含无 nginx 的
# standalone 直连)都能拿到同一份头部。
# - 本文件只保留边缘职责:TLS/301、HSTS(Next 不发送)、gzip、limit_req、
# /uploads/ 的落盘加固(磁盘直服,Next 不参与应答 ⇒ 该 location 必须自行声明
# nosniff 与 sandbox CSP,这不属于跨层重复)。
# - 为什么不能让两层都发:nginx add_header 是「追加」而非「替换」,上游同名头部不会被覆盖,
# 浏览器于是收到 `X-Frame-Options: DENY, SAMEORIGIN` 这类重复且互斥的非法值
# (ACCEPTANCE_REVIEW_2026-09-21 §5 记录的线上缺陷,亦是 test:security:headers 的告警来源)。
#
# 为什么移除页面静态 HTML 快路径(原 `location / { try_files $uri $uri.html $uri/index.html @nextjs; }`):
# 该分支服务的是 export 时代的预渲染副本(../novalon-static),standalone 下页面属运行时产物;
# 一旦命中磁盘,nginx 就成了唯一的头部来源,要么重新引入两层重复,要么让页面裸奔。
#
# 前置条件:
# - nextjs_app upstream 指向运行 standalone server.js 的 Next.js 服务。
# - 默认使用 docker-compose.server.yml 中的服务名 novalon-website:3000。
# - 验证:nginx -t && nginx -s reload
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
resolver 127.0.0.11 valid=30s ipv6=off;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
server_tokens off;
client_max_body_size 100M;
# http 级曾在此 add_header X-Frame-Options / X-XSS-Protection / X-Content-Type-Options /
# Referrer-Policy:与 next.config.mjs 同名 ⇒ 每个经应用应答的响应都会出现重复值。
# 应用级头部一律由 next.config.mjs 单点发出,此处不再设置。
limit_req_zone $binary_remote_addr zone=general:10m rate=100r/s;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
gzip on;
gzip_disable "msie6";
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+text text/javascript;
upstream gitea_app {
server gitea:3000;
}
upstream jenkins_app {
server jenkins:8080;
}
upstream novavis_authority {
server novavis-authority:8080;
}
# Next.js 混合渲染服务(SSR/ISR/API Routes)
# 必须与 docker-compose.server.yml 中 Next.js 服务名/端口一致
upstream nextjs_app {
server novalon-website:3000;
}
# WebSocket 连接升级映射(Next.js HMR / 实时功能使用)
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# 反向代理通用头(用于 /api/*、/admin/* 及 ISR 回源)
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-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# Novalon 主站 - 静态文件 + SSR/ISR 回源
server {
listen 80;
server_name novalon.cn www.novalon.cn;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://www.novalon.cn$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name novalon.cn www.novalon.cn;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# 边缘自有头部:Next 不发送 HSTS(它无法得知 TLS 终结点的协议与证书状态)。
# CSP / XFO / nosniff / Referrer-Policy / Permissions-Policy / X-XSS-Protection 由
# next.config.mjs 的 headers() 单点发出;nginx add_header 只追加不覆盖上游同名头,
# 两层各设一次即产生 "DENY, SAMEORIGIN" 这类非法重复值(验收 §5)。
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# 静态资源根目录:仅用于下面按文件类型/路径磁盘直服的 location;
# 页面不再从此目录取(见文件头的「为什么移除页面静态 HTML 快路径」)。
root /var/www/novalon;
index index.html;
# 静态资源长期缓存(哈希文件名,不可变)
# 磁盘副本只是加速:未命中时回源 @nextjs,由 standalone 服务自带的 dist/static 应答;
# 回源走 named location,本块的 add_header 不参与 ⇒ 不会与应用头部同名叠加。
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
# location 内含 add_header 即不继承 server 级头部 ⇒ HSTS 需在此重述
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri @nextjs;
}
# 字体文件缓存
location ~* \.(ttf|woff|woff2|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Access-Control-Allow-Origin "*";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
# 图片文件缓存
location ~* \.(svg|jpg|jpeg|png|gif|webp|avif|ico)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
try_files $uri =404;
}
# 上传媒体文件(本地存储时落盘到 public/uploads/,并由部署侧同步进 /var/www/novalon/uploads/)
# 注意:location 内一旦出现 add_header,按 nginx 语义就不再继承 server 级头部,
# 故此处必须重述 nosniff/CSP,否则 /uploads/ 成为全站唯一无防护的同源落点(验收 A-4)。
# 这里的 nosniff/sandbox CSP 不是跨层重复:本 location 由磁盘直服(try_files … =404),
# Next 不参与应答,因此不会与 next.config.mjs 的同名头部叠加成双值。
# ⚠️ 若改为回源(proxy_pass / @nextjs),必须同时删掉这里的 nosniff 与 CSP,
# 否则会重新产生 "nosniff, nosniff" / 两份 CSP 的重复值缺陷。
location /uploads/ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; sandbox" always;
# 存量防线:上传接口已在写入侧拒绝这些类型,这里拦截早于该修复落库的历史文件
location ~* \.(html?|htm|xhtml|svg|xml|js|mjs|css|php|pl|py|sh|exe|dmg|swf)$ {
return 403;
}
# 文档与压缩包强制下载,避免在同源上下文里被内联渲染
location ~* \.(pdf|zip|docx?|xlsx?|pptx?)$ {
add_header Content-Disposition "attachment" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'; sandbox" always;
try_files $uri =404;
}
try_files $uri =404;
}
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# CMS / 联系表单 / 认证等 API 路由:禁止缓存,代理到 Next.js
# 安全头由应用发出并原样透传(nginx add_header 只追加,重述即成重复值)
location /api/ {
limit_req zone=general burst=50 nodelay;
proxy_pass http://nextjs_app;
proxy_hide_header X-Powered-By;
# 注:Cache-Control 属边缘策略,仍在此设置。若某个 Route Handler 自己也发
# Cache-Control,会出现两个值(与本次修复的安全头同类问题);需在应用侧统一,
# 本次未动,以免改变 API 缓存语义。
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}
# 管理后台与 Draft Mode:代理到 Next.js,Cookie 透传以支持 draft 预览
# 页面一律由应用应答 ⇒ X-Frame-Options: DENY 等头部同样来自 next.config.mjs
location /admin {
limit_req zone=general burst=20 nodelay;
proxy_pass http://nextjs_app;
proxy_hide_header X-Powered-By;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}
# 页面与动态路由:一律交给 Next.js standalone 运行时
# (ISR / SSR / Draft / 404 页与应用安全头都在应用侧;见文件头的架构说明)
# 原先的 `try_files $uri $uri.html $uri/index.html @nextjs` 静态 HTML 快路径已移除:
# 它服务的是 export 时代的预渲染副本,命中时应用头部无处可加(本文件已不再 add_header
# 这些名字),未命中时才回源 ⇒ 同一站点两种头部来源,正是重复/缺失缺陷的成因。
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://nextjs_app;
proxy_hide_header X-Powered-By;
# 本 location 有 add_header ⇒ 不继承 server 级头部,HSTS 在此重述
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}
# 静态资源磁盘副本未命中时的回源出口(页面/API/后台已直接 proxy_pass nextjs_app)。
# 这里刻意不放 add_header:named location 只继承 server 级头部(HSTS),
# 从而不会把上面各块的 Cache-Control 叠加到应用自己的响应头上。
location @nextjs {
proxy_pass http://nextjs_app;
proxy_hide_header X-Powered-By;
}
# 自定义 404 页面(Nginx 层面未命中且 Next.js 也返回 404 时展示)
error_page 404 /404.html;
# 优化文件传输
sendfile on;
tcp_nopush on;
tcp_nodelay on;
}
include /etc/nginx/conf.d/*.conf;
}