Run Claude Code on a remote VPS with tmux
Run Claude Code on an always-on Linux VPS inside tmux so agent sessions survive a dropped SSH connection. Install, harden, and the failure modes to expect.
The problem is the laptop lid, not the CLI
Claude Code runs fine on your laptop until you close it: the SSH session dies, the shell gets a SIGHUP, and the agent three minutes into a test run dies with it. Run the CLI on a machine that never sleeps, inside a terminal multiplexer whose processes are not children of your SSH session. That is the whole trick — and tmux, not the install, is the load-bearing part.
This is a page about operating a box you leave agents running on. If you have no Linux server you can leave switched on, none of it applies. That is the one honest prerequisite.
What tmux actually does
When you SSH in, sshd forks a shell and hands it a pseudo-terminal; everything you start from that shell is a child of it. Drop the connection and the kernel tears down the pty, the shell gets SIGHUP, and it hangs up on its children in turn. Long-running foreground processes die.
tmux inverts the ownership. The tmux command you type is a thin client talking over a unix socket to a tmux server that runs detached from your terminal. Shells inside a session are children of that server, not of sshd. Kill the SSH connection and the client goes away while the server, the session and the agent mid-task keep running. Reconnect, tmux attach, and you are back in the same shell with the same scrollback. nohup survives a hangup too, but gives you no way back in — you cannot re-attach to a backgrounded TUI. Claude Code is interactive; tmux (or screen) is the right tool.
Sizing the box
The CLI is a Node process; it is not what fills the machine. What fills the machine is whatever the agent runs on your behalf: a build, a full test suite, tsc, a language server, a database in Docker. Size for the toolchain, not the CLI. Add swap even if you plan never to touch it — it turns a hard OOM kill into a slow build:
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
Watch the disk too: repos, node_modules and Docker images accumulate fast. And if the toolchain reaches past containers into full virtual machines — a KVM guest, a local Kubernetes node — check the plan exposes the CPU virtualisation extensions before you commit, since running nested virtualization on a VPS is something the provider enables for you rather than something you switch on from inside the guest.
A non-root user first
Create a dedicated user with its own home, and put your public key in place:
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
Deliberately, agent is not in the sudo group. If a system package is needed, you install it. That one decision removes most of the ways a stray shell command can wreck the host.
SSH hygiene for a box you leave running
Password auth on a machine that sits on the public internet all day, holding an agent and your source code, is not a risk worth carrying. Turn it off. On Ubuntu 24.04 and Debian 13, /etc/ssh/sshd_config includes /etc/ssh/sshd_config.d/*.conf, so drop a file in rather than editing the main config:
# /etc/ssh/sshd_config.d/10-hardening.conf
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin no
Validate and reload — keeping your current session open while you test a new one from a second terminal:
sudo sshd -t && sudo systemctl restart ssh
A nuance on Ubuntu 24.04: sshd is socket-activated. Auth settings apply on systemctl restart ssh, but a change to the listening Port also needs systemctl daemon-reload and a restart of ssh.socket.
Then the firewall. Allow SSH before you enable it, or you lock yourself out:
sudo ufw allow OpenSSH
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable
Install fail2ban with a clear head about what it buys you: once password auth is off, brute force cannot succeed anyway — it keeps failed attempts out of your journal.
# /etc/fail2ban/jail.local
[sshd]
enabled = true
backend = systemd
maxretry = 5
bantime = 1h
Finally, patch automatically with sudo apt install unattended-upgrades and sudo dpkg-reconfigure -plow unattended-upgrades. Note the interaction with tmux: switch on Unattended-Upgrade::Automatic-Reboot and a kernel update reboots the box, taking every session with it. Leave it off and reboot on your own schedule, when nothing is mid-run.
Installing Node and the CLI
Claude Code is a Node CLI, so you need a current Node. The distro package often lags; NodeSource is the usual route on Ubuntu and Debian, and it ships a signed repo (no apt-key — that tool is gone):
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node --version
Now the part people get wrong: install the CLI as your agent user, never with sudo npm -g. A root-owned global prefix produces permission errors later and leaves root-owned files in the npm cache. Point npm's prefix at the user's home first:
mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH="$HOME/.npm-global/bin:$PATH"' >> ~/.profile
source ~/.profile
npm install -g @anthropic-ai/claude-code
claude --version
A per-user Node via a version manager such as nvm achieves the same thing; the goal either way is that npm install -g never needs sudo. Check Anthropic's install docs before you paste — install methods change.
Run claude inside a repo to start it. The first run walks you through authentication; a headless box has no browser, so the flow hands you a URL to open on your own machine and a code to bring back to the terminal. (An API key in the environment is the other route.) Either way, that credential now lives on the server — which brings us to the part people skip.
The blast radius conversation
An agent with shell access is a shell. It can read anything the user it runs as can read, and push anywhere that user can push. That is not a criticism of the tool, it is the definition of it — and it is why the account it runs under matters more than any individual setting.
- Dedicated, unprivileged user. No
sudogroup, no home directory shared with your own account. - No production credentials on the box. No
~/.aws/credentialsholding prod keys, no.envcopied down from production, no database password with write access to anything that matters. Give the agent a staging or read-only credential. - Scoped tokens. A fine-grained GitHub token limited to one repository; a deploy key when read access is enough.
Claude Code ships a flag that skips its permission prompts entirely. On a laptop, on a throwaway project, that is your call. On a server holding tokens, it removes the last thing standing between a misread instruction and a git push --force.
Deploy key vs SSH agent forwarding
It is tempting to ssh -A in so git can use the key on your laptop. Understand what that grants: agent forwarding exposes your local SSH agent's socket to processes running as that user on the box. Anything running as agent — including the agent — can ask your key to sign for any host it can reach, as long as you stay connected. That is far more than "let git pull this one repo".
Generate a key on the server instead, register it as a per-repository deploy key (write access only if the agent needs to push), and set a git identity so commits from the box are recognisable:
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 "[email protected]"
The tmux workflow
Install it (sudo apt install tmux), then a minimal ~/.tmux.conf:
set -g mouse on
set -g history-limit 50000
set -g default-terminal "tmux-256color"
Four commands cover daily use:
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 is the one to memorise — it attaches if the session exists and creates it if it doesn't, so one command covers both starting the day and resuming after a drop. Alias it. Inside a session, Ctrl-b c opens a window, Ctrl-b n and Ctrl-b p cycle them, and Ctrl-b [ enters copy mode to scroll back (q exits).
Failure modes
"My session is gone." tmux ls prints no server running on /tmp/tmux-1000/default. This nearly always means the process was never inside tmux — you SSH'd in, ran claude directly, and the disconnect killed it. Nothing to recover. The habit that prevents it: tmux new -A -s <project> is the first command after every login.
The pane shrinks to a tiny box. tmux sizes a session to the smallest attached client, so a stale client still attached from another machine squeezes the display. Force the others off as you attach: tmux attach -d -t claude.
A build prints Killed. One word, no stack trace. Confirm with sudo dmesg -T | grep -i -E 'out of memory|killed process' — the kernel OOM killer picked the biggest process. From Node you may instead see FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory. Fixes, in order: add swap (above), cap test and compiler parallelism, raise Node's heap with NODE_OPTIONS=--max-old-space-size=..., or size the VPS up. The OOM killer may also pick the tmux server rather than the build, taking your session with it; if systemd-oomd is running, it can kill a whole user slice with the same effect.
npm error code EACCES / permission denied, mkdir '/usr/lib/node_modules/...'. A global install into a root-owned prefix. Use the ~/.npm-global prefix above. If you already ran sudo npm at some point you may also see Your cache folder contains root-owned files — repair with sudo chown -R $(id -u):$(id -g) ~/.npm.
claude: command not found — but only sometimes. Your PATH export sits in ~/.bashrc below the "If not running interactively, don't do anything" guard, so non-interactive shells skip it. Put PATH in ~/.profile, and remember tmux may start a non-login shell depending on your config.
Garbled colours after attaching. A TERM mismatch — the default-terminal line above is the fix.
Sessions vanish after a reboot. Not a bug: the tmux server is a process, and a reboot ends it. Check uptime.
What breaks as this grows
More projects. One tmux session per repo, named after it; tmux ls is then your dashboard. Skip the naming discipline and you get sessions 0, 1, 2. Ports sprawl the same way — six repos all wanting :3000 is the point to stop hand-assigning them and let a Traefik reverse proxy route multiple apps under Docker Compose do the dispatching by hostname.
More people. tmux sockets are per-user, so two developers on the same box each get their own tmux server and cannot see each other's sessions. Sharing one session over a shared socket means everyone types into the same shell as the same Unix user, with the audit and permission consequences that implies. Separate users is the boring, correct answer.
Unattended work. tmux is for interactive sessions you attach to. Jobs that run on a schedule with nobody watching belong in a systemd unit and timer, where they get logging, a restart policy and boot survival for free. Reaching for tmux to run a cron-shaped job is a sign the job wants to be a service.
One last note: bind dev servers the agent starts to 127.0.0.1, not 0.0.0.0, and reach them over an SSH tunnel (ssh -L 3000:127.0.0.1:3000 agent@your-server) rather than opening ports in ufw. Once you are forwarding half a dozen ports, or a phone and a laptop both want at the same preview, put a self-hosted WireGuard VPN on the VPS in front of them instead: the dev servers bind to a private interface, and ufw keeps denying everything from the public one. The firewall only helps if you stop punching holes in it.
FAQ
Does Claude Code keep running after my SSH connection drops?
Only if you started it inside tmux. A process launched straight from the SSH shell is a child of that shell and dies with the pty when the link goes. Inside tmux the shell belongs to the detached tmux server, so the agent keeps working mid-task and tmux attach drops you back into the same scrollback. Make tmux new -A -s <project> the first command after every login and the problem goes away.
Should I install the CLI with sudo npm install -g?
No. A root-owned global prefix hands you EACCES errors on later installs and root-owned files in the npm cache. Set npm's prefix to ~/.npm-global (or use a version manager like nvm), install as the unprivileged agent user, and export ~/.npm-global/bin onto PATH from ~/.profile. If you already ran sudo npm once, repair the cache with sudo chown -R $(id -u):$(id -g) ~/.npm.
Is ssh -A agent forwarding safe on a box running an agent?
It grants far more than the job needs. Forwarding exposes your local SSH agent's socket to every process running as that user, so anything on the box can ask your key to sign for any host it can reach for as long as you stay attached. Generate an ed25519 key on the server and register it as a per-repository deploy key, with write access only if the agent actually has to push.
Why does my build just print Killed?
One word with no stack trace is the kernel OOM killer. Confirm it with sudo dmesg -T | grep -i -E 'out of memory|killed process'; from Node you may see JavaScript heap out of memory instead. Work through the fixes in order: add a swapfile, cap test and compiler parallelism, raise NODE_OPTIONS=--max-old-space-size=..., then size the VPS up. Watch out that the OOM killer can pick the tmux server rather than the build, taking your whole session with it.
tmux or a systemd service?
tmux fits interactive sessions you attach to, watch and type into, which is exactly what an agent session is. Work that runs on a schedule with nobody watching belongs in a systemd unit and timer, where logging, a restart policy and boot survival come for free. If you are reaching for tmux to run a cron-shaped job, the job wants to be a service.