在绿联 NAS 上部署 DSH,手机可访问
摘要
从 SSH 登录开始,先在绿联 NAS 上用 Docker 部署 DSH;不想用 Docker 的,后面还有 systemd 常驻、固定 50289 入口和 Tailscale 直连方案。手机在同一 Wi-Fi 或外网都能打开。
下面从登录 NAS 开始,按顺序执行。每一步都有命令和检查结果,命令整段复制到 SSH 里。
文中的 NAS_IP、TAILSCALE_IP、NAS_LAN_CIDR 都是占位符,执行前换成你自己的地址,不要把真实 IP 写进文章或截图。
1. 打开 SSH 窗口
SSH 窗口不是 UGOS 网页,是电脑上的命令行窗口。登录成功后,所有命令都在这个窗口里执行。
1.1 准备
UGOS 控制面板 → 应用中心,确认 Docker 已安装;控制面板 → 终端机/SSH,开启 SSH;控制面板 → 网络 → 网络接口,能看到 NAS 的 IPv4 地址。
1.2 Windows 打开 PowerShell
按 Win 键,输入 PowerShell,点“Windows PowerShell”。
或者按 Win + R,输入 powershell,回车。
如果提示 ssh 不是内部或外部命令,去“设置 → 应用 → 可选功能 → 添加功能”,安装 OpenSSH 客户端。
1.3 macOS 打开终端
按 Command + 空格,输入 Terminal,回车。
1.4 登录 NAS
在窗口里执行:
ssh 你的管理员账号@NAS_IP
把 你的管理员账号 换成 UGOS 的登录账号,NAS_IP 换成 NAS 的局域网 IP。
首次连接会问:
Are you sure you want to continue connecting (yes/no)?
输入 yes,回车。然后输入 NAS 密码。密码不会显示,输完直接回车。
登录成功后,行首会变成类似:
你的账号@DXP4800PLUS:~$
看到这个提示符,说明 SSH 窗口已经连上 NAS。
检查当前用户:
whoami
应该输出你的 UGOS 账号。
检查 Docker:
docker --version
docker compose version
两条命令都能输出版本号,再继续。提示 permission denied,在命令前加 sudo,或者把当前账号加入 Docker 组。
1.5 连不上怎么办
Connection refused:SSH 没开,回 UGOS 控制面板开启。Connection timed out:IP 不对,或者电脑和 NAS 不在同一个网络。Permission denied:账号或密码不对,注意 UGOS 账号不是 NAS 设备名。
2. 建文件夹
mkdir -p /volume1/docker/dsh
cd /volume1/docker/dsh
pwd
pwd 应该输出:
/volume1/docker/dsh
不是这个路径,说明 cd 没成功,重新执行上一行。
3. 生成配置文件
SSH 窗口就是第 1 步登录后停留的窗口。
需要五个文件。每个代码块是一整段命令,复制后粘到 SSH 窗口,按回车。粘贴用鼠标右键,或按 Ctrl+Shift+V。看到新的命令提示符,就是写好了。
每生成一个文件,执行 ls -lh 文件名 检查。这里的“文件名”换成实际名字,比如 ls -lh Dockerfile。文件大小不是 0,再执行 head -3 文件名,看开头是不是表格里的内容:
| 文件 | 检查命令 | 应该看到 |
|---|---|---|
| Dockerfile | head -3 Dockerfile |
FROM node:22.23.4-bookworm-slim |
| nginx.conf | head -3 nginx.conf |
worker_processes auto; |
| entrypoint.sh | head -3 entrypoint.sh |
#!/usr/bin/env bash |
| docker-compose.yml | head -3 docker-compose.yml |
services: |
| .env | cat .env |
DSH_TRUSTED_HOSTS= |
3.1 生成 Dockerfile
cat > Dockerfile <<'EOF'
FROM node:22.23.4-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends nginx \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @deepseek-ai/dsh@latest
COPY nginx.conf /etc/nginx/nginx.conf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENV DSH_HOME=/data
EXPOSE 3080
ENTRYPOINT ["/entrypoint.sh"]
EOF
3.2 生成 nginx.conf
cat > nginx.conf <<'EOF'
worker_processes auto;
error_log /dev/stderr warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 3080;
server_name _;
client_max_body_size 64m;
location / {
proxy_pass http://127.0.0.1:3081;
proxy_http_version 1.1;
proxy_set_header Host $http_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 Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_buffering off;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
}
}
EOF
3.3 生成 entrypoint.sh
cat > entrypoint.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
TRUST_ARGS=()
for h in ${DSH_TRUSTED_HOSTS:-}; do
TRUST_ARGS+=(--trusted-host "$h")
done
dsh web --port 3081 "${TRUST_ARGS[@]}" &
DSH_PID=$!
cleanup() {
kill "$DSH_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
exec nginx -g "daemon off;"
EOF
3.4 生成 docker-compose.yml
cat > docker-compose.yml <<'EOF'
services:
dsh:
build: .
image: dsh-web:local
container_name: dsh-web
restart: unless-stopped
environment:
TZ: Asia/Shanghai
DSH_HOME: /data
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
DSH_TRUSTED_HOSTS: ${DSH_TRUSTED_HOSTS:-}
volumes:
- dsh-data:/data
ports:
- "3080:3080"
volumes:
dsh-data:
EOF
这里的 ${DEEPSEEK_API_KEY:-} 不用改成你的 Key。它是从 .env 读变量,compose 文件保持原样。
3.5 生成 .env
先看 NAS 的 IP。UGOS 控制面板 → 网络 → 网络接口,能看到 IPv4 地址。
cat > .env <<'EOF'
DSH_TRUSTED_HOSTS="NAS_IP"
DEEPSEEK_API_KEY=
EOF
这段内容不是网页表单,是一段终端命令。DEEPSEEK_API_KEY= 等号后面就是填 Key 的位置;现在等号后面是空的,所以看起来没有地方可填。
把 NAS_IP 换成 NAS 的 IP。
DEEPSEEK_API_KEY 是 DeepSeek 开放平台的 API Key,不是 NAS 密码,也不是 DSH 的登录 token。没有 Key,打开 DeepSeek 开放平台,登录后创建,复制 sk- 开头的那串。账户里要有可用余额。Key 通常只显示一次,丢了就重新创建。
DEEPSEEK_API_KEY= 可以留空,启动后进网页,在设置或模型入口里填,Key 会存进 dsh-data 卷。
也可以现在填。把 3.5 的命令重新执行一遍,改成这样:
cat > .env <<'EOF'
DSH_TRUSTED_HOSTS="NAS_IP"
DEEPSEEK_API_KEY=sk-xxxxxxxx
EOF
DSH_TRUSTED_HOSTS 保留 NAS 的 IP,把 sk-xxxxxxxx 换成你的 Key,不要加引号。改完 .env 要执行 docker compose up -d --force-recreate,环境变量才会重新注入容器;在网页里填则不用重建。
不要把 Key 写进 docker-compose.yml,也不要截图发出去。
检查文件:
ls -lh Dockerfile nginx.conf entrypoint.sh docker-compose.yml .env
cat .env
五个文件都要在,大小不是 0。cat .env 里的 DSH_TRUSTED_HOSTS 要等于 NAS 的 IP。
4. 启动
先检查 Compose 配置:
cd /volume1/docker/dsh
docker compose config
能输出一段展开后的 YAML,没有报错,再构建:
docker compose up -d --build
首次构建要下载 Node 镜像和 npm 包,几分钟到十几分钟都正常。
看日志:
docker compose logs -f dsh
看到 dsh web 的监听地址后,按 Ctrl+C 退出日志。
检查状态:
docker compose ps
状态是 Up 才继续。
也可以在 UGOS 的 Docker 项目页操作:

Docker → 项目,点“创建”。

项目名随便填;存放路径选 /volume1/docker/dsh;Compose 配置粘贴 docker-compose.yml 的内容。
5. 电脑打开
浏览器访问:
http://NAS_IP:3080
把 IP 换成 NAS 的 IP。
页面能打开后,点设置或模型入口,填 DeepSeek API Key,保存,点测试连接。
没有 API Key,在 DeepSeek 开放平台 创建一个。
如果打开后被跳到带 ?token=... 的地址,把完整地址留着。DSH 重启后 token 会变。
6. 手机在同一个 Wi-Fi
手机连同一个 Wi-Fi,浏览器打开:
http://NAS_IP:3080
打不开,先确认手机和 NAS 在同一网段。手机用 5G、NAS 在局域网,当然打不开。路由器开了 AP 隔离或访客网络,也会打不开。
页面显示 403 Forbidden,改 .env:
DSH_TRUSTED_HOSTS="NAS_IP"
这里填 NAS 的访问地址,不是手机自己的 IP。改完执行:
docker compose up -d --force-recreate
7. 手机在外网
不要把 3080 直接映射到公网。DSH 能在容器里执行命令,没有登录鉴权。
NAS 终端里安装 Tailscale:
curl -fsSL https://tailscale.com/install.sh | sudo sh
启动:
sudo tailscale up \
--advertise-routes=NAS_LAN_CIDR \
--accept-dns=false \
--hostname=ugreen-nas
NAS_LAN_CIDR 换成 NAS 所在网段。
到 Tailscale 管理后台 批准这条子网路由:

找到 ugreen-nas,点 Approve。
手机装 Tailscale,登录同一个账号,允许使用子网路由。然后打开:
http://NAS_IP:3080
也可以试 Tailscale 地址:
http://TAILSCALE_IP:3080
TAILSCALE_IP 换成 Tailscale 分配给 NAS 的地址。
把手机实际使用的地址加进 .env:
DSH_TRUSTED_HOSTS="NAS_IP TAILSCALE_IP ugreen-nas"
重建:
docker compose up -d --force-recreate
手机打开页面,发一条消息,能收到流式输出,就算通了。
8. 日常操作
看日志:
docker compose logs -f dsh
重启:
docker compose restart dsh
停止:
docker compose down
更新:
docker compose build --pull --no-cache
docker compose up -d
dsh-data 卷里存会话和设置。不要执行 docker compose down -v,会把数据删掉。
9. 出错时
403 Forbidden。
.env 里的地址和浏览器地址栏不一致。改完执行 docker compose up -d --force-recreate。
页面能开,回答不动。
WebSocket 没通。检查 nginx.conf 里 proxy_set_header Upgrade、Connection 和 proxy_http_version 1.1。
容器一直重启。
docker compose logs --tail=100 dsh
常见原因是 .env 里有空项,或者 DSH 启动参数错了。
构建卡在 npm install。
网络问题。给 Docker 配代理,或者给 npm 换一个能用的 registry。
端口被占用。
把 docker-compose.yml 里的 "3080:3080" 改成 "4080:3080",浏览器访问 http://NAS_IP:4080。
Tailscale 打不开。
确认 NAS 上 Tailscale 在线,确认子网路由已批准。
token 变了。
查日志:
docker compose logs dsh | grep -i token
把最新 URL 里的本机地址换成 NAS 的 IP,再打开。
10. 安全
- 不要把
3080映射到公网 - 不要在路由器上做端口转发
- 用 Tailscale 访问,不要直接暴露
- API Key 放在
.env,不要提交到 Git - DSH 只挂需要读写的文件夹,不要挂整个
/volume1
11. 不想用 Docker:改成 systemd 常驻
Docker 方案能跑。原生安装少一层容器,改配置不用重建镜像;代价是 unit 文件、权限、日志都要自己维护。
11.1 装独立的 Node
系统自带的 Node 版本可能偏低,装一个独立的:
cd /volume1/docker/dsh
curl -O https://nodejs.org/dist/v24.22.1/node-v24.22.1-linux-x64.tar.xz
tar -xJf node-v24.22.1-linux-x64.tar.xz
mv node-v24.22.1-linux-x64 node
./node/bin/node -v
把 npm 全局目录指到自己的盘上,再装 DSH:
mkdir -p /volume1/docker/dsh/npm-global
./node/bin/npm config set prefix /volume1/docker/dsh/npm-global
./node/bin/npm i -g @deepseek-ai/dsh
/volume1/docker/dsh/npm-global/bin/dsh --version
11.2 写 systemd unit
先建目录:
mkdir -p /volume1/docker/dsh/home /volume1/docker/dsh/workspace /volume1/docker/dsh/logs
写 /etc/systemd/system/dsh-web.service:
[Unit]
Description=DeepSeek Harness Web UI
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
UMask=0077
User=1000
Group=100
WorkingDirectory=/volume1/docker/dsh/workspace
Environment=HOME=/volume1/docker/dsh/home
Environment=DSH_HOME=/volume1/docker/dsh/home
Environment=PATH=/volume1/docker/dsh/node/bin:/volume1/docker/dsh/npm-global/bin:/usr/bin:/bin
ExecStart=/volume1/docker/dsh/npm-global/bin/dsh web --host NAS_IP --port 50288 --no-open
Restart=on-failure
RestartSec=3
StandardOutput=append:/volume1/docker/dsh/logs/web.log
StandardError=append:/volume1/docker/dsh/logs/web.log
[Install]
WantedBy=multi-user.target
NAS_IP 换成 NAS 的局域网 IP。
sudo systemctl daemon-reload
sudo systemctl enable --now dsh-web
sudo systemctl status dsh-web
--host 只写一个具体 IP。写 0.0.0.0 会被 DSH 直接拒绝,这不是防火墙拦的。
11.3 三个容易卡住的地方
status=217/USER,服务起不来。
UGOS 的用户名不走标准 NSS,unit 里不要写账号名。用数字:
User=1000
Group=100
启动就退出,日志说凭据权限不对。
.credentials.yaml 的权限太宽。找到它,改成 600:
find /volume1/docker/dsh/home -name '.credentials.yaml' -exec chmod 600 {} \;
unit 里的 UMask=0077 也要保留,否则下次写出来还是宽权限。
打开页面提示工作区初始化失败。
DSH 默认工作区会依赖 xdg-user-dir DOCUMENTS,NAS 上没有这个目录。找到 profile 里的 cordis.patch.yml:
find /volume1/docker/dsh/home -name cordis.patch.yml
把文档目录固定到工作区,例如:
documentsDirectory: /volume1/docker/dsh/workspace
改完重启:
sudo systemctl restart dsh-web
12. 固定入口,不用每次找 token
dsh web 每次启动都会生成新的 token,重启一次地址就变一次,手机书签立刻失效。
加一个只做 302 的小服务,端口 50289。它每次请求都读 logs/web.log 里最后一次出现的 ?token=,然后跳到最新地址:
http://NAS_IP:50289/ → 302 http://NAS_IP:50288/?token=<最新>
这样书签固定为 50289,后端重启不用管。
入口服务还要看请求的 Host:从局域网 IP 来就跳局域网地址,从 Tailscale IP 来就跳 Tailscale 地址。不处理这一点,外面打开会被跳到家里的地址。
/volume1/docker/dsh/entry.py:
#!/usr/bin/env python3
import os
import re
import http.server
import socketserver
from pathlib import Path
LOG = Path("/volume1/docker/dsh/logs/web.log")
PORT = 50289
LAN_HOST = os.environ["DSH_LAN_HOST"]
TS_HOST = os.environ["DSH_TS_HOST"]
PATTERN = re.compile(r"http://[0-9.]+:50288/\?token=[A-Za-z0-9_-]+")
def current_url():
try:
text = LOG.read_text(errors="replace")
except OSError:
return None
found = PATTERN.findall(text)
return found[-1] if found else None
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
url = current_url()
if url is None:
body = b"DSH web is not running or has not printed a token yet.\n"
self.send_response(503)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
host = self.headers.get("Host", "")
target = TS_HOST if host.startswith("100.") or host.startswith(TS_HOST) else LAN_HOST
url = re.sub(r"^http://[^/]+", f"http://{target}:50288", url)
self.send_response(302)
self.send_header("Location", url)
self.send_header("Cache-Control", "no-store")
self.end_headers()
def log_message(self, fmt, *args):
pass
class Server(socketserver.ThreadingTCPServer):
allow_reuse_address = True
daemon_threads = True
if __name__ == "__main__":
with Server(("0.0.0.0", PORT), Handler) as httpd:
httpd.serve_forever()
对应 unit /etc/systemd/system/dsh-entry.service:
[Unit]
Description=DSH stable entry
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment=DSH_LAN_HOST=NAS_IP
Environment=DSH_TS_HOST=TAILSCALE_IP
ExecStart=/usr/bin/python3 /volume1/docker/dsh/entry.py
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now dsh-entry
手机书签写:
http://NAS_IP:50289/
13. 手机在外网:直接走 Tailscale 地址
Docker 方案用子网路由。子网路由会被网络管理器删规则,tailscaled 日志里能看到:
systemd-networkd deleted ip rules; restoring Tailscale's
规则一删,手机就找不到 NAS。与其跟 systemd-networkd 抢路由,不如不依赖子网路由,把服务直接暴露在 NAS 的 Tailscale IP 上。
先确认 Tailscale 在线:
sudo /volume1/docker/tailscale/tailscale \
--socket=/volume1/docker/tailscale/tailscaled.sock status
正常情况下只看到 NAS 和手机,电脑关不关机不影响。
看两个字段:
- 手机节点的
LastHandshake是0001-01-01:控制面连上了,数据通道没建立 - 日志里有
systemd-networkd deleted ip rules:路由规则被反复删除
路由问题不用继续修。直接在 NAS 上做 TCP 转发,把 TAILSCALE_IP:50288 转到 NAS_IP:50288。有 socat 的话,一条命令:
sudo socat TCP-LISTEN:50288,bind=TAILSCALE_IP,fork,reuseaddr TCP:NAS_IP:50288
长期跑就写成 systemd unit,ExecStart 换成上面的 socat 命令。
还要让 DSH 接受 Tailscale 地址,否则它看到 Host: TAILSCALE_IP:50288 会拒绝:
ExecStart=/volume1/docker/dsh/npm-global/bin/dsh web \
--host NAS_IP \
--port 50288 \
--trusted-host TAILSCALE_IP:50288 \
--no-open
改完:
sudo systemctl daemon-reload
sudo systemctl restart dsh-web
入口服务按 Host 决定跳转目标,所以手机只需要连 Tailscale:
| 场景 | 地址 | 依赖 |
|---|---|---|
| 家里 Wi-Fi | http://NAS_IP:50289/ |
同网段即可 |
| 外面 / 4G | http://TAILSCALE_IP:50289/ |
只依赖 Tailscale 隧道 |
这样路由表怎么抖动都不影响访问。
14. 跑 Codex CLI
DSH 装好后,Codex 也可以放在 NAS 上,写代码不用一直开着电脑。
/volume1/docker/dsh/node/bin/npm i -g @openai/codex
/volume1/docker/dsh/npm-global/bin/codex --version
它需要三个环境变量:
CODEX_HOME:配置和登录态放哪HTTPS_PROXY/HTTP_PROXY:NAS 直连不通时走本机代理- 运行时加
--skip-git-repo-check:NAS 上没有 git 仓库,不加会报“不在受信任目录”
从 DSH 里调用 Codex(subagent 插件)时,这三个变量要写进插件配置的 env 里。只在 shell 里 export 没用,插件拉起的子进程不继承交互式环境,表现就是“命令行能用、插件里报网络不可达”。
手机访问 Codex,用 ttyd 不合适:手机上敲命令太累,输出也不会自动滚到底。用一个很薄的网页壳,只保留三件事:
- 输入框把内容送到 Codex,输出按行流式返回
- 历史记录存本地 JSON,刷新不丢
- 停止按钮,长任务能中断
命令列表、工具调用这类结构化内容按卡片渲染,比终端里的纯文本好读。
15. 更新时要注意
/volume1/docker/dsh/node/bin/npm i -g @deepseek-ai/dsh@latest
sudo systemctl restart dsh-web
token 会变,但入口服务读日志,书签不用改。
如果改过 --host 或端口,三处要同步:
- systemd 的 unit 文件
- 入口服务里写死的后端端口
- Tailscale 转发器的目标端口
任意一处不同步,表现都是“页面打不开”。