SSD Nodes Learn
指南 Matt Connor作者: Matt Connor · 更新于 2026-07-19

在远程 VPS 上用 tmux 运行 Claude Code

在始终在线的 Linux VPS 上,把 Claude Code 放进 tmux 里运行,即使 SSH 断开,代理会话也能继续。涵盖安装、加固以及要预料的故障模式。

问题出在笔记本盖子上,而不是 CLI

Claude Code 在您的笔记本上运行得好好的,直到您合上盖子:SSH 会话中断,shell 收到 SIGHUP,那个跑了三分钟测试的代理也随之一起死掉。要把 CLI 放到一台永不休眠的机器上运行,并且放进一个终端复用器里,让它的进程不再是您 SSH 会话的子进程。这就是全部诀窍所在,而承重的部分是 tmux,不是安装本身。

这是一篇关于运维一台您让代理常驻其上的机器的文章。如果您没有一台可以一直开着的 Linux 服务器,这里的内容都不适用。这就是唯一一条实打实的前提条件。

tmux 究竟做了什么

当您 SSH 登录时,sshd 会派生出一个 shell,并给它分配一个伪终端;您从那个 shell 启动的一切都是它的子进程。连接一断,内核就拆掉这个 pty,shell 收到 SIGHUP,接着又对它的子进程逐个挂断。长时间运行的前台进程就此死掉。

tmux 把这层归属关系倒了过来。您敲下的 tmux 命令只是一个瘦客户端,它通过一个 unix 套接字与一个脱离了您终端而运行的 tmux 服务器通信。会话里的各个 shell 是那台服务器的子进程,而不是 sshd 的。断掉 SSH 连接,客户端消失,而服务器、会话以及那个正在执行任务的代理都继续运行。重新连上,tmux attach,您就回到了同一个 shell,连回滚缓冲区都还在。nohup 同样能挺过挂断,但它不给您回去的路,您无法重新连回一个在后台运行的 TUI。Claude Code 是交互式的;tmux(或者 screen)才是对的工具。

给机器定规格

CLI 是一个 Node 进程;把机器塞满的不是它。把机器塞满的是代理替您跑起来的那些东西:一次构建、一整套测试、tsc、一个语言服务器、一个跑在 Docker 里的数据库。按工具链来定规格,别按 CLI 来定。哪怕您打算永远不去碰它,也要加上 swap,它能把一次硬性的 OOM 击杀变成一次缓慢的构建:

sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

也要盯着磁盘:代码仓库、node_modules 和 Docker 镜像积累得很快。如果工具链越过容器、伸向了完整的虚拟机——一个 KVM 客户机、一个本地 Kubernetes 节点——那么在您下手之前先确认套餐暴露了 CPU 虚拟化扩展,因为在 VPS 上运行嵌套虚拟化是由服务商替您开启的,而不是您从客户机内部就能打开的东西。

先建一个非 root 用户

创建一个有独立家目录的专用用户,并把您的公钥放到位:

sudo adduser --disabled-password --gecos "" agent
sudo install -d -m 700 -o agent -g agent /home/agent/.ssh
sudo cp ~/.ssh/authorized_keys /home/agent/.ssh/authorized_keys
sudo chown agent:agent /home/agent/.ssh/authorized_keys
sudo chmod 600 /home/agent/.ssh/authorized_keys

这是有意为之:agent 不在 sudo 组里。如果需要某个系统软件包,由您来安装。就这一个决定,就排除了一条随手写下的 shell 命令能毁掉主机的大多数途径。

一台常驻机器的 SSH 卫生

