Always-on Claude Code sessions with Remote Control and systemd
Run Claude Code on a Linux box as systemd services, and drive it from claude.ai or your phone — even after a reboot.
Claude Code’s Remote Control lets you drive a session running on your own machine from claude.ai/code or the mobile app. The code, the tools and the credentials stay on your machine; the browser is just a window onto it.
The catch: a Remote Control session only lives as long as the claude process
behind it. Close the terminal, lose the SSH connection or reboot, and it’s gone.
This post is how I turned mine into always-on systemd services that survive
all of that. It’s verified on Ubuntu with tmux 3.4 and Claude Code 2.1.28x
(September 2026).
There are two kinds of service:
- A generic
claude-rc@<project>.service: a Remote Control server for a project directory. It spawns new sessions on demand, whenever you start one from claude.ai/code or the app. - A dedicated
claude-<name>.service: keeps one existing conversation always available, reusing its Remote Control URL when it already had one.
Plus a logrotate rule, because these services write debug logs that grow forever otherwise.
Before you start
Unit files can’t use $HOME or ~, so every path in them must be absolute.
Collect these values on the target machine, as the user who will own the
services (not root):
| Placeholder | Meaning | How to get it |
|---|---|---|
<user> | account that runs claude and owns ~/.claude | id -un |
<group> | its primary group (for logrotate) | id -gn |
<home> | its home directory | getent passwd "$(id -un)" | cut -d: -f6 |
<claude-bin-dir> | directory holding the claude binary | dirname "$(command -v claude)" |
<projects-root> | parent directory of your project folders | your choice |
<host> | short machine name, used in session names | hostname -s |
You also need tmux, systemctl and logrotate installed, sudo, and to be
logged in to Claude Code (~/.claude/.credentials.json exists).
If a machine already has some of this set up, look before you change anything:
systemctl list-unit-files 'claude*' --no-pager # installed units
systemctl list-units 'claude*' --all --no-pager # and their state
cat /etc/logrotate.d/claude-cache 2>/dev/null
ls ~/.cache/claude-rc/ 2>/dev/null
The common design
Both kinds of service share the same shape:
- System units in
/etc/systemd/system/, running asUser=<user>. systemd setsHOME, soclaudefinds its credentials. No user lingering needed. clauderuns inside a detached tmux server with its own socket name (tmux -L <socket>). That way you can attach to it from any shell withtmux -L <socket> attachand see exactly what the session shows (detach withCtrl-b d). HenceType=forking,ExecStop=… kill-serverandRestart=always.- Never set
PrivateTmp=true. The tmux socket lives in/tmp/tmux-<uid>/, and a private/tmpmakes it impossible to attach from a normal shell. - Every
claudegets a--debug-file, because tmux output never reaches the journal. Everything else the service writes (logs of spawned sessions, bridge transcripts, alatestsymlink) lands in the same directory as that file, so I point them all at<home>/.cache/claude-rc/: one directory, one logrotate rule. claudedoesn’t create that directory, so each unit has anExecStartPrethat does.- An explicit
PATH, soclauderesolves.
1. The generic service
/etc/systemd/system/claude-rc@.service:
[Unit]
Description=Claude Code Remote Control (%i)
After=network-online.target
Wants=network-online.target
[Service]
Type=forking
User=<user>
WorkingDirectory=<projects-root>/%i
Environment=PATH=<claude-bin-dir>:/usr/local/bin:/usr/bin:/bin
ExecStartPre=/usr/bin/mkdir -p <home>/.cache/claude-rc
ExecStart=/usr/bin/tmux -L rc-%i new-session -d -s rc 'claude remote-control --name %i@<host> --spawn worktree --capacity 6 --permission-mode auto --debug-file <home>/.cache/claude-rc/rc-%i.log'
ExecStop=/usr/bin/tmux -L rc-%i kill-server
Restart=always
RestartSec=10
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
It’s a template: enable one instance per project, named after its folder under
<projects-root>:
sudo systemd-analyze verify /etc/systemd/system/claude-rc@.service
sudo systemctl daemon-reload
sudo systemctl enable --now claude-rc@my-project.service
A few things I tripped on:
- Use
%i, not%I, and don’t run the instance name throughsystemd-escape. Escaping turns-into\x2d, and%Iturns-into/. A plainclaude-rc@my-projectwith%igives the right path. --spawn worktreegives each new session its own git worktree, so parallel sessions don’t step on each other. It requires the directory to be a git repo.--capacity 6caps how many sessions the server runs at once.
The other spawn modes are same-dir (the default: every session works in the
directory itself) and session (a single session; the server exits when it
ends).
For a directory that isn’t a git repo — say <projects-root> itself, to
start sessions that work across several projects — worktree is impossible. I
use a standalone claude-rc-<name>.service for that: the same unit with a fixed
WorkingDirectory, its own socket, and --spawn same-dir. Just remember that
concurrent sessions then share the directory and can collide.
To check it works: systemctl status claude-rc@my-project should show
active (running) with a claude remote-control child, and the debug log should
contain Created initial session session_01… and work/poll -> 200:
systemctl status claude-rc@my-project
tail ~/.cache/claude-rc/rc-my-project.log
The project then shows up in claude.ai/code and the app, ready for new sessions.
2. A dedicated service for an existing conversation
Sometimes you don’t want a new session, but to keep one particular conversation reachable — one with a lot of context you don’t want to lose.
What I learned the hard way
- The Remote Control URL (
https://claude.ai/code/session_01…) is assigned by Anthropic’s server. It isn’t derived from the local session id. claude remote-control --session-id session_01…looks like the obvious tool, but it only reattaches sessions created by aclaude remote-controlserver. For a normal session it fails withSession … has no environment_id. It may never have been attached to a bridge.- What works is resuming the conversation with Remote Control on:
claude --resume <local-uuid> --remote-control "<Display Name>". If the session already had a Remote Control URL, it reconnects to the same URL, history intact. If it never had one, a new URL is created, and it then stays the same across restarts. - Exception: a session that had already stopped got a new URL, even though its old one was still on record. There’s no way to force the old one back, so expect the link to change once.
- Only one process may run a given conversation. Stop any other instance first.
Find the conversation
Conversations live under ~/.claude/projects/<project-slug>/<uuid>.jsonl, and
their titles in a sibling <uuid>/custom-title.json. Titles may contain typos,
so search loosely on one distinctive word:
grep -rli "<word>" ~/.claude/projects --include=custom-title.json \
| xargs -I{} sh -c 'echo "{}: $(cat {})"'
The directory name is the local session UUID. Running sessions each have a
~/.claude/sessions/<pid>.json with the sessionId, the working directory, and
bridgeSessionId: the current Remote Control URL (cse_X ↔ session_X), empty
if it has never been on Remote Control. Note the model and effort it used too,
so the service can pin them.
Stop it, and back it up
Copy the transcript somewhere safe first. Then stop whatever runs it: exit it if
it’s in a terminal. If it’s a background session, claude daemon stop --any
stops it — but it stops every background session the daemon runs, so check
what else is listed in ~/.claude/daemon/roster.json first.
Try it by hand
Before writing a unit, run the exact command in tmux and check it comes up:
mkdir -p ~/.cache/claude-rc
cd <cwd> && tmux -L rc-<name> new-session -d -s rc -x 160 -y 47 \
'claude --resume <uuid> --remote-control "<Display Name>" --permission-mode auto --model <model> --debug-file <home>/.cache/claude-rc/<name>.log'
# Wait for Remote Control, then look at the pane.
timeout 90 bash -c 'until tmux -L rc-<name> capture-pane -p -t rc | grep -q "remote-control is active"; do sleep 2; done'
tmux -L rc-<name> capture-pane -p -t rc | grep -E 'remote-control is active|auto mode'
Check the URL (the same as before, if it had one), auto mode on, and the model.
Pin the full model id (e.g. claude-opus-5-5) rather than an alias, and pass
--effort explicitly if it matters. Then kill the test —
tmux -L rc-<name> kill-server — since the service uses the same socket and
conversation.
The unit
/etc/systemd/system/claude-<name>.service:
[Unit]
Description=Claude Code session "<Display Name>" (Remote Control)
After=network-online.target
Wants=network-online.target
[Service]
Type=forking
User=<user>
WorkingDirectory=<cwd>
Environment=PATH=<claude-bin-dir>:/usr/local/bin:/usr/bin:/bin
ExecStartPre=/usr/bin/mkdir -p <home>/.cache/claude-rc
ExecStart=/usr/bin/tmux -L rc-<name> new-session -d -s rc -x 160 -y 47 'claude --resume <uuid> --remote-control "<Display Name>" --permission-mode auto --model <model> --debug-file <home>/.cache/claude-rc/<name>.log'
ExecStop=/usr/bin/tmux -L rc-<name> kill-server
Restart=always
RestartSec=10
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
sudo systemd-analyze verify /etc/systemd/system/claude-<name>.service
sudo systemctl daemon-reload
sudo systemctl enable --now claude-<name>.service
--remote-control "<Display Name>" sets the name shown in claude.ai/code. Every
restart resumes the same conversation on the same URL.
3. Rotating the logs
Debug logs and bridge transcripts grow without limit.
/etc/logrotate.d/claude-cache:
# Claude Code debug logs and bridge transcripts written by the claude-rc services.
# claude keeps these files open, so copytruncate instead of moving them.
<home>/.cache/claude-rc/*.log <home>/.cache/claude-rc/*.jsonl {
su <user> <group>
daily
maxsize 50M
rotate 7
maxage 14
compress
delaycompress
copytruncate
missingok
notifempty
}
The system logrotate.timer picks it up daily, and new services need no change
as long as their --debug-file is a .log in that directory. Rotated copies
(*.log.1, *.log.2.gz) don’t match the globs, so they’re never re-rotated.
Test it with a dry run, then a forced rotation:
sudo logrotate -d /etc/logrotate.d/claude-cache # dry run
sudo logrotate -v -f /etc/logrotate.d/claude-cache # real rotation
tr -dc '\000' < ~/.cache/claude-rc/<name>.log | wc -c # must print 0
That last line checks the truncated log contains no NUL bytes, which proves
claude writes in append mode and copytruncate is safe.
One rule above all: never rotate ~/.claude/projects/**/*.jsonl. Those are
the actual conversations, and the dedicated services resume from them on every
restart.
Restarting without losing work
Before any systemctl restart — a unit edit, an upgrade — look at the session
first:
tmux -L rc-<name> capture-pane -p -t rc
Don’t restart it while it’s working, while its input box holds an unsent draft, or while it has scheduled jobs created in the session: those only live in the running process and are lost on restart.
A restart briefly drops the Remote Control connection and interrupts any turn in progress. Dedicated sessions come back on the same URL; a generic server re-registers and re-adopts its sessions.
To change a flag (model, effort, name), edit the unit, then:
sudo systemd-analyze verify /etc/systemd/system/claude-<name>.service
sudo systemctl daemon-reload
sudo systemctl restart claude-<name>.service
A word on permissions
These units run Claude with --permission-mode auto, around the clock, on a
machine you care about. That’s the point — you want it to get on with work while
you’re away — but it’s worth taking seriously: whoever can open your claude.ai
account can drive this machine. Protect that account, and keep the services to
directories you’re happy for it to work in.
If you ask Claude Code itself to set this up, expect it to ask for explicit
permission before writing unit files or running systemctl. That’s by design;
say yes, or run the commands yourself.
That’s it: a few unit files, one logrotate rule, and Claude Code is waiting in my pocket whenever I need it — reboots included.