Self-hosted Docker image for running multiple isolated, long-lived Claude Code sessions on a homelab box. One container = one session = one git repo, reachable two ways at once: SSH into a persistent tmux session, and the Claude mobile app's Remote Control (Code tab).
Auth is your Claude Max subscription via OAuth. No API keys: the entrypoint
hard-fails if ANTHROPIC_API_KEY is set so you never accidentally bill per
token.
Unofficial project. Not affiliated with, endorsed by, or supported by Anthropic. "Claude" and "Claude Code" are Anthropic's trademarks and are used here only to say what this runs. It installs the published
@anthropic-ai/claude-codeCLI at a pinned version; your use of Claude Code and of your Claude subscription remains subject to Anthropic's own terms. Report Claude Code bugs to Anthropic, not here.
Retired features you may find references to. An earlier version ran a nested-Sysbox worker broker: a controller container that spawned autonomous nested worker containers (
--broker/--sysbox). It was retired on 2026-07-12 in favour of Claude Code subagents in git worktrees, and is frozen on branchlegacy/sysbox-broker-2026-07-12(taglegacy-sysbox-broker-2026-07-12), which is not maintained. The later per-session Docker engine (--docker), the last thing that needed that runtime, was removed too: every session now runs on plainrunc. Removed flags refuse with an error naming the removal rather than silently doing nothing. Background: docs/legacy-sysbox-broker.md.
cp .env.example .env # edit if you want; defaults are sane
make build # builds the image for this host's arch
make login # one-time OAuth: opens a URL, you paste a code
./bin/claude-launch first-project --repo git@github.com:you/first-project.gitclaude-launch prints the exact SSH command and the name to look for in the
app. SSH in and you're dropped straight into the running Claude session:
ssh -p 2200 claude@your-homelab
Open the Claude mobile app → Code tab → first-project has a green dot.
Stop and resume later without losing the workspace or conversation history:
./bin/claude-stop first-project
./bin/claude-launch first-project # resumes; no --repo neededRun as many in parallel as you like: each gets its own SSH port and app
session. ./bin/claude-list shows them all. Put bin/ on your PATH to drop
the ./bin/ prefix.
host container: claude-<project>
┌────────────────────────┐ ┌──────────────────────────────────┐
│ ./bin/claude-launch ───┼── docker ──▶│ entrypoint.sh (root) │
│ │ run -d │ ├─ refuse ANTHROPIC_API_KEY │
│ ssh -p 22NN ───────────┼────────────▶│ ├─ sshd (port 22 ⇄ host 22NN) │
│ │ │ ├─ merge baked-in config │
│ │ │ ├─ clone / use /workspace │
│ │ │ └─ gosu claude ▶ tmux "claude" │
│ │ │ ├─ main: claude \ │
│ │ │ │ --dangerously- │
│ │ │ │ skip-permissions │
│ │ │ │ --remote-control │
│ │ │ │ "<project>" │
│ │ │ └─ <name>: claude … │
│ │ │ --remote-control │
│ │ │ "<project>-<name>"│
└────────────────────────┘ └───────┬──────────────────────────┘
│ outbound HTTPS only
Claude mobile app ◀───── Remote Control ───────┘ (no inbound port)
volumes:
claude-auth (shared) OAuth credentials → /auth
claude-sshkeys (shared) SSH host keys → /etc/ssh/host-keys
claude-config-<proj> (per ctr) sessions + config → /home/claude/.claude
claude-ws-<proj> (per ctr) the git repo → /workspace
claude-scratch-<proj>(per ctr) disk-backed TMPDIR → /scratch
claude-state-<proj> (per ctr) app dbs and keys → /state (CLAUDE_STATE_DIR)
claude-cache (shared) tool caches only → /cache
/cache is shared by every container on the host, all as one user, so anything in it is
readable from every container. An app's private state (a database, a key, a token) goes
in /state/<app>, the container's own volume, which no other container mounts, and so do
worktrees, clones and working folders that must last (/state/wt, /state/tmp):
docs/volume-reference.md.
Why credentials and config are split: a single shared ~/.claude across
containers would corrupt concurrent sessions and collide on the /workspace
project key. Credentials are shared (one login, all containers); per-container
config keeps sessions independent and resumable. The entrypoint keeps the
credentials file converged across containers. Details and the deviation
rationale: docs/architecture.md.
Both modes, the durable task queue, the SCM observer, fleet telemetry and the shared-quota rules: docs/unattended-autopilot.md.
Set in .env (auto-loaded by the scripts and passed into containers). Real env
vars override .env. Full reference: .env.example.
| Variable | Default | Purpose |
|---|---|---|
CLAUDE_IMAGE |
claude-code-box:latest |
Image tag built/run |
CLAUDE_CODE_VERSION |
2.1.293 |
Pinned Claude Code npm version (min 2.1.52). opus resolves to the latest Opus: Opus 5.5 from CLI 2.1.280 (unchanged on 2.1.293), Opus 5 from 2.1.219, Opus 4.8 from 2.1.154. Auto-update is on by default (DISABLE_AUTOUPDATER is unset), so a running container's binary can self-update past this pin unless the operator sets DISABLE_AUTOUPDATER=1 |
NODE_VERSION |
24 |
Base Node LTS |
UV_VERSION |
latest |
uv version (pin for reproducibility) |
PNPM_VERSION |
latest |
pnpm version baked in (pin for reproducibility) |
CLAUDE_UID/CLAUDE_GID/CLAUDE_USER |
1000/1000/claude |
Container user |
CLAUDE_MODEL |
opus |
Model the session runs. Defaults to the best available model (the opus alias always resolves to the latest Opus, Opus 5.5, 1M context, on the pinned CLI; it became the default Opus in 2.1.280, after Opus 5 from 2.1.219, which moved the fleet off Opus 4.8). Passed through to --model verbatim, so use an alias the pinned CLI actually ships (opus/sonnet/haiku/opusplan/fable/best) or a full id like claude-opus-5 / claude-opus-4-8 (set the latter to hold a container on 4.8). default is not in the pinned CLI's alias table, don't rely on it to defer to Claude Code's own pick. Both launchers now default to opus when this is unset, so a session can never silently fall back to Claude Code's default (which is Sonnet 5 from CLI 2.1.197). Per-container via claude-launch --model, per-repo via claude-compose-gen --model REPO=MODEL |
CLAUDE_PERMISSION_MODE |
bypassPermissions |
acceptEdits/auto/bypassPermissions/manual/dontAsk/plan: the choice set the pinned CLI accepts. Honored by both the interactive session and autopilot; acceptEdits is the safer fleet posture (gates shell/network). default was renamed manual upstream in CLI 2.1.200 and no longer appears in claude --help; it is still accepted for now (re-verified on 2.1.241, a bogus mode is rejected with the allowed-choices list, default is not), so existing .env files keep working, but prefer manual, since an undocumented alias can be dropped |
CLAUDE_SECRET_GUARD |
1 |
1 installs a fleet-wide git pre-commit hook that blocks committing secrets (.env, *.pem, *.key, id_rsa, PRIVATE KEY blocks). Bypass once with git commit --no-verify; extend via CLAUDE_SECRET_GUARD_EXTRA |
CLAUDE_AUTOPILOT |
0 |
1 = unattended mode: main pane runs a headless claude -p loop instead of Remote Control (see Unattended autopilot) |
CLAUDE_AUTOPILOT_CMD |
none, required | What the autopilot loop runs each cycle. No default, it must be a command the workspace you mount actually defines. Unset + no queue = the autopilot refuses to run |
CLAUDE_AUTOPILOT_INTERVAL |
3600 |
Seconds between successful autopilot runs |
CLAUDE_AUTOPILOT_MAX_RUNS |
0 |
Stop after N autopilot runs (0 = unlimited) |
CLAUDE_AUTOPILOT_RESUME |
0 |
1 = carry the conversation forward via --resume <session_id> each cycle instead of a fresh session |
CLAUDE_AUTOPILOT_QUEUE |
0 |
1 = consume prompt files from a durable task queue (claude-enqueue), falling back to CLAUDE_AUTOPILOT_CMD when empty, or idling, if that is unset (see Unattended autopilot) |
CLAUDE_SCM_OBSERVER |
0 |
1 = poll the repo's PRs via gh and route CI failures / change requests / merge conflicts into the queue (CLAUDE_SCM_* tune it; see .env.example) |
CLAUDE_OTEL_ENABLED |
0 |
1 (or setting OTEL_EXPORTER_OTLP_ENDPOINT) exports Claude Code's per-call cost/token telemetry to an OTLP backend, tagged per container. CLAUDE_OTEL_TRACES=1 adds traces; see .env.example for the OTEL_* vars |
CLAUDE_EXTRA_ARGS |
: | Extra args to claude (or --extra-args) |
CLAUDE_MCP_ENABLED |
: | CSV of baked MCP servers to load (empty = all) |
WITH_BROWSER |
0 |
Build arg: 1 bakes Chromium + chrome-devtools-mcp (+~200 MB). make build-browser flips it. |
CLAUDE_BROWSER |
auto | Tri-state for the chrome-devtools MCP: unset = auto (a browser image self-enables it), 1/--browser = force on (fails loud on a lean image), 0/--no-browser = opt out. |
CLAUDE_GPU |
0 |
1/--gpu gives the session the host's NVIDIA GPU through CDI (see GPU sessions); --no-gpu opts out of an ambient 1. Fixed at container creation. |
CLAUDE_GPU_SCRATCH_TMPFS |
4g |
Size of the RAM /scratch a GPU session gets instead of the disk volume. Charged to CLAUDE_MEM_LIMIT. |
GIT_REPO_URL/_BRANCH/_DEPTH |
: | Clone source (or use --repo/--branch/--depth) |
GIT_REPOS |
: | Several repos in one container: whitespace-separated URL[#BRANCH], each cloned into /workspace/<repo> (set by claude-compose-gen --group or several --repo). Not together with GIT_REPO_URL |
GIT_AUTHOR_NAME/_EMAIL (+COMMITTER) |
host git config | Commit identity |
GIT_SSH_KEY |
~/.ssh/claude-git-key |
Host SSH key for git, mounted read-only and brokered. For git alone: never in SSH_AUTHORIZED_KEYS (the launch tools refuse that, and sshd in the container refuses it for logins) |
SSH_AUTHORIZED_KEYS |
~/.ssh/authorized_keys |
Your own public keys allowed to SSH in (read-only), never the git key's |
SSH_PORT_RANGE_START/_END |
2200/2299 |
Auto-assigned host SSH port range |
CLAUDE_SSH_HOST |
this host's name | Hostname shown in the connect line |
CLAUDE_SSH_BIND |
: | Bind the SSH port to one host interface (e.g. 127.0.0.1); empty = all |
CLAUDE_CPU_LIMIT/CLAUDE_MEM_LIMIT |
2/4g |
Per-container resource caps |
CLAUDE_MEM_RESERVATION |
75% of CLAUDE_MEM_LIMIT |
Soft memory floor (--memory-reservation) |
CLAUDE_PIDS_LIMIT |
2048 |
Fork-bomb guard (--pids-limit) |
CLAUDE_SHM_SIZE |
2g |
/dev/shm size |
CLAUDE_HARDEN_CAPS |
1 |
1 = --cap-drop ALL + minimal --cap-add (drops NET_RAW/MKNOD/SETFCAP); 0 = Docker defaults. no-new-privileges is always set. Override the set with CLAUDE_MIN_CAPS |
CLAUDE_EGRESS_LOCKDOWN |
0 |
1/true/yes/on = default-deny network firewall (iptables, IP-pinned allowlist) applied at boot before the unprivileged agent starts, fail-open on error (logs loudly, starts the agent anyway). strict = the same firewall fail-closed: if the ruleset cannot be applied the container refuses to start the agent and exits nonzero. 0/false/no/off, unset or empty = off; any other value is off and is reported in the boot log. Extend the allowlist with CLAUDE_EGRESS_EXTRA_HOSTS |
CLAUDE_EGRESS_PACKAGES |
0 |
1 = additively allowlist the curated package registries (PyPI, crates.io, Go proxy, mise.run, ghcr.io) so agent-driven pip/cargo/go/mise installs work under lockdown. Opt-in and curated (not open); nothing else broadens. Debian/apt system libs have no self-service path (see docs/package-provisioning-security.md) |
CLAUDE_EGRESS_REFRESH_INTERVAL |
unset (no refresh) | Seconds between re-resolves of the allowlist in a container that is already under lockdown, e.g. 900. Unset, the allowlist is pinned once at boot and goes stale as CDNs rotate addresses. Set, a root-owned loop re-resolves the same host set and re-commits each family through the same atomic restore the boot pass used, so the agent can neither alter the refreshed rules nor stop the refresh. It never narrows on a failed lookup: a host that comes back empty leaves the ruleset in force alone and is logged by name. Absent, malformed or non-positive values all mean off, and the boot log states which posture it chose and why. Needs CLAUDE_EGRESS_LOCKDOWN; ignored without it, and not started when the boot pass applied no ruleset |
CLAUDE_BROKER_GIT_KEY |
1 (brokered) |
Brokering is the default. Unset, the SSH deploy key is held in a root ssh-agent: the agent signs/pushes with it but cannot read the key bytes. 0/false/no/off is the explicit opt-out back to a readable ~/.ssh/id_ed25519; any other value (including a typo) brokers. If brokering cannot be established there is no readable-file fallback: git is left unable to authenticate with that key and the boot log says so |
CLAUDE_DISK_DATA_ROOT |
/var/lib/docker |
Path whose free space claude-disk-gc reports before/after each cycle |
CLAUDE_DISK_GC_INTERVAL |
3600 |
Seconds between claude-disk-gc --loop cycles (standalone tool; nothing auto-starts it) |
CLAUDE_CONTROLLER |
removed | Removed: setting it to 1 now refuses to boot. It had collapsed to a byte-identical pass-through to CLAUDE_AUTOPILOT=1. Use that instead |
CLAUDE_STOP_TIMEOUT |
20 |
Graceful stop timeout (s) |
AUTH_VOLUME/SSHKEYS_VOLUME |
claude-auth/claude-sshkeys |
Shared volume names |
ANTHROPIC_API_KEY |
unset | Must stay unset: entrypoint hard-fails otherwise |
Every path in the container, its source, its scope and what it holds: docs/volume-reference.md.
claude-config/ is copied into the image and merged into ~/.claude on start
(only filling what's absent, so per-container state is never clobbered). It
holds CLAUDE.md, mcp/, plugins/, commands/, skills/. MCP secrets are
never baked: use ${VAR} placeholders, supply values at runtime via
.env. Full guide: docs/customizing-bakeins.md.
Every claude-* command, the flags they take, and claude-compose-gen for many
repos at once: docs/launcher-commands.md.
The browser image variant and the chrome-devtools MCP it registers: docs/frontend-debugging.md.
A session normally owns one repo, cloned into /workspace. When the work spans repos (a
part, the board it mounts to, the notes about both), one container can hold several: each
is cloned into /workspace/<repo> and the session starts in /workspace, so it reads,
edits and commits across all of them. Every repo keeps its own git history and remote;
each repo's own CLAUDE.md still applies inside it.
# a stack service (scenario .conf, or the generator's command line)
--group maker=you/home,you/3d,you/keys:dev
--active maker
# a standalone container
claude-launch maker --repo git@github.com:you/home.git --repo git@github.com:you/3d.git
The group's name is the service (claude-maker, its Remote Control session name) and
takes every per-repo flag (--active, --gpu, --cpu, --mem, ...); it must not also be
one of the stack's single-repo services. A branch is repo:branch in --group and
URL#BRANCH with --repo. Each boot clones only the repos that are missing, so adding one
to the list and restarting brings it in, and an existing checkout is never touched. The
container passes the list as GIT_REPOS; it cannot be combined with a single-repo
workspace (a repo at /workspace itself), so a multi-repo service starts from a fresh
workspace volume.
Every container runs its own session in tmux window main (Remote Control name = the
project). It can run more, started at boot: each in its own tmux window, with its own
Remote Control link (<project>-<name>, so each shows up separately in the Claude app;
main becomes <project>-main alongside them, or CLAUDE_MAIN_NAME),
working directory, model, permission mode, and an optional first prompt or /goal. They
share the container's workspace, tools, credentials and resources, which makes this the
natural shape for several lanes of work over one checkout, or one session per repo of a
multi-repo container.
# a standalone container: main + one session per repo, plus a read-only reviewer
claude-launch maker --repo git@github.com:you/api.git --repo git@github.com:you/web.git \
--session '*' --session 'review dir=api model=sonnet mode=plan'
# a stack service (scenario .conf lines, or the generator's command line)
--group maker=you/api,you/web,you/docs
--session maker=api goal=docs/first.goal.txt
--session maker=web
--session maker=review dir=api model=sonnet mode=plan
--env maker=CLAUDE_MAIN_RESUME=1
A session spec is NAME [key=value ...], several separated by ; (the flag repeats too):
| Key | Meaning |
|---|---|
NAME |
lowercase letters, digits, -, _: the tmux window and the Remote Control suffix. main, dev, scm are taken. * is one session per git repo directly under /workspace, named after the repo (a named entry wins its name). |
dir= |
working directory, absolute or relative to /workspace. Default: /workspace/NAME when it is a directory, else /workspace. |
model= / mode= |
model alias or id / permission mode for this session (default: the container's). |
goal=FILE |
on its first start, begin with /goal <contents of FILE> (FILE relative to dir). |
prompt-file=FILE |
on its first start, begin with the contents of FILE. |
rc=off |
no Remote Control link (SSH / tmux only). |
| (names) | every session is named <project>-<window>, as its Remote Control name and its display name (--name), on every start: a resumed conversation reuses its old Remote Control session and would otherwise keep that session's old name in the app. |
resume=off |
a fresh conversation on every boot. By default a named session resumes its last conversation when the container restarts; main does not unless CLAUDE_MAIN_RESUME=1. |
claude-launch and claude-compose-gen validate the spec with the container's own
parser, so a typo fails the launch or the generation, not the boot. One removed
setting is the exception: chain (and claude-sessions new --chain, and the
CLAUDE_GOAL_CHAIN_REVIEW variable) belonged to a goal runner that no longer ships in this
image. A container still declared with them boots as before, each such session as a plain
session, with a warning in the boot log and from the generator; drop the word at the next
regeneration. A tool of that kind now comes in from outside the image. Inside, and from the
host with claude-sessions -C <project> …:
claude-sessions # ls: every session, its state (busy/idle/exited/stopped), dir, Remote Control, conversation
claude-sessions new review --dir home --model sonnet --prompt "Review the last 5 commits"
claude-sessions attach home # or Ctrl-b w in tmux, or from the host: claude-attach maker home
claude-sessions send home "Run the tests and fix what fails"
claude-sessions restart home [--fresh] # resume its conversation in a new process (or start over)
claude-sessions restart home --resume ID # take over conversation ID (e.g. one copied in from another container)
claude-sessions stop home # stays stopped across restarts, until: claude-sessions start home
claude-sessions reset home # forget its conversation; the next start re-sends its first prompt
claude-sessions rm review # a `new` session only; a declared one is stopped instead
Sessions from --session (CLAUDE_SESSIONS) are the container's declaration: reconciled
on every boot, so an entry dropped from it is unregistered on the next recreate. Sessions
from claude-sessions new persist on the config volume and come back after a restart. A
first prompt or goal is sent exactly once (the start is recorded before Claude runs), so
a restart never replays goal 1 over a session that is on goal 4. What keeps several
sessions honest:
- Resume is exact. A supervisor records which conversation each window runs (from
Claude Code's own
sessions/<pid>.json, so it follows a/clear), and a restart resumes that one with--resume <id>.--continueis only a fallback, and only when no other session works in the same directory, where it could pick up a sibling's conversation. - Recovery is per session. Every session with a Remote Control link gets its own RC
watchdog, debug log (
/tmp/claude-rc-debug-<name>.log) and respawn lock, and a restart kills only its own pane's processes. The usage-limit watchdog resumes every session that hit the limit, each with its own conversation. - Health stays about the container.
docker ps/claude-listreadhealthy; sessions: 2/3 up (3d: claude exited): a named session never makes the container unhealthy (that would restart every session in it); main still does. - Capacity is shared. The boot log says what each session gets (
Capacity: 4 Claude sessions share 16384 MiB (~4096 MiB each)) and warns under 1 GiB each: a Claude process alone uses 300-600 MiB, and an OOM kill lands on whichever session the kernel picks. Raise--mem/CLAUDE_MEM_LIMITwith the session count. Pids too: threads count as pids, so give a multi-session container thousands per session (CLAUDE_PIDS_LIMIT). Library thread pools are capped per process (CLAUDE_THREADS_PER_PROCESS, default 4) because they size themselves to the host, and the supervisor warns inclaude-logsnear either limit. - A crashed session comes back. If Claude exits abnormally (an abort, a segfault, an OOM
kill, a failed thread create), its window relaunches it after a backoff, resuming the same
conversation, up to 5 times in 15 minutes.
/exit, Ctrl-C andclaude-sessions stopstill mean stop.
Sessions share one checkout per directory. Two sessions writing the same git tree race on
its index, so give concurrent writers their own directories (one per repo, or a git worktree each) and keep extra sessions in a shared directory to reading and reviewing
(mode=plan).
Log the new account into the credential volume the containers mount, and they follow by themselves. No restart, no recreate:
claude-account-login personal # logs into claude-auth-personal (the stack's AUTH_VOLUME)
make login # the same for the default claude-auth volume
claude-account-list # each volume's account, and the containers on it: in sync / moving
claude-sessions -C maker account # that container: the account in use and who is still moving
Every running container on that volume then:
- takes the new credential within one sync tick (~30 s) and updates its cached identity
(the boot log and
claude-logssayAuth account : the shared credential is now <email>); - moves each session at its next idle moment: the session restarts and resumes its
conversation, so no turn is cut off (
claude-sessions account --nowmoves the busy ones too;CLAUDE_SESSIONS_SWITCH_FORCE_AFTER=<seconds>does that automatically after a wait); - tells a session that a usage limit had stopped to continue, since getting past a limit is
the usual reason to switch (
CLAUDE_SESSIONS_SWITCH_NUDGE=0turns that off); - gets fresh Remote Control links under the new account, named as before: Claude will not reattach a link another account owns, so the sessions appear in the Claude app of the new account, and the old ones go offline in the old account's.
The old account cannot come back by accident. A session still running on it refreshes the OLD
account's token into the container's own credential file; until every session has moved, the
sync never copies that file up to the shared volume. And a container that was stopped across the
change (a dormant service) replaces its own stale credential at boot, before any session starts.
CLAUDE_AUTH_FOLLOW=0 turns the whole behaviour off (a new login then needs a container
restart, as before). The health line shows moving to a new account (N sessions to go) while a
switch is in progress.
This is for changing the account a volume holds. --accounts a,b (rotation between several
logged-in accounts on a usage-limit hit) is separate and unchanged.
A kit is a git repository of your own that is a Claude Code plugin marketplace: skills,
commands, hooks and agents packaged as plugins, and optionally a tool it ships. The image
installs one when the container starts, from a git URL, with no rebuild. The repository can
be private: it is cloned with the container's own git credentials (the gh credential
helper over https when GH_TOKEN is set, or the SSH deploy key). Three values declare it,
all optional:
| Value | Flag (claude-launch / claude-compose-gen) |
What it does |
|---|---|---|
CLAUDE_EXTRA_MARKETPLACES |
--marketplace NAME=URL[#REF] / --marketplace SVC=NAME=URL[#REF] |
registers the marketplace in settings.json (extraKnownMarketplaces, git source). NAME is the name the marketplace gives itself in .claude-plugin/marketplace.json. |
CLAUDE_EXTRA_PLUGINS |
--plugin PLUGIN@NAME / --plugin SVC=PLUGIN@NAME[,…] |
enables each plugin (enabledPlugins) and installs it at user scope if it is not installed yet. |
CLAUDE_EXTRA_START_CMD |
--start-cmd COMMAND / --start-cmd SVC=COMMAND |
one command, run once per container start as the agent user, in the background, after the sessions are up. Output: ~/.claude/kit-start.log. |
# one container
claude-launch site --repo git@github.com:you/site.git \
--marketplace 'team-kit=https://git.example.com/you/team-kit.git#v1.2.0' \
--plugin helper@team-kit \
--start-cmd 'uv tool install git+https://git.example.com/you/team-kit.git@v1.2.0 && team-kit serve --ensure'
# a stack service (scenario .conf lines, or the generator's command line)
--marketplace site=team-kit=https://git.example.com/you/team-kit.git#v1.2.0
--plugin site=helper@team-kit
--start-cmd site=uv tool install git+https://git.example.com/you/team-kit.git@v1.2.0 && team-kit serve --ensure
What happens at boot, in order (bin/claude-kit, each step a warning in the boot log if it
fails, never a failed boot):
- settings: the marketplaces and plugins are merged into
~/.claude/settings.json. - install: before any Claude session exists, each declared marketplace the CLI does not
know is added (
claude plugin marketplace add URL#REF) and each declared plugin it does not have is installed (claude plugin install PLUGIN@NAME --scope user), each call bounded byCLAUDE_KIT_TIMEOUT(default 120 s). Enabling a plugin from a git source insettings.jsondoes not install it by itself, which is why this step exists. A plugin's ownSessionStarthook therefore runs from every session's first start. Sessions start after this step, so a kit host that cannot be reached delays them by up to one timeout (its plugins are then skipped); underCLAUDE_EGRESS_LOCKDOWNa kit on a host other than GitHub needs that host inCLAUDE_EGRESS_EXTRA_HOSTS. - start: after the sessions are up, the start command runs once, detached.
claude-kit status (inside the container) prints what is declared, registered and installed.
Pin or auto-update, not both. Without #REF a marketplace follows its repository's
default branch and is registered with "autoUpdate": true: Claude Code refreshes it and
updates its installed plugins in the background after startup. With #REF (a branch or a
tag) the marketplace is pinned and registered with "autoUpdate": false. The two contradict
each other: an auto-updating marketplace moves its plugins whenever the catalog moves, which
is exactly what a pin is there to prevent, and a plugin entry that pins its own source (ref
or sha in marketplace.json) would be moved by the next catalog refresh. Pin when the kit
and something outside it (a tool the start command installs, a running service) must stay at
the same version.
Moving a pin. The declaration is the operator's last word: change #REF (or add or drop
it) and recreate the container. At the next start the marketplace is registered again at the
declared ref and each declared plugin from it is updated to what that ref offers (an older
ref moves it back), so the plugins and whatever the start command installs move together.
Nothing is removed along the way: plugins you installed from that marketplace by hand stay
installed and are not updated by the hook.
The start command runs with the agent user's privileges and environment, like --dev-cmd:
it is part of the container's declaration, not something a session can set. Keep it
idempotent (it runs on every start) and quick to return (start a long-lived service in the
background or in a tmux window of its own). References: Claude Code's
extraKnownMarketplaces
and plugin installation (read 2026-10-02).
Off by default. A session that renders (Blender Cycles, EEVEE, Workbench), previews CAD
through OpenGL/EGL (VTK, PyVista, build123d), or runs CUDA can be given the host's GPU.
NVIDIA only, through the NVIDIA Container Toolkit's CDI spec; there is no /dev/dri
path and no per-device selection (the session gets nvidia.com/gpu=all).
Host prerequisites: the NVIDIA driver, the NVIDIA Container Toolkit, and its CDI spec
(nvidia-ctk cdi generate --output=/var/run/cdi/nvidia.yaml, or the toolkit's refresh
unit), on a Docker with CDI (28 or newer enables it by default). Check with
docker info | grep nvidia.com/gpu: you want nvidia.com/gpu=all.
Turning it on for a stack service (GPU access is fixed at container creation, so it is always regenerate + recreate):
# 1. in the stack's scenario .conf
--gpu myrepo
# 2. regenerate, then recreate just that service
claude-compose-gen --scenario /srv/claude/personal/personal.conf
docker compose -f /srv/claude/personal/docker-compose.yml up -d myrepo
For a standalone container: claude-launch <name> --gpu ... (or CLAUDE_GPU=1 in .env,
with --no-gpu to opt one out). Removing it is the same in reverse.
What the service gets, and what it does not:
- the CDI device
nvidia.com/gpu=allon the defaultruncruntime. CDI injects the device nodes, the driver's user libraries (CUDA, OptiX, the EGL/GLX vendor libraries, the Vulkan ICD) andnvidia-smi, matched to the host driver. Noruntime: nvidia, no extra capability, no privilege:cap_drop: ALL, the minimal set andno-new-privilegesstay exactly as on every other session.NVIDIA_DRIVER_CAPABILITIEShas no effect in CDI mode (the spec decides what is mounted). /scratch(TMPDIR) on a RAM tmpfs (CLAUDE_GPU_SCRATCH_TMPFS, 4g) instead of the disk volume, so render temp and kernel caches stay off a spinning pool. With the 1g/tmpthat is 5g of the 16g defaultCLAUDE_MEM_LIMIT, leaving 11g for the session and the render's own memory.CLAUDE_GPU=1, which turns on the boot probe, the GPU line inclaude-healthcheck, and a short GPU note in the session's managed memory (/etc/claude-code/CLAUDE.md), so the agent finds the tooling on its own.
Every image variant ships the vendor-neutral GL side (glvnd libEGL/libGL/libOpenGL,
Mesa llvmpipe as the software fallback, and the X client libraries Blender links even
headless). glvnd picks NVIDIA's EGL vendor when the device is attached and Mesa when it is
not. The image never carries NVIDIA driver libraries.
The guard, claude-gpu. The card is usually shared (a media server's NVENC transcodes,
an exporter), so GPU work goes through a polite preflight:
claude-gpu status # ok | degraded (<reason>) | off, with card, driver, VRAM, NVENC, utilization
claude-gpu run -- <cmd> # waits for the card, else runs on CPU; says which device ran and why
claude-gpu blender <args> # Blender through the guard: Cycles on OptiX, else CUDA, else CPU
run checks the device-wide numbers (other tenants' usage included): at least
CLAUDE_GPU_MIN_FREE_MIB (2048) MiB free, utilization at most CLAUDE_GPU_MAX_UTIL (80%),
at most CLAUDE_GPU_MAX_NVENC (2) NVENC sessions. The 2048 MiB default is a modest
render (about 1 GiB) plus a reserve for new transcodes, which hold a few hundred MiB each.
A busy card is polled for CLAUDE_GPU_WAIT (120) seconds, then the job runs on CPU. A GPU
run that fails with an out-of-memory error is retried once on CPU. The child gets
CLAUDE_GPU_DEVICE=gpu|cpu; on CPU also CUDA_VISIBLE_DEVICES= and glvnd pointed at Mesa
(__EGL_VENDOR_LIBRARY_FILENAMES), so EGL programs render in software.
claude-gpu env cpu prints that contract.
Blender. claude-blender-install downloads the pinned Blender LTS for Linux x64,
verifies its SHA-256 before extracting (a mismatch deletes it and fails), and installs it
rootless and atomically into the shared /cache/blender/<version> under a lock, so one
install serves every container on the host; blender on PATH then runs it. It tries
blender.org, then two of its official mirrors (the checksum, not the host, is the trust).
Headless device choice uses Cycles' own --cycles-device, never a GUI preferences file;
--log-level info makes Cycles name the device it used (Path tracing on: <card> (OptiX)).
EEVEE and Workbench render with -b and no X server, through EGL. Bumping Blender:
pick the newest LTS, run Cycles' device list for CUDA and OptiX and a test render on each
on the oldest card you support, and update BLENDER_VERSION, BLENDER_SHA256 and the
PIN_EVIDENCE note in bin/claude-blender-install together.
When the GPU is unusable. If the driver breaks under a running host (typically a driver
update without a reboot: NVML: Driver/library version mismatch), a GPU session still
boots: the log carries a GPU DEGRADED banner, the tmux status line shows it,
claude-healthcheck reports gpu: degraded (<reason>) without failing health, and GPU work
falls back to CPU. A missing CDI spec is different: Docker refuses to create the
container at all, which no image can degrade around. Regenerate the spec, or drop --gpu
and regenerate. Runbook: docs/troubleshooting.md.
Why TMPDIR points at a disk-backed volume rather than the /tmp tmpfs:
docs/temp-space.md.
Rootless toolchain and CLI installs, the shared /cache volume, and what
lockdown costs them: docs/toolchains-on-demand.md.
Full runbook: docs/troubleshooting.md.
- App session missing / no green dot: needs Claude Code ≥ 2.1.52 and
outbound HTTPS.
claude-logs <name>should show the session started; check egress isn't firewalled. The name in the app is the project name. --dangerously-skip-permissionsvs Remote Control: there were earlier reports that skip-permissions didn't fully apply under Remote Control. On the pinned 2.1.293 both flags are accepted together with no interlock, and the launch (claude --dangerously-skip-permissions --remote-control <name>) combines them, so it works. This is the reason the CLI version is pinned at all: re-verify it on any bump. If a future version regresses, setCLAUDE_PERMISSION_MODE=acceptEdits(edits auto-approved, shell still prompts): see troubleshooting for the verification steps.- SSH connection refused: no
authorized_keyswas mounted, or wrong port.claude-listshows the port; the connect line is reprinted byclaude-launch <name>. The git key never logs in: use a key of your own inSSH_AUTHORIZED_KEYS(see docs/security-notes.md, SSH keys). - Git push fails:
GIT_SSH_KEYnot mounted or not authorized on the remote. Public/https clones still work without it. - Workspace trust prompt: pre-accepted by the entrypoint; if you see it, the config volume didn't mount. See troubleshooting.
- GPU session:
claude-gpu statusinside saysokordegraded (<reason>); a service that will not start at all usually means the host's CDI spec is missing. See troubleshooting, GPU sessions.
The threat model, managed policy, the secret guard, secret brokering, egress lockdown and the honest blast radius: docs/security-notes.md.
See docs/architecture.md for how each spec acceptance criterion maps to this implementation and how to verify it.
- Contributing: CONTRIBUTING.md, the gates are
make lint+npm test(both Docker-free, both what CI runs);make smokebuilds a real image and is a local gate only. - Security: SECURITY.md. Report privately, never in an issue. Read the model first: a container is not a security boundary against a fully weaponized agent, and several plausible reports are documented non-goals.
- Licence: MIT.
- Support: none promised. This is self-hosted infrastructure published as-is
in case it is useful; issues are read, but there is no SLA and no release
cadence:
mainis the version.