NAS 网站搬到容器里,404/403 差点把全家首页干崩:这次我终于搞懂了 Synology ACL 的坑
先说结论:这次迁移最危险的地方,不是“容器没跑起来”,也不是“80/443 没切过去”,而是文件已经挂进容器、nginx 也启动了,页面却死活打不开。
最后真正的原因不是路径错,不是配置错,也不是镜像坏,而是 Synology 的权限模型里,Unix 权限只是第一道门,ACL 还像额外的小门禁名单。容器里的 nginx worker 用户,根本不在允许名单里。
所以这篇文章,我不是想讲“怎么把 Web Station 换成 nginx 容器”这件事本身,而是想讲一个更有价值的经验:NAS 静态站上容器时,先把“谁有资格读文件”想清楚,比先折腾 nginx 配置重要得多。
一、问题背景:为什么要把 Web Station 迁到 nginx 容器
先交代一下背景。
这台 NAS 上一直用 Synology Web Station 提供一个静态站点,内容很简单,就是一些首页、小工具页、静态资源目录,没有动态 PHP 业务。像这样的站点,继续放在 Web Station 里当然也能跑,但我会越来越在意三件事:
第一,服务边界不够清晰。Web Station、群晖自带 nginx、证书、反向代理、系统入口之间的关系比较紧,想要单独调整静态站行为时,总觉得动一下就会碰到别的功能。
第二,后续维护不够顺手。静态站点本身很适合容器化,尤其是我已经在用 Portainer 管理其他容器时,把网站内容也交给 Portainer 管理,统一部署、统一重启、统一看健康状态,会更自然。
第三,我想保留原访问地址不变。也就是说,对外还是原来那个站点入口,用户端尽量无感。这样迁移风险最低,也最容易验收。
所以这次目标不是“把 NAS 上的网站彻底重做一遍”,而是“把内容提供方从 Web Station 切到容器 nginx,同时保留原入口和回滚能力”。
二、迁移前的基线:先把“正常状态”记下来
正式动刀前,我先记录了原站点的正常状态。这是非常重要的一步,因为如果后面要做迁移验收,就必须知道“迁移前后应该长什么样”。
我当时主要核对了几类页面:首页、一个游戏页面、一个工具页面、一个静态资源文件。它们分别代表站点里最典型的几种访问模式。与此同时,我也确认了 HTTPS 入口的行为,因为最终目标不是只把 HTTP 跑通,而是原站点的整套访问方式都别变。
这一步的结果很朴素:这些关键页面都能正常返回,大小也稳定。迁移后只要继续对照这组结果,就能判断新容器是不是已经真正接住了站点。

三、问题表现:容器起来了,页面却先给你一记下马威
接下来,我按“先并行、再切换”的方式,先把新 nginx 容器跑在一个临时端口上。这个思路很简单:旧站继续服务,新容器先在旁边跑,等它完全没问题了,再切原入口。这样对线上影响最小。
但这时候问题出现了。
新容器明明已经启动,端口映射也有了,挂载看上去也是对的,可访问临时端口时,首页先返回 403,很多静态资源直接 404。更要命的是,这种感觉非常迷惑:如果你只看“容器已经起来了”“端口也通了”,很容易以为就差一点点配置;但如果你真去测页面,就会发现站点根本没接住。
这一步我把失败状态也记录下来了,因为后面证明它非常关键:问题不是“整个站点都坏了”,而是“首页和各类静态资源呈现出不同的失败形态”。这往往能提示我们,故障点更靠近文件系统和权限,而不是简单的路由问题。

四、问题分析:先别急着改配置,先看日志和挂载
遇到这种问题时,我最想提醒的是:不要一上来就反复重写 nginx 配置。那样很容易把自己绕进去。
我当时按一个更朴素的顺序排查:
先看挂载是不是对,确认网站目录真的进了容器;再看 nginx 配置是不是生效,确认 root、index、try_files 没有写错;最后才是看权限。因为前两类问题是“服务没配对”,第三类问题是“文件根本不给你读”。
这一步最直观的证据来自容器日志:日志里明确出现了 Permission denied。也就是说,nginx 不是找不到页面,而是找到了页面但读不了。这个区别非常重要。
因为“找不到”通常意味着路径、root、索引文件、目录结构有问题;“找到但不给读”则往往意味着运行用户、用户组、ACL、Unix mode 这些权限层面的东西没对上。

五、根因解释:Unix 权限是大门,ACL 是门禁名单
这也是这次事件里最有价值的部分。
很多人第一次看到 Synology 上这种“目录看起来挂对了,服务看起来也起来了,但文件就是读不了”的情况,都会有点懵。我的建议是,把它想成一个小区门禁场景。
Unix 权限就像小区门口的大牌子,写着“业主能进”“访客不能进”。这是第一层规则,比较粗。
ACL 则像额外的小门禁名单。哪怕大门口写着某种通行规则,真正到某一栋楼时,还有一个更细的名单:谁能进电梯,谁能进楼道,谁能开哪扇门。这个名单不一定直观地表现在普通 ls 结果里,但它真实存在,而且优先级很高。
而容器里的 nginx worker 用户,就像临时进来的送餐员。它拿着正确的楼栋号、门牌号,甚至人都站在门口了,但只要它不在那张额外的门禁名单里,就还是会吃闭门羹。
这次问题就是这样。网站文件本身没坏,路径没挂错,nginx 配置也不是主要问题。真正卡住的是:容器里的 nginx 用户没有拿到读取这些文件的资格。
六、如何解决问题:最小改动,优先复用现有组权限
找到根因后,解决方案反而没那么复杂。
我没有选择粗暴地去改整站文件权限,也没有为了图省事给目录开很大的权限。原因很简单:静态站迁移的目标是把服务搬过去,不是顺手把文件安全边界打穿。
更合理的做法,是让容器里的 nginx worker 复用现有的用户组权限。也就是说,不改动网站内容,不去重写文件 ACL,而是让运行 nginx 的进程用户归属到那个本来就允许读这些文件的组里。这样改动小,风险低,也符合“最小权限、最小变更”的原则。
这里有一个很现实的工程经验:在家用 NAS 场景里,chmod 777 看起来是最快的,但往往是最不值得的。因为你真正要解决的问题,通常只是“某个服务用户读不了某些文件”,而不是“全世界都能读写这个目录”。这两者之间的安全差距很大。

七、迁移方案设计:先并行验证,再切原入口
这次迁移最值得保留的方法论,其实不是“nginx 容器怎么写”,而是顺序。
正确顺序应该是:先在临时端口把新容器跑起来;再验证关键页面、静态资源、404 行为、PHP 行为;确认没问题后,再备份原生入口配置;最后才切原 80/443 入口。
这样做的好处是,你始终有一条退路。新容器不行,就不切;切过去有问题,立刻回滚。整个过程的旧站存活时间足够长,不会因为“新站没起来,旧站又停了”而直接把首页搞挂。
我觉得这也是很多迁移类任务最容易犯错的地方:大家都太关注“新方案怎么搭”,却低估了“旧方案怎么体面退场”。实际上,很多时候迁移失败不是因为新方案不行,而是因为切换太早、回滚太晚。
八、修复后验证:临时端口先过,原地址再过
修完权限后,我先验证的是临时端口。原因很简单:这时候还没切原入口,万一还有问题,也只会影响测试,不会影响真实访问。
这一步我重点看两类结果:一是首页、工具页、静态资源是否恢复 200;二是失败路径是否仍然符合预期,比如不存在的路径要 404,PHP 路径要继续保持原来的错误行为。这样做不是为了“看起来都绿”,而是确认新容器在接管站点时,没有把旧行为悄悄改坏。
临时端口全部通过后,我才开始切原 80/443 入口。切换后紧接着又做了一次完整验证:HTTP 原地址、HTTPS 原地址、临时端口都再过一遍。最后的结果是,关键页面全部恢复一致,容器健康,原入口也正常。
这时候我基本就可以确认:这次迁移不是“服务起来了”,而是“站点已经真的接住了”。


