Notes from running Claude Code as a persistent container on an Unraid box, so sessions don't depend on a desktop being powered on. Reachable over SSH and from a phone, surviving container restarts and host reboots.
Most of this is the gotchas — the things that cost hours and aren't in any doc. The happy path is short; the traps are the useful part.
This is the first surprise, and it's a policy constraint rather than a technical one.
Claude Code's subscription auth (Pro/Max) requires a real browser-based OAuth login. There's no headless token you can bake into an image. So the container needs a browser, once.
Just as importantly: Anthropic's terms restrict subscription OAuth tokens to interactive use of the official tool. Fully unattended/autonomous automation under a subscription is against ToS — this is a real policy point, not just a technical limitation. (API-key billing is the supported route for automation.)
The workable shape:
- base on a desktop image with noVNC (e.g.
linuxserver/webtop:ubuntu-xfce) - do the one-time OAuth login through the browser in noVNC
- afterwards, use it interactively over SSH /
docker exec
You get a persistent, always-available interactive session — not an autonomous agent.
Three different lifetimes, and conflating them is the #1 source of pain:
| Needs to survive | Where it must live |
|---|---|
| container restart | the bind-mounted config volume |
| container recreation | same — but must be generated at runtime, not baked |
| image rebuild | same, and must never exist in the image at all |
/config (or whatever you bind-mount) is a runtime mount. Anything the
Dockerfile writes under that path is silently invisible once the real volume
mounts. Not a bug, just easy to forget.
Two buckets:
- build-time/image-permanent (npm prefix, an
authorized_keysseed): put it under a non-volume path like/opt/..., and have a first-boot script copy it into the volume. - runtime state that must survive an image rebuild (SSH host keys): generate it with a startup script directly into the volume, and never bake it into the image — otherwise host keys churn on every rebuild and every client screams about changed fingerprints.
Desktop images commonly run as a non-root user (PUID/PGID). If you
npm install -g during the build you'll do it as root, and then Claude's own
self-update fails with a permission error.
ENV NPM_CONFIG_PREFIX=/opt/npm-global
RUN npm install -g @anthropic-ai/claude-codeAn env var applies to every user, unlike a per-user npm config. Also note Claude Code requires Node >= 22 — install 22.x, not 20.x.
Running claude from the bare home dir re-prompts the trust dialog every
single time. This is a deliberate security boundary, not a bug.
Fix: work from a real subdirectory (~/workspace). Trust persists there after
you accept it once.
The trap: SSH in, type claude, and you've started a second process racing
the one already running. Two sessions, divergent state, confusing results.
Run the session inside tmux, and always reattach rather than starting fresh:
# in ~/.bashrc
alias cr='tmux attach -t VM || tmux new-session -s VM'From another machine, -t is required or tmux won't get a pty:
ssh -t -i ~/.ssh/your_key user@container-ip "tmux attach -t VM || tmux new -s VM"Check on it without attaching:
tmux capture-pane -t VM -pA script in the image's init directory (/custom-cont-init.d/ on
linuxserver.io images) launching a detached tmux session works well. Have it
fall back to a fresh session if --continue finds no prior state:
tmux new-session -d -s VM 'claude --continue || claude'Run it from the workspace subdirectory, not the home dir — see gotcha 3.
On s6-based images (all linuxserver.io ones) there is already a supervised
svc-cron service. Its logic: if root or the runtime user has a crontab,
exec cron -f; otherwise sleep forever.
Consequences:
service cron statuslies here. It's a sysvinit-style command that knows nothing about s6 supervision. A "not running" result may just mean no crontab exists yet.- Never run
service cron start. That launches a second, unsupervised cron racing the native one. (Easy to end up with two cron daemons and not notice.) - To add a job: just ensure the crontab entry exists. Do it from an init script,
because
/var/spool/cronusually isn't on the persisted volume and would vanish on container recreation. - Restart the supervised one properly:
s6-svc -r /run/service/svc-cron.
- Key-only, no password auth.
- Run as the image's actual runtime user (uid 1000 on linuxserver images), not
root — matching
PUID/PGID. - Seed
authorized_keysfrom a non-volume path in the image, copied in on first boot (gotcha 1). - Generate host keys at first boot into the persisted volume. If they're baked into the image, every rebuild changes your host fingerprint.
Keep private keys in a password manager, not in the repo or the image. Fetch them onto the container's persisted volume once.
It's tempting, and it does stop the constant permission prompts. Be honest about the trade:
Anthropic's own documentation recommends this flag only for sandboxes with no internet access. A container with full internet, passwordless sudo, and a mounted Docker socket is the opposite of that. With the flag on there is no interactive confirmation gate at all — anything the model decides to run, runs.
That may still be the right call for a single-purpose, isolated container you own. It is not a general-purpose default, and it's worth re-reading that sentence before enabling it on anything that matters.
Related: mounting the host's Docker socket gives the container effective root on the host. Deliberate here; know that you're doing it.
A custom, locally-built image has no upstream registry, so Unraid's "Check for Update" button has nothing to pull and won't do anything meaningful. Updates are:
- weekly:
apt upgradeinside the container plusnpm install -g @anthropic-ai/claude-code@latest. This doesn't disrupt a live session — npm rewriting files on disk doesn't affect an already-running process; the new version applies on next start. - image changes: rebuild the Dockerfile and recreate the container.
Keep the Dockerfile on persisted storage and rebuild from it. Don't hand-patch a running container — the changes die on the next recreation.
Adding an MCP server via .mcp.json requires a restart of the claude
process to move it out of "pending approval" — even with
--dangerously-skip-permissions. Worth knowing before you assume the config is
wrong.
Start new MCP servers read-only where the server supports it, until you trust them against your environment.
MIT. These are field notes — no warranty, and check anything security-relevant against current Anthropic documentation, which moves.