一台整天挂在公网上、装着一个代理和您源代码的机器,用密码认证不是一个值得背的风险。把它关掉。在 Ubuntu 24.04 和 Debian 13 上,/etc/ssh/sshd_config 会包含 /etc/ssh/sshd_config.d/*.conf,所以请放进一个文件,而不是去改主配置:

# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no

先校验再重载,同时保持您当前的会话开着,另开第二个终端去测试一个全新的会话:

sudo sshd -t && sudo systemctl restart ssh

Ubuntu 24.04 上有个细微之处:sshd 是套接字激活的。认证设置在 systemctl restart ssh 时生效,但改动监听的 Port 还需要 systemctl daemon-reload 并重启 ssh.socket

然后是防火墙。在启用它之前先放行 SSH,否则您会把自己锁在门外:

sudo ufw allow OpenSSH
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable

安装 fail2ban,但要清醒地知道它到底能给您带来什么:一旦密码认证关掉,暴力破解本来就无法得逞——它的作用是把失败的尝试挡在您的日志之外。

# /etc/fail2ban/jail.local
[sshd]
enabled = true
backend = systemd
maxretry = 5
bantime = 1h

最后,用 sudo apt install unattended-upgradessudo dpkg-reconfigure -plow unattended-upgrades 来自动打补丁。注意它和 tmux 的相互影响:打开 Unattended-Upgrade::Automatic-Reboot,一次内核更新就会重启机器,把每一个会话都带走。把它关掉,按您自己的节奏、在没有任务正在执行的时候重启。

在 Ubuntu 上安装 Node.js 和 Claude Code

Claude Code 是一个 Node CLI,所以您需要一个较新的 Node。发行版自带的软件包往往滞后;在 Ubuntu 和 Debian 上通常走 NodeSource,它提供一个带签名的仓库(不用 apt-key,那个工具已经没了):

curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node --version

接下来是大家常做错的地方:用您的 agent 用户来安装 CLI,绝不要用 sudo npm -g 一个属于 root 的全局前缀会在之后引出权限错误,还会在 npm 缓存里留下属于 root 的文件。先把 npm 的前缀指向该用户的家目录:

mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
npm install -g @anthropic-ai/claude-code
claude --version

这个 export 要写进 ~/.bashrc,而不是 ~/.profile,而且它得放在文件顶部附近那句“If not running interactively, don't do anything”守卫的上方:tmux 可能启动非登录 shell,这类 shell 会读取 ~/.bashrc 而跳过 ~/.profile——~/.profile 只对登录 shell 运行。用像 nvm 这样的版本管理器为每个用户装一份 Node,也能达到同样的效果;无论走哪条路,目标都是让 npm install -g 永远不需要 sudo。npm 照样能用,或者用 Anthropic 的原生安装脚本,也就是目前文档里的默认方式。粘贴之前先查一下 Anthropic 的安装文档——安装方式会变。

在一个代码仓库里运行 claude 来启动它。首次运行会带您走一遍认证流程;一台无界面的机器没有浏览器,于是这套流程会给您一个 URL,让您在自己的机器上打开,再把一个验证码带回终端。(环境里放一个 API 密钥是另一条路。)无论走哪条路,这份凭据现在都落在了服务器上——这就把我们带到了大家往往跳过的那一部分。

关于波及范围的对话

一个拥有 shell 访问权的代理,本身就是一个 shell。它能读取它所运行的那个用户能读取的一切,也能推送到那个用户能推送的任何地方。这不是对工具的批评,这就是它的定义——也正因如此,它跑在哪个账户之下,比任何单项设置都更重要。

  • 专用的、无特权的用户。 不进 sudo 组,家目录不与您自己的账户共用。
  • 机器上不放生产凭据。 没有装着生产密钥的 ~/.aws/credentials,没有从生产环境拷下来的 .env,没有对任何要紧东西有写权限的数据库密码。给代理一个预发布或只读的凭据。
  • 范围受限的令牌。 一个只限于单个仓库的细粒度 GitHub 令牌;当只读就够用时,用一个部署密钥。

Claude Code 提供了一个能彻底跳过其权限提示的开关。在笔记本上、在一个用完即弃的项目里,那由您说了算。而在一台装着令牌的服务器上,它拿掉了横在“一条被误读的指令”和“一次 git push --force”之间的最后一道防线。这个开关到底改变了什么,以及如何从内置沙箱一路到一次性 VPS 去约束一个带着它运行的代理,都在在服务器上安全运行 Claude Code里讲到了。

部署密钥 vs SSH 代理转发

很想用 ssh -A 登录,好让 git 能用您笔记本上的密钥。搞清楚这授予了什么:代理转发会把您本地 SSH agent 的套接字暴露给机器上以那个用户身份运行的进程。任何以 agent 身份运行的东西——包括那个代理——只要您还连着,就能请求您的密钥去为它能够到的任何主机签名。这远不止“让 git 拉取这一个仓库”。

应当改为在服务器上生成一个密钥,把它登记为一个按仓库设置的部署密钥(只有代理需要推送时才给写权限),并设置一个 git 身份,好让来自这台机器的提交可辨认:

ssh-keygen -t ed25519 -C "agent deploy key" -f ~/.ssh/id_ed25519_repo
cat ~/.ssh/id_ed25519_repo.pub   # paste into the repo's Deploy Keys
git config --global user.name "Agent (build box)"
git config --global user.email "agent@example.com"

tmux 工作流

先装上它(sudo apt install tmux),再来一个精简的 ~/.tmux.conf

set -g mouse on
set -g history-limit 50000
set -g default-terminal "tmux-256color"

四条命令就覆盖了日常使用:

tmux new -A -s claude     # attach to session "claude", creating it if absent
# ...run `claude` inside it, work normally...
# Ctrl-b then d           -> detach; everything keeps running
tmux ls                   # list sessions
tmux attach -t claude     # reattach, from this machine or any other
tmux kill-session -t claude

tmux new -A -s claude 是那条要记住的命令——会话存在时它就连上,不存在时它就创建,所以一条命令既能用来开启一天的工作,也能用来在断线后接着干。给它起个别名。在一个会话里,Ctrl-b c 开一个窗口,Ctrl-b nCtrl-b p 在窗口间切换,Ctrl-b [ 进入复制模式向上回滚(q 退出)。

故障模式

“我的会话没了。” tmux ls 打印出 no server running on /tmp/tmux-1000/default。这几乎总是意味着那个进程从来就不在 tmux 里面——您 SSH 登录后直接跑了 claude,一断线它就被杀了。没什么可恢复的。能防住它的习惯是:tmux new -A -s <project> 是每次登录后的第一条命令。

窗格缩成了一个小方块。 tmux 会把一个会话的尺寸调整到最小的那个已连接客户端,所以另一台机器上一个还挂着的陈旧客户端会把显示挤扁。连上时把其他的强行踢掉:tmux attach -d -t claude

一次构建打印出 Killed 就一个词,没有堆栈跟踪。用 sudo dmesg -T | grep -i -E 'out of memory|killed process' 确认——内核的 OOM killer 挑中了最大的那个进程。从 Node 那边,您可能改为看到 FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory。按顺序来修:加 swap(见上),限制测试和编译器的并行度,用 NODE_OPTIONS=--max-old-space-size=... 抬高 Node 的堆,或者把 VPS 升配。OOM killer 也可能挑中tmux 服务器而不是那次构建,把您的会话一起带走;如果 systemd-oomd 在运行,它能以同样的效果杀掉整个用户切片。

npm error code EACCES / permission denied, mkdir '/usr/lib/node_modules/...' 一次装进了属于 root 的前缀的全局安装。改用上面那个 ~/.npm-global 前缀。如果您在某个时刻已经跑过 sudo npm,您可能还会看到 Your cache folder contains root-owned files——用 sudo chown -R $(id -u):$(id -g) ~/.npm 来修复。

claude: command not found——但只是偶尔。 您那句 PATH export 落在了 ~/.bashrc 里“If not running interactively, don't do anything”守卫的下方,于是非交互 shell 跳过了它。把这个 export 挪到那道守卫的上方,并且把它留在 ~/.bashrc 里,而不是 ~/.profile:tmux 可能启动非登录 shell,这类 shell 会读取 ~/.bashrc,永远不去碰 ~/.profile

连上之后颜色乱掉。 一个 TERM 不匹配——上面那行 default-terminal 就是修法。

会话在重启后消失。 这不是 bug:tmux 服务器是一个进程,一次重启就结束了它。查一下 uptime

随着规模变大会出问题的地方

更多项目。 每个仓库一个 tmux 会话,以仓库命名;这样 tmux ls 就成了您的仪表盘。跳过这套命名纪律,您就会得到 012 这样的会话。端口也会以同样的方式蔓延——六个仓库全都想要 :3000,就是该停止手工分配、让一个 Traefik 反向代理在 Docker Compose 下按主机名把多个应用路由过去来做调度的时候了。

更多人。 tmux 套接字是按用户划分的,所以同一台机器上的两个开发者各自拥有自己的 tmux 服务器,看不到对方的会话。通过一个共享套接字来共用一个会话,意味着所有人都以同一个 Unix 用户身份往同一个 shell 里敲字,连带其审计和权限上的后果。分开用户才是那个乏味而正确的答案。

无人值守的工作。 tmux 是给您会去连接的交互式会话用的。那些按计划、没人看着运行的作业,属于 systemd 单元和定时器,在那里它们能免费获得日志、重启策略和开机存活。伸手去用 tmux 跑一个 cron 形状的作业,是这个作业想成为一个服务的信号。

最后提一句:把代理启动的开发服务器绑到 127.0.0.1,而不是 0.0.0.0,并通过一条 SSH 隧道(ssh -L 3000:127.0.0.1:3000 agent@your-server)去访问它们,而不是在 ufw 里开端口。一旦您要转发六七个端口,或者一部手机和一台笔记本都想访问同一个预览,就在它们前面放一个自托管在 VPS 上的 WireGuard VPN:开发服务器绑到一个私有网络接口上,而 ufw 对公网接口上来的一切照旧一律拒绝。防火墙只有在您别再往上凿洞时才管用。

Claude Code 不是唯一的选择:在 VPS 上运行一个编程 AI 代理也把 Aider 和 Goose 一并掂量了。

FAQ

SSH 断开后 Claude Code 还会继续运行吗?

只有当您是在 tmux 里启动它的才会。一个直接从 SSH shell 启动的进程是那个 shell 的子进程,链接一断就随 pty 一起死掉。在 tmux 里面,shell 归属于那个脱离出来的 tmux 服务器,所以代理会带着任务继续干,而 tmux attach 把您送回同一个回滚缓冲区。把 tmux new -A -s <project> 定为每次登录后的第一条命令,这个问题就消失了。

我应该用 sudo npm install -g 来安装 CLI 吗?

不。一个属于 root 的全局前缀会在之后的安装里递给您 EACCES 错误,还会在 npm 缓存里留下属于 root 的文件。把 npm 的前缀设为 ~/.npm-global(或用像 nvm 这样的版本管理器),以无特权的 agent 用户来安装,并从 ~/.bashrc 里、在交互守卫的上方把 ~/.npm-global/bin 导出到 PATH。如果您已经跑过一次 sudo npm,用 sudo chown -R $(id -u):$(id -g) ~/.npm 修复缓存。

在一台跑着代理的机器上,ssh -A 代理转发安全吗?

它授予的东西远超这份工作所需。转发会把您本地 SSH agent 的套接字暴露给以那个用户身份运行的每一个进程,所以只要您还连着,机器上任何东西都能请求您的密钥去为它能够到的任何主机签名。请在服务器上生成一个 ed25519 密钥,把它登记为一个按仓库设置的部署密钥,只有当代理确实需要推送时才给写权限。

为什么我的构建只打印出 Killed

就一个词、没有堆栈跟踪,那是内核的 OOM killer。用 sudo dmesg -T | grep -i -E 'out of memory|killed process' 确认;从 Node 那边您可能改为看到 JavaScript heap out of memory。按顺序过一遍这些修法:加一个 swapfile,限制测试和编译器的并行度,抬高 NODE_OPTIONS=--max-old-space-size=...,然后把 VPS 升配。当心 OOM killer 可能挑中 tmux 服务器而不是那次构建,把您整个会话都带走。

用 tmux 还是一个 systemd 服务?

tmux 适合您会去连接、观看并往里敲字的交互式会话,而这正是一个代理会话的样子。那些按计划、没人看着运行的工作,属于 systemd 单元和定时器,在那里日志、重启策略和开机存活都是免费送的。如果您正伸手去用 tmux 跑一个 cron 形状的作业,那这个作业其实想成为一个服务。