九、三平台一键脚本:人工执行和 Agent 自动配置都行
为了方便你复用,我把这次思路整理成了三份脚本,分别覆盖 Windows 11、Ubuntu 26.04 和 macOS 27。它们不依赖第三方服务,主要做四件事:预检、生成配置、部署容器、健康检查,并在失败时给出回滚提示。
先说明一下脚本边界:它们不会替你硬编码真实 NAS 地址,也不会替你保存账号密码;它们更像一个“迁移脚手架”,帮你少漏步骤,而不是代替你做最后判断。
1. 人工自动执行怎么用
如果你是自己手动执行,建议按这个顺序来:
先在 NAS 或目标 Docker 主机上确认网站根目录、临时端口、镜像名、Stack 名都没冲突;再运行脚本生成配置并启动容器;然后先看健康状态,再访问临时端口;临时端口验证通过后,再切原入口;最后把回滚文件路径记下来。
2. Agent 自动配置怎么用
如果你是把这件事交给 Agent 去做,建议不要让它一上来就“直接切入口”。更稳妥的方式,是把脚本作为迁移步骤的一部分:先让 Agent 做预检和并行部署,再让它读取健康检查结果和页面验证结果,最后再决定是否切换入口。这样 Agent 的自动化会更安全,也更容易解释每一步为什么这么做。
3. Windows 11 版
文件名建议保存为:webstation_nginx_toolkit_windows11.ps1
param(
[string]$SiteRoot = "",
[string]$ConfigDir = "",
[string]$Image = "nginx:alpine",
[string]$StackName = "webstation-nginx",
[string]$ContainerName = "webstation-nginx",
[int]$TempPort = 18080,
[switch]$AgentMode
)
function Log([string]$Message) {
if (-not $AgentMode) { Write-Host $Message }
}
if ([string]::IsNullOrWhiteSpace($SiteRoot)) { throw "SiteRoot is required." }
if ([string]::IsNullOrWhiteSpace($ConfigDir)) { $ConfigDir = Join-Path $SiteRoot "..\webstation-nginx-config" }
if (-not (Test-Path $SiteRoot)) { throw "Site root not found: $SiteRoot" }
if (Get-NetTCPConnection -LocalPort $TempPort -ErrorAction SilentlyContinue) { throw "Port $TempPort is occupied." }
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
$defaultConf = Join-Path $ConfigDir "default.conf"
$compose = Join-Path $ConfigDir "compose.yml"
@'
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
autoindex off;
location ~* \.(php[345]?|phtml)$ {
return 500;
}
location / {
try_files $uri $uri/ =404;
}
}
'@ | Set-Content -Encoding UTF8 $defaultConf
@"
services:
web:
container_name: $ContainerName
image: $Image
restart: unless-stopped
ports:
- "$($TempPort):80"
volumes:
- "$SiteRoot:/usr/share/nginx/html:ro"
- "$defaultConf:/etc/nginx/conf.d/default.conf:ro"
"@ | Set-Content -Encoding UTF8 $compose
Log "Config generated at $ConfigDir"
Log "Next steps:"
Log "1. Review compose.yml"
Log "2. Deploy with Docker or Portainer"
Log "3. Verify http://<nas-host>:$TempPort/"
Log "4. Only then update the original 80/443 reverse proxy target"
Log "Rollback: restore the previous reverse proxy config and reload nginx."
4. Ubuntu 26.04 版
文件名建议保存为:webstation_nginx_toolkit_ubuntu2604.sh
#!/usr/bin/env bash
set -euo pipefail
AGENT_MODE=0
if [[ "${1:-}" == "--agent" || "${1:-}" == "--json" ]]; then
AGENT_MODE=1
fi
log() { if [[ $AGENT_MODE -eq 0 ]]; then echo "$1"; fi }
fail() { echo "$1" >&2; exit 1; }
SITE_ROOT="${SITE_ROOT:-}"
CONFIG_DIR="${CONFIG_DIR:-}"
IMAGE="${IMAGE:-nginx:alpine}"
STACK_NAME="${STACK_NAME:-webstation-nginx}"
CONTAINER_NAME="${CONTAINER_NAME:-webstation-nginx}"
TEMP_PORT="${TEMP_PORT:-18080}"
[[ -n "$SITE_ROOT" ]] || fail "SITE_ROOT is required."
[[ -d "$SITE_ROOT" ]] || fail "Site root not found: $SITE_ROOT"
CONFIG_DIR="${CONFIG_DIR:-$SITE_ROOT/../webstation-nginx-config}"
mkdir -p "$CONFIG_DIR"
if ss -lnt | awk '{print $4}' | grep -q ":${TEMP_PORT}$"; then
fail "Port ${TEMP_PORT} is occupied."
fi
cat > "$CONFIG_DIR/default.conf" <<'EOF'
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
autoindex off;
location ~* \.(php[345]?|phtml)$ {
return 500;
}
location / {
try_files $uri $uri/ =404;
}
}
EOF
cat > "$CONFIG_DIR/compose.yml" <<EOF
services:
web:
container_name: ${CONTAINER_NAME}
image: ${IMAGE}
restart: unless-stopped
ports:
- "${TEMP_PORT}:80"
volumes:
- "${SITE_ROOT}:/usr/share/nginx/html:ro"
- "${CONFIG_DIR}/default.conf:/etc/nginx/conf.d/default.conf:ro"
EOF
log "Config generated at $CONFIG_DIR"
log "Next steps:"
log "1. Review compose.yml"
log "2. Deploy with Docker or Portainer"
log "3. Verify http://<nas-host>:${TEMP_PORT}/"
log "4. Only then update the original 80/443 reverse proxy target"
log "Rollback: restore the previous reverse proxy config and reload nginx."
5. macOS 27 版
文件名建议保存为:webstation_nginx_toolkit_macos27.zsh
#!/usr/bin/env zsh
set -euo pipefail
AGENT_MODE=0
if [[ "${1:-}" == "--agent" || "${1:-}" == "--json" ]]; then
AGENT_MODE=1
fi
log() { if [[ $AGENT_MODE -eq 0 ]]; then echo "$1"; fi }
fail() { echo "$1" >&2; exit 1; }
SITE_ROOT="${SITE_ROOT:-}"
CONFIG_DIR="${CONFIG_DIR:-}"
IMAGE="${IMAGE:-nginx:alpine}"
STACK_NAME="${STACK_NAME:-webstation-nginx}"
CONTAINER_NAME="${CONTAINER_NAME:-webstation-nginx}"
TEMP_PORT="${TEMP_PORT:-18080}"
[[ -n "$SITE_ROOT" ]] || fail "SITE_ROOT is required."
[[ -d "$SITE_ROOT" ]] || fail "Site root not found: $SITE_ROOT"
CONFIG_DIR="${CONFIG_DIR:-$SITE_ROOT/../webstation-nginx-config}"
mkdir -p "$CONFIG_DIR"
if lsof -nP -iTCP:${TEMP_PORT} -sTCP:LISTEN >/dev/null 2>&1; then
fail "Port ${TEMP_PORT} is occupied."
fi
cat > "$CONFIG_DIR/default.conf" <<'EOF'
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
autoindex off;
location ~* \.(php[345]?|phtml)$ {
return 500;
}
location / {
try_files $uri $uri/ =404;
}
}
EOF
cat > "$CONFIG_DIR/compose.yml" <<EOF
services:
web:
container_name: ${CONTAINER_NAME}
image: ${IMAGE}
restart: unless-stopped
ports:
- "${TEMP_PORT}:80"
volumes:
- "${SITE_ROOT}:/usr/share/nginx/html:ro"
- "${CONFIG_DIR}/default.conf:/etc/nginx/conf.d/default.conf:ro"
EOF
log "Config generated at $CONFIG_DIR"
log "Next steps:"
log "1. Review compose.yml"
log "2. Deploy with Docker or Portainer"
log "3. Verify http://<nas-host>:${TEMP_PORT}/"
log "4. Only then update the original 80/443 reverse proxy target"
log "Rollback: restore the previous reverse proxy config and reload nginx."
十、Q&A
1. 为什么不直接让 nginx 容器监听 80/443?
因为这台 NAS 上 80/443 已经承担了原站点入口、HTTPS、系统相关服务等职责。直接让新容器抢端口,表面上更“彻底”,实际上会把影响面放大。更稳妥的做法,是先并行验证,再通过原入口反向代理到容器。
2. 为什么 Web Station 还保留?
因为保留 Web Station 可以保留回滚路径,也能避免误删套件导致其他功能受影响。迁移的关键不是“立刻清除旧方案”,而是“新方案稳定前,不轻易拆掉旧方案”。
3. 为什么不用 chmod 777?
因为 777 解决的是“所有人都可读写执行”,而实际问题通常只是“某个服务用户需要读取”。这两者不是一回事。能用更小权限解决问题,就不要放大权限边界。
4. 为什么先做临时端口再做切换?
因为临时端口可以把“部署问题”和“入口切换问题”拆开。部署错了,不影响原站;切换错了,也可以立刻回滚。这个顺序能显著降低事故面。
5. 为什么这次 403 和 404 会一起出现?
因为权限问题在不同路径、不同文件类型、不同 fallback 场景下,表现会不一样。对排查来说,真正重要的不是记住状态码本身,而是看到“服务能响应但内容读不出来”这个信号。
十一、最后一点经验:先想“谁能读”,再想“怎么跑”
如果你也在折腾 NAS、Docker、静态站迁移,我这次最大的建议只有一句话:先把“谁能读这些文件”想清楚,再决定服务怎么跑。
很多迁移任务表面上是在改 nginx、改端口、改反向代理,最后真正卡住的却是权限。尤其是群晖这类带 ACL 的系统,普通 Linux 经验有时不够用。你要把它当成一个更细的门禁系统来理解,而不是只看一个目录权限数字。
这次迁移最后能平稳落地,不是因为配置多复杂,而是因为顺序对:先记录基线,再并行部署,再验证内容,再备份入口,最后才切换。真正值钱的,不是某个命令,而是这套不容易把站点搞挂的顺序。
愿你的下一次迁移,也不要先被 403 和 404 吓一跳。