Chạy Claude Code trên VPS bằng tmux
Chạy Claude Code trong tmux trên một VPS Linux luôn bật, để phiên agent không chết khi kết nối SSH rớt. Cách cài, tăng bảo mật và các lỗi hay gặp.
Vấn đề nằm ở nắp laptop, không phải CLI
Claude Code chạy tốt trên laptop cho tới lúc bạn gập máy lại: phiên SSH đứt, shell nhận SIGHUP, và con agent mới chạy test được ba phút cũng chết theo. Hãy chạy CLI trên một máy không bao giờ ngủ, bên trong một terminal multiplexer mà tiến trình của nó không phải là con của phiên SSH. Toàn bộ mẹo chỉ nằm ở đó, và thứ quyết định là tmux chứ không phải bước cài đặt.
Đây là bài về việc vận hành một cái máy mà bạn để agent chạy trên đó. Nếu bạn không có máy chủ Linux nào có thể để bật liên tục thì không có gì ở đây dùng được. Đó là yêu cầu bắt buộc duy nhất.
tmux thực sự làm gì
Khi bạn SSH vào máy, sshd fork ra một shell rồi cấp cho nó một pseudo-terminal (pty, thiết bị đầu cuối giả); mọi thứ bạn khởi động từ shell đó đều là tiến trình con của nó. Mất kết nối thì kernel tháo bỏ pty, shell nhận SIGHUP, rồi shell lại cắt tiếp các tiến trình con của mình. Các tiến trình foreground chạy lâu đều chết theo.
tmux đảo ngược quan hệ sở hữu đó. Lệnh tmux bạn gõ chỉ là một client nhẹ, nói chuyện qua unix socket với một tmux server chạy tách rời khỏi terminal của bạn. Các shell bên trong một phiên là con của server đó, không phải của sshd. Ngắt kết nối SSH thì client biến mất, còn server, phiên làm việc và con agent đang làm dở vẫn chạy tiếp. Kết nối lại, gõ tmux attach, bạn quay về đúng shell cũ với nguyên scrollback. nohup cũng sống sót qua một lần hangup, nhưng nó không chừa đường quay lại: bạn không thể attach lại vào một TUI đã bị đẩy xuống chạy nền. Claude Code là chương trình tương tác, nên tmux (hoặc screen) mới là công cụ đúng.
Chọn cấu hình máy
CLI là một tiến trình Node, và nó không phải thứ ngốn tài nguyên. Thứ ngốn tài nguyên là những gì agent chạy thay bạn: một lần build, một bộ test đầy đủ, tsc, một language server, một database trong Docker. Hãy chọn cấu hình theo toolchain, đừng chọn theo CLI. Thêm swap kể cả khi bạn định không bao giờ đụng tới nó, vì nó biến chuyện bị OOM killer giết thẳng tay thành một lần build chậm:
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/fstabCũng để mắt tới ổ đĩa: repo, node_modules và image Docker phình lên rất nhanh. Còn nếu toolchain vượt khỏi container để chạm tới máy ảo đầy đủ, ví dụ một guest KVM hay một node Kubernetes cục bộ, hãy kiểm tra xem gói dịch vụ có mở phần mở rộng ảo hóa của CPU hay không trước khi xuống tiền, bởi chạy ảo hóa lồng nhau trên VPS là thứ nhà cung cấp bật cho bạn chứ không phải thứ bạn tự bật từ bên trong guest.
Trước hết là một user không phải root
Tạo một user riêng với thư mục home của nó, rồi đặt public key của bạn vào đúng chỗ:
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_keysagent cố ý không nằm trong nhóm sudo. Cần cài gói hệ thống nào thì bạn tự cài. Chỉ một quyết định đó đã loại bỏ phần lớn các cách mà một câu lệnh shell đi lạc có thể phá hỏng máy chủ.
Siết SSH cho máy chạy suốt ngày đêm
Xác thực bằng mật khẩu trên một cái máy phơi ra internet công cộng cả ngày, đang giữ một agent và mã nguồn của bạn, là rủi ro không đáng chuốc lấy. Tắt nó đi. Trên Ubuntu 24.04 và Debian 13, /etc/ssh/sshd_config có include /etc/ssh/sshd_config.d/*.conf, nên hãy thả một file vào đó thay vì sửa file cấu hình chính:
# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin noKiểm tra rồi nạp lại, và giữ nguyên phiên hiện tại trong lúc bạn thử một phiên mới từ terminal thứ hai:
sudo sshd -t && sudo systemctl restart sshMột điểm cần lưu ý trên Ubuntu 24.04: sshd được kích hoạt qua socket. Thiết lập xác thực có hiệu lực khi chạy systemctl restart ssh, nhưng nếu bạn đổi Port đang lắng nghe thì còn cần systemctl daemon-reload và khởi động lại ssh.socket.
Tiếp đến là firewall. Cho phép SSH trước khi bật nó, nếu không bạn tự khóa mình ở ngoài:
sudo ufw allow OpenSSH
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enableCài fail2ban, nhưng hãy tỉnh táo về thứ nó thực sự mang lại: một khi xác thực mật khẩu đã tắt thì brute force vốn không thể thành công. Nó chỉ giúp journal của bạn khỏi ngập những lần đăng nhập hỏng.
# /etc/fail2ban/jail.local
[sshd]
enabled = true
backend = systemd
maxretry = 5
bantime = 1hCuối cùng, vá lỗi tự động bằng sudo apt install unattended-upgrades và sudo dpkg-reconfigure -plow unattended-upgrades. Để ý chỗ nó đụng vào tmux: bật Unattended-Upgrade::Automatic-Reboot thì một bản cập nhật kernel sẽ khởi động lại máy, kéo theo mọi phiên đang mở. Cứ để tắt và tự khởi động lại theo lịch của bạn, vào lúc không có việc gì đang chạy dở.
Cài Node.js và Claude Code trên Ubuntu
Claude Code là một CLI viết bằng Node, nên bạn cần bản Node đủ mới. Gói trong repo của distro thường tụt lại phía sau; trên Ubuntu và Debian thì NodeSource là đường quen thuộc, và nó cung cấp repo đã ký (không còn apt-key, công cụ đó đã bị bỏ):
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
node --versionGiờ tới chỗ nhiều người làm sai: hãy cài CLI dưới quyền user agent, đừng bao giờ dùng sudo npm -g. Một prefix global do root sở hữu sẽ khiến bạn dính lỗi quyền về sau và để lại file thuộc root trong cache của npm. Trước hết trỏ prefix của npm về home của user:
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 --versionDòng export nằm trong ~/.bashrc, không phải ~/.profile, và nó phải đứng phía trên đoạn chặn "If not running interactively, don't do anything" gần đầu file: tmux có thể khởi động shell không phải login shell, loại shell này đọc ~/.bashrc và bỏ qua ~/.profile, vì ~/.profile chỉ chạy với login shell. Cài Node riêng cho từng user bằng một version manager như nvm cũng cho kết quả tương tự; dù theo cách nào thì mục tiêu vẫn là npm install -g không bao giờ cần sudo. npm vẫn chạy tốt, hoặc bạn dùng script cài đặt native của Anthropic, hiện là cách mặc định trong tài liệu. Hãy xem tài liệu cài đặt của Anthropic trước khi dán lệnh, vì cách cài có thay đổi.
Chạy claude bên trong một repo để khởi động. Lần chạy đầu sẽ dẫn bạn qua bước xác thực; máy chủ headless không có trình duyệt, nên luồng này đưa cho bạn một URL để mở trên máy của mình và một mã để mang về dán vào terminal. (Đặt API key trong biến môi trường là cách còn lại.) Dù theo cách nào, thông tin đăng nhập đó giờ nằm trên máy chủ, và đó chính là phần nhiều người bỏ qua.
Nói thẳng về phạm vi thiệt hại
Một agent có quyền chạy shell chính là một shell. Nó đọc được mọi thứ mà user chạy nó đọc được, và push được tới bất cứ đâu user đó push được. Nói vậy không phải để chê công cụ, đó là bản chất của nó, và cũng là lý do cái tài khoản mà nó chạy dưới quyền lại quan trọng hơn bất kỳ thiết lập đơn lẻ nào.
- User riêng, không đặc quyền. Không nằm trong nhóm
sudo, không dùng chung thư mục home với tài khoản của bạn. - Không để credential production trên máy. Không có
~/.aws/credentialschứa key production, không có.envchép xuống từ production, không có mật khẩu database có quyền ghi vào bất cứ thứ gì quan trọng. Hãy cấp cho agent credential của môi trường staging hoặc credential chỉ đọc. - Token giới hạn phạm vi. Một fine-grained token của GitHub chỉ dùng được cho một repository; dùng deploy key khi quyền đọc là đủ.
Claude Code có một flag bỏ qua hoàn toàn các bước hỏi quyền của nó. Trên laptop, với một dự án bỏ đi, thì tùy bạn. Trên máy chủ đang giữ token, nó gỡ nốt thứ cuối cùng đứng giữa một chỉ dẫn bị hiểu sai và một lệnh git push --force. Bài chạy Claude Code an toàn trên máy chủ nói rõ flag đó thực sự thay đổi những gì, và cách khoanh vùng một agent chạy kèm nó, từ sandbox có sẵn cho tới một VPS dùng một lần.
Deploy key so với SSH agent forwarding
Rất dễ nảy ra ý dùng ssh -A để git xài luôn key trên laptop của bạn. Nhưng hãy hiểu rõ làm vậy là cấp quyền gì: agent forwarding phơi socket của SSH agent trên máy bạn ra cho mọi tiến trình chạy dưới quyền user đó trên máy chủ. Bất cứ thứ gì chạy dưới quyền agent, kể cả bản thân con agent, đều có thể nhờ key của bạn ký cho bất kỳ host nào nó với tới được, chừng nào bạn còn kết nối. Như vậy là nhiều hơn hẳn mức "cho git pull đúng một repo này".
Thay vào đó, hãy tạo key ngay trên máy chủ, đăng ký nó làm deploy key cho riêng từng repository (chỉ cấp quyền ghi nếu agent cần push), và đặt danh tính git để commit từ máy này nhìn là biết:
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"Quy trình làm việc với tmux
Cài nó (sudo apt install tmux), rồi tạo một ~/.tmux.conf tối giản:
set -g mouse on
set -g history-limit 50000
set -g default-terminal "tmux-256color"Bốn lệnh là đủ cho việc dùng hằng ngày:
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 claudetmux new -A -s claude là lệnh cần thuộc lòng: nó attach nếu phiên đã tồn tại và tạo mới nếu chưa, nên một lệnh lo được cả việc bắt đầu ngày làm việc lẫn việc quay lại sau khi rớt mạng. Hãy đặt alias cho nó. Bên trong một phiên, Ctrl-b c mở một window mới, Ctrl-b n và Ctrl-b p chuyển qua lại giữa các window, còn Ctrl-b [ vào chế độ copy để cuộn ngược (q để thoát).
Một điều cần biết về những phiên bạn không bao giờ kill: agent gửi lại toàn bộ cuộc hội thoại ở mỗi lượt, nên hãy đọc một phiên Claude Code chạy dài tiêu token vào đâu trước khi để nó chạy cả tuần.
Các kiểu hỏng thường gặp
"Phiên của tôi biến mất." tmux ls in ra no server running on /tmp/tmux-1000/default. Gần như lúc nào cũng vậy: tiến trình chưa bao giờ nằm trong tmux. Bạn SSH vào, chạy thẳng claude, và lần mất kết nối đã giết nó. Không có gì để khôi phục. Thói quen phòng tránh: tmux new -A -s <project> là lệnh đầu tiên sau mỗi lần đăng nhập.
Pane co lại thành một ô bé xíu. tmux lấy kích thước phiên theo client nhỏ nhất đang attach, nên một client cũ vẫn còn attach từ máy khác sẽ bóp màn hình lại. Hãy đẩy các client kia ra ngay khi bạn attach: tmux attach -d -t claude.
Bản build in ra Killed. Đúng một chữ, không có stack trace. Xác nhận bằng sudo dmesg -T | grep -i -E 'out of memory|killed process': OOM killer của kernel đã chọn tiến trình to nhất. Còn từ phía Node, thứ bạn thấy có thể lại là FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory. Cách sửa, theo thứ tự: thêm swap (ở trên), giảm mức chạy song song của test và trình biên dịch, nâng heap của Node bằng NODE_OPTIONS=--max-old-space-size=..., hoặc nâng cấu hình VPS. OOM killer cũng có thể chọn tmux server thay vì tiến trình build, kéo theo cả phiên của bạn; nếu systemd-oomd đang chạy, nó có thể giết cả một user slice với hậu quả tương tự.
npm error code EACCES / permission denied, mkdir '/usr/lib/node_modules/...'. Đây là lỗi cài global vào một prefix do root sở hữu. Hãy dùng prefix ~/.npm-global ở trên. Nếu trước đó bạn từng chạy sudo npm, bạn có thể còn thấy Your cache folder contains root-owned files; sửa bằng sudo chown -R $(id -u):$(id -g) ~/.npm.
claude: command not found, nhưng chỉ thỉnh thoảng. Dòng export PATH của bạn nằm trong ~/.bashrc, phía dưới đoạn chặn "If not running interactively, don't do anything", nên shell không tương tác bỏ qua nó. Hãy chuyển dòng export lên trên đoạn đó và giữ nó trong ~/.bashrc, không phải ~/.profile: tmux có thể khởi động shell không phải login shell, loại này đọc ~/.bashrc và không bao giờ đụng tới ~/.profile.
Màu sắc loạn xạ sau khi attach. Đây là lệch TERM, và dòng default-terminal ở trên là cách sửa.
Phiên biến mất sau khi khởi động lại máy. Không phải lỗi: tmux server là một tiến trình, và reboot thì tiến trình kết thúc. Kiểm tra bằng uptime.
Cái gì sẽ vỡ khi quy mô lớn dần
Nhiều dự án hơn. Mỗi repo một phiên tmux, đặt tên theo repo; khi đó tmux ls chính là bảng điều khiển của bạn. Bỏ qua kỷ luật đặt tên thì bạn sẽ có các phiên tên 0, 1, 2. Cổng cũng loang ra theo kiểu tương tự: sáu repo cùng đòi :3000 là lúc nên dừng việc gán cổng bằng tay và để một reverse proxy Traefik định tuyến nhiều ứng dụng dưới Docker Compose phân phối theo hostname.
Nhiều người hơn. Socket của tmux là riêng theo từng user, nên hai lập trình viên trên cùng một máy mỗi người có tmux server riêng và không thấy phiên của nhau. Dùng chung một phiên qua một socket dùng chung nghĩa là mọi người gõ vào cùng một shell dưới cùng một user Unix, kèm theo hệ quả về nhật ký kiểm toán và quyền hạn. Tách user riêng là câu trả lời nhàm chán và đúng.
Việc chạy không có người trông. tmux dành cho các phiên tương tác mà bạn attach vào. Việc chạy theo lịch, không ai ngồi xem, thì thuộc về một unit và timer của systemd, ở đó chúng có sẵn logging, chính sách khởi động lại và khả năng sống sót qua reboot. Phải lôi tmux ra để chạy một việc dạng cron là dấu hiệu việc đó muốn thành một service.
Một lưu ý cuối: hãy bind các dev server mà agent khởi động vào 127.0.0.1 chứ không phải 0.0.0.0, rồi truy cập chúng qua một SSH tunnel (ssh -L 3000:127.0.0.1:3000 agent@your-server) thay vì mở cổng trong ufw. Khi bạn đã phải forward tới nửa tá cổng, hoặc một điện thoại và một laptop cùng muốn xem một bản preview, hãy đặt một VPN WireGuard tự dựng trên VPS ở phía trước: dev server bind vào một interface riêng, còn ufw vẫn từ chối mọi thứ đến từ interface công cộng. Firewall chỉ có ích nếu bạn ngừng chọc thủng nó.
Claude Code không phải lựa chọn duy nhất: bài chạy một AI agent lập trình trên VPS cân đo thêm cả Aider và Goose.
FAQ
Claude Code có chạy tiếp sau khi kết nối SSH của tôi rớt không?
Chỉ khi bạn đã khởi động nó bên trong tmux. Một tiến trình chạy thẳng từ shell SSH là con của shell đó và chết cùng pty khi đường truyền đứt. Bên trong tmux, shell thuộc về tmux server đang chạy tách rời, nên agent vẫn làm tiếp việc dở dang và tmux attach đưa bạn về đúng scrollback cũ. Hãy biến tmux new -A -s <project> thành lệnh đầu tiên sau mỗi lần đăng nhập, vấn đề sẽ biến mất.
Tôi có nên cài CLI bằng sudo npm install -g không?
Không. Một prefix global do root sở hữu sẽ khiến các lần cài sau dính lỗi EACCES và để lại file thuộc root trong cache của npm. Hãy đặt prefix của npm thành ~/.npm-global (hoặc dùng version manager như nvm), cài dưới quyền user agent không đặc quyền, rồi export ~/.npm-global/bin vào PATH từ ~/.bashrc, phía trên đoạn chặn shell tương tác. Nếu bạn từng chạy sudo npm một lần, hãy sửa cache bằng sudo chown -R $(id -u):$(id -g) ~/.npm.
Dùng agent forwarding ssh -A trên máy đang chạy agent có an toàn không?
Nó cấp nhiều quyền hơn hẳn mức công việc cần. Forwarding phơi socket của SSH agent trên máy bạn ra cho mọi tiến trình chạy dưới quyền user đó, nên bất cứ thứ gì trên máy chủ cũng có thể nhờ key của bạn ký cho bất kỳ host nào nó với tới được, suốt thời gian bạn còn attach. Hãy tạo một key ed25519 ngay trên máy chủ và đăng ký nó làm deploy key cho riêng từng repository, chỉ cấp quyền ghi nếu agent thật sự phải push.
Tại sao bản build của tôi chỉ in ra Killed?
Đúng một chữ, không stack trace, đó là OOM killer của kernel. Xác nhận bằng sudo dmesg -T | grep -i -E 'out of memory|killed process'; còn từ phía Node thì thứ bạn thấy có thể là JavaScript heap out of memory. Hãy làm theo thứ tự: thêm swapfile, giảm mức chạy song song của test và trình biên dịch, nâng NODE_OPTIONS=--max-old-space-size=..., rồi mới nâng cấu hình VPS. Coi chừng OOM killer có thể chọn tmux server thay vì tiến trình build, kéo theo cả phiên của bạn.
tmux hay một service systemd?
tmux hợp với các phiên tương tác mà bạn attach vào, ngồi xem và gõ lệnh, và đó đúng là bản chất của một phiên agent. Việc chạy theo lịch mà không ai trông thì thuộc về một unit và timer của systemd, nơi logging, chính sách khởi động lại và khả năng sống sót qua reboot đều có sẵn. Nếu bạn đang định dùng tmux để chạy một việc dạng cron, thì việc đó muốn thành một service.