Running Claude Code Securely on Your Own VPS
A hardened Debian VPS keeps Claude Code sessions persistently available. This guide covers everything from user accounts and SSH keys to firewalls, data hygiene, tmux, and secure access from an iPhone.
On your own computer, a Claude Code session ends unexpectedly at the latest when the laptop goes to sleep or the network connection drops. A VPS keeps running and can be reached from multiple devices. At the same time, it is permanently connected to the public internet and is automatically scanned shortly after startup.
This guide combines both requirements: Claude Code remains available in a tmux session, while the Debian server offers only a key-protected SSH connection to the outside world. The hardening is not specific to Claude and is also suitable for other publicly accessible Linux servers.
Why a VPS can make sense
Compared with a purely local installation, the server offers three practical advantages:
- Persistence. In a
tmuxsession, Claude keeps running even if the SSH connection is disconnected. A task that takes ten minutes or an hour finishes without the laptop needing to remain open. - Accessibility. The same session can be accessed from a desktop, laptop, and iPhone. You start a task at your desk and check the result while on the go.
- Data control. You decide what is stored on the server. No sync service, no credentials accidentally backed up along with it—provided you migrate carefully (see below).
tmux is purely an availability and convenience feature, not a security measure. The actual work lies in securing the system.
Starting point
The foundation is Debian 13 (Trixie), installed minimally, without a desktop environment or additional network services. The provider supplies an upstream firewall that operates independently of the operating system. The goal is a server on which only SSH is reachable from the outside—and even that only with passphrase-protected keys.
1. Update the system
Immediately after installation, update all packages:
sudo apt update
sudo apt full-upgrade
Unlike upgrade, full-upgrade also resolves dependencies that require new or removed packages. On a fresh system, this is the right approach to install all available security updates. Restart once after kernel updates.
2. Use a dedicated user instead of root
Working as root is unnecessarily risky: every typo affects the entire system, and direct root login is the first thing automated attacks try. Therefore, create a dedicated user (here, claude) with sudo privileges for the cases where they are needed:
sudo adduser claude
sudo usermod -aG sudo claude
From now on, all administration is done through claude and sudo, no longer through direct root access.
3. Passphrase-protected Ed25519 keys, one per device
Logins should use SSH keys exclusively, not passwords. Ed25519 is the current standard: short, fast, and cryptographically sound. Crucially, the key is generated on the client—that is, on the PC, not the server—and protected with a passphrase. The passphrase is the second line of defense if the private key ever falls into the wrong hands.
On the PC:
ssh-keygen -t ed25519 -C "pc-thinkpad"
The comment (-C) identifies the device. This pays off later: generate a separate key for every device—one for the PC and another for the iPhone. If a device is lost, remove only its public key from ~/.ssh/authorized_keys without having to redeploy all other access keys.
Only the public key belongs on the server. The private key never leaves the device. In authorized_keys, there are ultimately only public keys, each with its device comment:
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...pc pc-thinkpad
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...ios iphone-15
Transfer the public PC key initially. As long as password login is still active, the easiest way is:
ssh-copy-id claude@SERVER
Then test that key-based login works before disabling password login in the next step. File permissions must be correct, otherwise sshd ignores the file: ~/.ssh must be set to 700, and authorized_keys to 600.
4. Harden SSH: no root, no password
The server configuration is located in /etc/ssh/sshd_config and, on Debian 13, in drop-in files under /etc/ssh/sshd_config.d/. Changes belong in a dedicated drop-in file; this leaves the main file untouched and prevents package updates from overwriting anything. Create the file /etc/ssh/sshd_config.d/99-haertung.conf:
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
This disables direct root login and password authentication. From now on, only someone with a matching private key can get in. Before reloading, check the configuration syntax:
sudo sshd -t
If sshd -t reports nothing, the file is valid. Only then reload:
sudo systemctl reload ssh
Important: Keep the existing SSH session open and test the new access in a second terminal. Only close the old session once key-based login is demonstrably working there. This precaution reduces the risk of locking yourself out to virtually zero. A configuration error would otherwise cost you all access.
5. Move SSH to an uncommon port
Bots probe the standard port 22 around the clock. Switching to a high, freely chosen port (61417 in this example) causes most of this automated noise to lead nowhere. This is explicitly not a security gain in the strict sense: changing ports does not replace strong authentication; it only reduces log volume and scanning load. The key requirement from step 4 remains the real protection.
The port choice is not arbitrary. IANA distinguishes three ranges: 0–1023 (well-known ports) are reserved for standard services (SSH itself on 22, HTTP on 80, HTTPS on 443), require root privileges to bind, and have no place as a custom SSH port; scanners expect these ports, as do standard services installed later. 1024–49151 (registered ports) are assigned to individual applications upon request, such as 3306 (MySQL), 5432 (PostgreSQL), 6379 (Redis), or 8080/8443 as common HTTP alternatives; a randomly chosen port in this range can easily conflict later with software expecting its registered port. 49152–65535 (dynamic/private ports) are not assigned to any service according to IANA and are intended for temporary, private purposes—the right range for a permanent custom port.
One caveat remains: many Linux systems, including Debian, use part of the same range as source ports for their own outbound connections (net.ipv4.ip_local_port_range, by default around 32768–60999). A permanently listening service does not truly conflict with this, since the kernel does not assign an already bound port, but a port above 60999 also avoids this theoretical ambiguity. The example in this article (61417) is deliberately in that range. Before switching, also use ss -lntup (see step 7) to verify that the selected port is not already in use on your server.
There is a special consideration on Debian 13: SSH can be started through systemd socket activation. If that is the case, the Port setting in sshd_config is simply ignored; the port must instead be set on the socket. First check which case applies:
systemctl is-enabled ssh.socket
If the command returns enabled, SSH runs through the socket. Change the port there:
sudo systemctl edit ssh.socket
Enter the following lines in the editor. The first, empty ListenStream= line clears the preset port 22; the second sets the new one:
[Socket]
ListenStream=
ListenStream=61417
Then apply the changes:
sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
If socket activation is not active (disabled), add Port 61417 to the drop-in file from step 4 instead, followed by sudo sshd -t and sudo systemctl restart ssh.
The same applies here: first open the new port in the firewall (next step), then connect and test it, keeping the old session open until access over the new port has been confirmed.
6. Firewall: closed by default
The upstream provider firewall is the most effective boundary because it intercepts packets before they even reach the operating system. Two basic rules apply:
- Set the default action for incoming traffic to DROP. Anything not explicitly allowed is discarded silently, without sending feedback to the sender.
- One exception only: incoming TCP traffic on target port
61417. Nothing else needs to be reachable from the outside.
Outbound traffic remains allowed. This is intentional: the server must download packages, synchronize the time, and reach the API for Claude Code. Restricting outbound traffic offers little additional protection for a single server, but makes operation noticeably more cumbersome.
For additional defense in depth, you can duplicate the same rules on the host using nftables or ufw. For the setup described here, the provider firewall is sufficient.
7. Check the attack surface
After hardening, verify what the server is actually exposing externally. Two commands are enough. First: Which services are listening on which addresses?
sudo ss -lntup
The address column is crucial: a service on 0.0.0.0 or [::] is reachable from the outside, while one on 127.0.0.1 or [::1] is local only. In the secured state, only SSH should appear publicly. Services such as chronyd (time synchronization) may appear, but only bound to local addresses. If chronyd listens exclusively on 127.0.0.1 and ::1, it cannot be reached from the outside and is therefore not an issue.
Second: Are there failed system services that indicate a configuration problem?
systemctl --failed
The response should be 0 loaded units listed, with not a single failed service. Failed units are not only an operational issue, but potentially also a security issue if they involve a partially started, misconfigured network service.
8. Install and run Claude Code
Claude Code requires a current Node.js runtime environment. After installing it, set up the CLI according to the official documentation and authenticate anew on the server; do not upload local credentials (more on that in a moment).
For persistent operation, use tmux:
tmux new -s claude
Start Claude within the session. Pressing Ctrl-b, then d detaches from the session without ending it; Claude keeps running. Return with:
tmux attach -t claude
This allows a running task to survive disconnected connections, switching devices, and the laptop sleeping overnight.
9. Data hygiene during migration
The most sensitive part of moving to a server is not the technology, but deciding what to bring along. Three rules apply:
- No private keys on the server. Only public keys are stored in
authorized_keys. Private keys remain on endpoint devices. - Do not copy credentials indiscriminately. Sensitive local files such as a
.credentials.jsondo not belong on the VPS without review. Instead, authenticate anew on the server. - Move configuration to a migration folder first. Do not write existing Claude memories and configuration directly into active configuration paths. Instead, transfer them first to a separate migration folder and review what should actually be adopted. Anything no longer needed, such as old MCP entries or orphaned settings, is deliberately left behind rather than carried over without review.
10. Web previews through an SSH tunnel
For web previews, such as a local development server started by Claude, it is tempting to simply open another port. Do not do that. Every additional open port increases the attack surface. Instead, run the preview through an encrypted SSH port tunnel: the service listens only locally on the server, and SSH forwards it to the client.
From the PC, make a service running locally on port 4321 accessible:
ssh -p 61417 -L 4321:localhost:4321 claude@SERVER
Then open http://localhost:4321 in the local browser. All traffic runs through the existing, authenticated SSH connection, without opening even a single additional port in the firewall.
Access from an iPhone
Access while on the go follows the same security model as from a PC. You only need an SSH client with key management. Popular choices include Termius, Blink Shell, and Secure ShellFish; all can generate Ed25519 keys and store them in the iOS keychain, in some cases protected with Face ID.
The procedure is the same as in step 3, only on the iPhone:
- Generate a dedicated Ed25519 key for the iPhone in the SSH client; do not copy the PC key. The private key remains in the device’s keychain.
- Add the iPhone’s public key as an additional line in
~/.ssh/authorized_keyson the server, with a descriptive comment (iphone-15). - Create the connection in the client: server address, user
claude, port61417, and the iPhone key for authentication.
This is exactly why a separate key per device is worthwhile: if the iPhone is lost, delete the single iphone-15 line from authorized_keys on the server, and the device is locked out while PC access and all other keys continue to work unaffected.
After connecting, resume the running Claude session with tmux attach -t claude and continue where you left off at your desk. The port tunnel from step 10 also works from iOS; Termius and Secure ShellFish support port forwarding.
Checklist
Here is the complete process in summary:
- Installed Debian 13 and fully updated it with
apt full-upgrade. - Created dedicated user
claudewith sudo privileges; direct root login is no longer used. - Used passphrase-protected Ed25519 keys, one per device; only public keys in
authorized_keys. - Hardened sshd:
PermitRootLogin no,PasswordAuthentication no; checked withsshd -tbefore reloading and kept the existing session open until testing was complete. - Moved SSH to port 61417, configured on
ssh.socketwhen using socket activation, otherwise in the sshd configuration. - Provider firewall: incoming default DROP, with TCP 61417 as the only exception; outbound traffic allowed.
- Checked the attack surface with
ss -lntup(only SSH public,chronydlocal) andsystemctl --failed(no errors). - Authenticated Claude Code anew on the server and ran it in a
tmuxsession. - Maintained data hygiene: no private keys or credentials on the server; reviewed configuration through a migration folder first.
- No additional ports; web previews run through an SSH tunnel.
After this setup, only SSH on the specified port is reachable from the outside, and only with a passphrase-protected key. Claude Code runs independently of the endpoint device in tmux; web previews remain accessible through SSH tunnels without opening an additional port.
Comments
Comments are loaded from GitHub / Giscus.