Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 

Repository files navigation

Always-on Claude Code container (Unraid / Docker) with SSH access

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.


Why a full desktop image, not a headless one

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.


Persistence: what actually survives what

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

Gotcha 1 — the config volume shadows anything the build wrote there

/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_keys seed): 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.

Gotcha 2 — installing the CLI as root when the container runs as a user

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-code

An 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.

Gotcha 3 — Claude never persists directory trust for a home directory

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.


Keeping one session, not many

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 -p

Auto-start on container start

A 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.


Cron: don't fight the init system

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 status lies 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/cron usually isn't on the persisted volume and would vanish on container recreation.
  • Restart the supervised one properly: s6-svc -r /run/service/svc-cron.

SSH access

  • 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_keys from 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.


--dangerously-skip-permissions — read this before using it

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.


Updating

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 upgrade inside the container plus npm 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.


MCP servers

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.


License

MIT. These are field notes — no warranty, and check anything security-relevant against current Anthropic documentation, which moves.

About

Field notes: running Claude Code as an always-on Docker container on Unraid, with SSH access - and the gotchas that cost hours

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors