Self-hosted environments are in public beta on Team and Enterprise plans; Availability and limitations covers the enablement path. This page covers running the fleet in production; see the quickstart for your first runner and session.
Harden your deployment
A self-hosted runner executes arbitrary, model-directed code on your infrastructure on behalf of any member of your Anthropic organization. Work through each item before you connect an environment to production systems:-
Ephemeral, per-session containers: run each runner process in a fresh container or VM that’s destroyed when the process exits, with
--capacity 1and the default--drain-grace-sec 0so each container serves exactly one session. At a higher capacity, or with a positive drain grace, one container serves multiple sessions from the same locked account instead of one session each; see Runner lifecycle. Don’t reuse a filesystem between runner restarts, except in the deliberate pre-warmed checkout setup, and never across accounts. -
No broad credentials in the image: don’t include long-lived SSH keys, cloud-provider credentials, or personal access tokens that grant more than a session needs. Mint credentials used during a session, such as push or API tokens, per session from your wrapper script. For the initial clone, which happens before the wrapper runs, use a
checkoutlifecycle hook or--use-anthropic-git-proxy; see Configure git. - Keep the environment secret off session-running hosts: the environment secret can register runners and pick up any org member’s queued sessions. On a fixed fleet it lives on every runner host, where any session’s code can read the secret file. Prefer on-demand runners, where the secret stays on the orchestrator host, which never runs user code, and each runner receives a single-use work order that registers exactly one runner. On a fixed fleet, treat the environment-secret file as readable by every session and rotate the secret after any suspected session compromise.
- Default-deny network egress: restrict runner and session container outbound traffic at your own network boundary on every environment; Default-deny egress covers what to allow and why.
- Least-privilege host IAM: the compute identity attached to the runner host, such as an instance profile or node service account, should grant only what the runner itself needs. Sessions should obtain their own credentials through your wrapper script rather than inheriting the host’s.
-
Block the cloud metadata endpoint from sessions: keeping sessions off the host identity requires blocking their access to the cloud metadata endpoint, and subnet-level egress policies don’t intercept link-local metadata traffic, so block it in the container itself:
- IMDSv2 with a hop limit of one
- GKE Workload Identity with metadata concealment
- An explicit deny for
169.254.169.254in the session container’s network namespace
-
Per-runner filesystem isolation: each runner process gets its own working directory that no other process on the host can read or write. Make
--hooks-dir, the wrapper script, and the host’s~/.claude/read-only to the session, either built into the image or mounted read-only. -
Dispatch is organization-wide: any member of your Anthropic organization can dispatch a session to any of its environments, and there’s no per-environment access control on dispatch. Treat every runner host as reachable for code execution by every org member, and don’t place data or credentials on a runner host that any org member shouldn’t be able to read.
--lock-to-accountbounds which account’s sessions a given host executes, but dispatch into the environment itself stays organization-wide. To make self-hosted environments the only picker option, an Owner or admin can hide Anthropic-hosted environments for the whole organization from the Cloud environments page. -
Enforce the repo-settings guard: choose the guard mode with
--confine-repo-settings. The defaultwarnlogs a violation and still spawns the session,enforcerefuses the session, andoffdisables the scan. The runner scans each repository’s committed settings for:- A grant that resolves outside that session’s own workspace: an
additionalDirectoriesentry, anEdit,Write, orNotebookEditrule inpermissions.allow, or asandbox.filesystem.allowWriteorallowReadentry - A non-empty
envblock - An operator-posture override such as
sandbox.enabled: false
--trust-workspace, and doesn’t cover repository hooks,.mcp.json, or Bash rules; see Permissions and tool approval for where those grants belong. - A grant that resolves outside that session’s own workspace: an
Your organization’s IP allowlist doesn’t cover self-hosted runner traffic by default. Don’t rely on it as a network control for runner or session traffic; apply default-deny egress at your own network boundary instead, and contact your Anthropic account team if you want IP-allowlist enforcement for your organization.
Network requirements
The runner and the session children it spawns make outbound connections to the hosts below. Restrict session-container egress to these hosts and the specific internal services sessions need to reach; Default-deny egress covers how and why. These hosts are always required:
Whether these hosts are needed depends on your configuration:
The runner doesn’t reach
statsig.anthropic.com, *.sentry.io, claude.ai, or platform.claude.com. These hosts appear in some older enterprise network checklists, but you don’t need to allowlist them for runner or session traffic: feature-flag fetches go to api.anthropic.com, and the runner authenticates with the environment secret rather than interactive OAuth. Two host-side flows do reach claude.ai, so run them from a host whose egress allows it rather than widening session-container egress: the one-line installer fetches install.sh from claude.ai at install time, and interactive claude auth login, which the guided setup, doctor’s signed-in mode, and CI dispatch use, signs in through claude.ai, claude.com, and platform.claude.com. mcp-proxy.anthropic.com isn’t required either: self-hosted sessions don’t use it, and delivery of your organization’s claude.ai connectors to sessions, when enabled for your organization, routes through api.anthropic.com. See MCP servers.
Default-deny egress
Deploy runner and session containers in a network segment or namespace whose outbound traffic is limited to the hosts in the network requirements table, your git host, and the specific internal services sessions need to reach. The product can’t verify or enforce this, so apply it at your own network boundary on every environment. Session code is model-directed and can attempt connections to arbitrary hosts; default-deny egress at the network layer bounds where those attempts can land. This applies regardless of permission mode: the default pre-approved tool set already includesBash, so shell egress runs without a prompt even without auto mode.
For details on which telemetry each session emits and how to turn it off, see Telemetry.
Configure git
The runner manages repository checkouts but doesn’t configure git identity or credentials by default. You control the runner’s image and process environment, so you control the git config. Choose one of two approaches:- Let the runner configure git: start the runner with
--configure-gitto have it write the same identity and commit-signing config that Anthropic-hosted sessions use - Ship git config in your image: set identity and push credentials yourself, for example to commit under your own bot identity
--configure-git SSH commit signing requires Git 2.34 or newer, --use-anthropic-git-proxy requires 2.32 or newer, and resuming sessions from branches pushed by --push-outcome-on-release requires 2.29 or newer. Git 2.24 is sufficient if you omit all three and manage git identity yourself.
Let the runner configure git
Start the runner with--configure-git, or set SELF_HOSTED_RUNNER_CONFIGURE_GIT=1, to have it write global git config at startup:
user.name = Claudeanduser.email = noreply@anthropic.com, matching Anthropic-hosted sessions- SSH-format commit and tag signing, routed through a runner-managed shim that signs each commit via Anthropic’s signing service using the session’s own credentials. Signatures are verifiable on GitHub against Anthropic’s published SSH signing key.
Ship git config in your image
Git identity is required for any commit. Set it system-wide in your Dockerfile so the config applies regardless of which user the runner process runs as:git commit fails with Please tell me who you are and sessions can’t make progress. You can use your own bot identity instead; the runner doesn’t override these values.
Don’t bake long-lived or broadly-scoped push credentials into a shared runner image: a credential in the image is available to every session the image runs, across every member of your organization. Instead, mint a short-lived, least-scoped token per session from your wrapper script, using the session creator’s identity decoded from the session JWT. Pair it with an ephemeral per-session container, which requires --capacity 1, so no credential outlives the session that minted it; see the hardening section.
If you must configure push credentials at the image level, for example for a read-only deploy key, scope them as tightly as your git host allows:
- An SSH deploy key limited to one repository with a
url.<base>.insteadOfrewrite - A
credential.helperthat returns a minimally-scoped token GIT_SSH_COMMANDpointing at a narrowly-scoped key
safe.directory:
Use the Anthropic git proxy
Start the runner with--use-anthropic-git-proxy, or set CLAUDE_RUNNER_USE_GIT_PROXY=1, to have it clone through Anthropic’s git proxy, authenticated with the session’s own short-lived token. For ordinary user sessions, the proxy uses the GitHub or GitHub Enterprise OAuth token stored for the session creator; for bot and agent sessions, it uses your organization’s GitHub App installation token. Either way, the runner image needs no git credentials at all: no SSH keys, no credential helper, no .netrc. This is the same auth path Anthropic-hosted environments use.
The proxy requires --capacity 1 because the proxy URL is per-session, and git 2.32 or newer because older git ignores the configuration mechanism the proxy uses to isolate sessions from each other. The runner refuses to start if either requirement is unmet. Because the proxy fetches from Anthropic’s side, your git host must be reachable from Anthropic infrastructure, the same requirement Anthropic-hosted sessions have; for a git host that’s only routable inside your network, use a checkout lifecycle hook instead. Each runner process handles one session at a time, so run more replicas for parallelism. When the proxy is enabled, --git-host-rewrite and --git-ssh-rewrite have no effect: the proxy URL points at api.anthropic.com, not your git host.
Rewrite git URLs for private networks
Repository URLs arrive from the control plane as HTTPS, with the hostname of your git host; for GitHub Enterprise, that’s the hostname you configured for the GitHub Enterprise integration in Claude Code admin settings on claude.ai. Two repeatable flags rewrite those URLs before clone:--git-host-rewrite <from>=<to>: for split-horizon DNS, where Anthropic reaches your git host via an external hostname but runners must use an internal one--git-ssh-rewrite <host>: for git hosts that only accept SSH, rewritinghttps://<host>/owner/repotogit@<host>:owner/repo
--git-ssh-rewrite if you need both. For full control over checkout, use a checkout lifecycle hook.
Build the runner image
Anthropic doesn’t publish a pre-built runner image. Build your own around theclaude binary, layering in whatever toolchain your repositories need: language runtimes, compilers, package managers, and MCP sidecars.
The recipes below use --capacity 4, so one container serves up to four concurrent sessions from the same locked account. That doesn’t provide the per-session container isolation in the hardening section: before connecting an environment to production systems, either run the recipes at --capacity 1 with one container per session, or use on-demand runners, which also keep the environment secret off session-running hosts.
This Dockerfile is a minimal starting point:
linux-x64 for linux-arm64 if your nodes are ARM, or for linux-x64-musl or linux-arm64-musl on a musl-based image such as Alpine; see Alpine Linux setup for the extra packages musl images need. The URL is the standard Claude Code release location, so you can verify the downloaded binary against the release’s signed manifest as described in Binary integrity and code signing. Build the image with Claude Code version 2.1.224 or later, then push it to your registry and reference it in the recipes below:
Kubernetes
The runner servesGET /healthz on port 8080 by default, configurable with --health-port, so Kubernetes probes work with no extra setup. The endpoint returns 200 whenever the process is alive, so the probes below detect a dead process, not a stuck one; to catch a runner that stopped polling, alert on the last_poll_age_seconds series from /metrics. The Deployment below mounts the environment secret from a Kubernetes Secret, points the liveness and readiness probes at /healthz, and sets a 90-second termination grace period. See Shutdown timing for why the grace period matters.
claude-runners namespace. Create the namespace first:
(umask 077 && cat > ./environment-secret), paste the secret, press Enter, then Ctrl-D. Then create the Secret and delete the file:
Docker Compose
The Compose service below restarts the runner whenever it exits, which covers both crashes and the normal exit after draining. A Docker restart policy restarts the same container with its writable layer intact, so the runner comes back on a reused filesystem rather than the fresh one the hardening posture recommends; use this recipe for evaluation, and for production either recreate the container per run or use an orchestrator that does.Shutdown timing
OnSIGTERM, the runner stops taking new work, waits up to --drain-wait-sec, zero by default, for in-flight turns to finish, terminates each child process, and runs the post-session lifecycle hook. The full drain path needs up to --session-stop-grace-sec + --drain-wait-sec + --post-session-hook-timeout-sec, plus 15 seconds of fixed overhead for process cleanup, plus 30 more seconds when --push-outcome-on-release is set. That is 80 seconds at defaults, and the runner logs the total at startup. Sessions drain in parallel under this one budget, so the total doesn’t grow with --capacity.
At the default --drain-wait-sec 0, a rolling restart interrupts in-flight turns; each session resumes on another runner, losing unpushed work as described under Known issues. Set --drain-wait-sec, and raise the grace period to match, to let turns finish first.
Throughout that whole path, the runner keeps heartbeating to the control plane at zero capacity, so the session lease doesn’t expire and get requeued to another runner while the post-session hook is still writing out uncommitted work. The heartbeat stops just before the runner deregisters.
Set terminationGracePeriodSeconds on Kubernetes, stop_grace_period on Docker Compose, or your orchestrator’s equivalent to at least the total the runner logs, so a container-wide kill never lands while a post-session hook is mid-run. If your hosts die at a known wall-clock time and you pass --retire-at, size the margin between the retire time and the kill to cover typical turns plus this same budget, and compute the value at each launch, for example date +%s plus the runner’s intended lifetime, rather than baking a static value into a restartable manifest. The Kubernetes default of 30 seconds is shorter than the runner’s drain path and will kill the pod mid-cleanup. For the flags that control each phase, see the runner CLI reference.
What reaches a running post-session hook
Thepost-session hook and the Claude session child each run in their own POSIX process group, separate from the runner’s, so stop mechanisms reach them differently:
- A second
SIGTERMto the runner: force-exits the runner immediately, skipping whatever remains of the drain path. Nothing signals a mid-runpost-sessionhook, so on a bare host where an init process adopts orphans, it finishes on its own, but unsupervised: its timeout budget no longer applies, and a write to the closed log pipe can kill it withSIGPIPE, so a hook that needs to survive a forced exit there should redirect its own output to a file. In the container recipes on this page the runner is the container’s PID 1 and its exit ends the container, and under systemd’s defaultKillMode=control-groupthe cgroup kill in the third bullet applies; in both, treat a forced exit as fatal to the hook and rely on the grace period instead. - Process-group-wide signals, such as
kill -- -<pid>in a wrapper script, shell job control, or a group-wide watchdog: reach the runner and a mid-checkout-hook subprocess, which stays group-attached deliberately, but not a mid-runpost-sessionhook or the session child. - Cgroup-wide kills, such as systemd’s default
KillMode=control-groupor theSIGKILLKubernetes delivers to the whole container whenterminationGracePeriodSecondsexpires: reach everything, including the hook. Process-group isolation doesn’t protect against these, which is why the grace period must cover the full drain path. - The hook’s own timeout: when a hook exceeds
--post-session-hook-timeout-sec, the runner sendsSIGTERMto the hook’s whole process group, thenSIGKILLtwo seconds later, so a worker the hook forked, such as tar, rsync, or git, terminates with the wrapper shell instead of surviving as an orphan. The runner’s supervision ends once the hook’s stdio closes: a worker that redirected its own output to a file and outlives theSIGTERMstage is past the runner’s reach.
SIGTERM, and again on a forced exit, the runner logs how many post-session hooks are still running, so you can tell a quiet drain from one that’s mid-snapshot.
Keep the base directory and capacity identical across runners
If a runner dies mid-session, the server requeues the session and another runner in the environment picks it up. That runner derives the checkout path from its own--base-dir and --capacity: --capacity 1 checks out directly under --base-dir, and a --capacity above 1 uses per-session worktrees instead. When runners in the same environment use different values for either flag, the resumed session’s working directory changes, and absolute paths the agent recorded earlier, in edits, tool calls, or its own notes, point at a location that no longer exists.
Use the same --base-dir and --capacity on every runner in an environment, and don’t use a per-host value such as an instance ID or hostname. The base directory must be writable by the user the runner runs as: the runner creates per-session directories under it when each session starts and doesn’t check it at startup, so a missing or read-only base directory typically shows up as sessions that fail immediately after pickup rather than as a startup error. The default is /workspace, which a runner running as root creates on first use; for a non-root runner, create the directory and give that user ownership before starting the runner, or point --base-dir at a directory the user already owns.
Reuse a pre-warmed checkout
For large repositories, the clone can dominate session startup. At--capacity 1 with no checkout hook, the runner keeps one canonical clone per repository at <base-dir>/<owner>/<repo> and reuses it across sessions: it fetches the requested ref, detaches HEAD, and resets hard to it, which is near-instant when little has changed. To skip the cold clone, bake a clone into your runner image at that path, which gives every fresh container the warm clone without reusing a disk, or point --base-dir at a persistent volume paired with --lock-to-account so the disk only ever serves one account.
What the reuse path does and doesn’t guarantee:
- Any clone shape works: a full, shallow, or single-branch clone at the path is used as-is. The runner never passes
--depthwhen fetching into an existing clone, so a full pre-warm keeps its full history and a shallow one stays shallow.CLAUDE_RUNNER_FETCH_DEPTH(full,0, or a number; default 50) controls only the cold clone the runner makes when no clone exists yet. - Tracked changes reset, untracked files persist: each session starts from a hard reset that wipes the previous session’s tracked modifications, but the runner never runs
git clean, so untracked files from the locked account’s earlier sessions stay in the tree. - With the git proxy, the reset becomes a checkout: with
--use-anthropic-git-proxy, the runner sanitizes the clone’s.git/before each session, keeping the object store, refs, and shallow state but deleting the index, so each session pays a full working-tree checkout instead of a near-instant reset; it still never re-clones. Submodule pre-warms aren’t supported under the proxy. - Long clones need no workaround: the runner bounds each git operation with a 120-second no-progress watchdog and a 30-minute hard cap, not a flat timeout, so a slow cold clone that keeps reporting progress completes.
Pin the version
Each session’s child Claude Code process runs the runner’s own binary, and the runner turns off auto-update inside the sessions it spawns, so every session runs the version you installed on the host or built into the image. A host-level update takes effect the next time the runner starts.- To hold a fleet on one version: build the image with a pinned version, or on a bare host install a specific version and disable auto-updates
- To upgrade: install the newer version or rebuild the image, then restart the runners
- Plugins: plugin marketplaces don’t auto-update either; set
FORCE_AUTOUPDATE_PLUGINS=1in the runner’s environment to let plugins auto-update while the binary stays pinned
Scale the fleet
Your orchestrator decides when to add or remove runners. Because of the one-user-per-runner lock, the minimum replica count is the number of users you expect to be active concurrently;--capacity controls parallelism within one user’s sessions, not across users.
Two scaling approaches are available:
- Fixed fleet: run a static set of runner replicas and scale on the Prometheus metrics each runner serves
- On-demand runners: run the
claude self-hosted-runner orchestratorsubcommand, which polls Anthropic for sessions that are queued with no runner available and invokes yourspawn-runnerhook to boot one per session. See On-demand runners.
Known issues and limitations
The following are the limitations in this release, with workarounds where one exists.Connector traffic leaves your network
Connector tools, such as GitHub, Slack, Linear, and the other claude.ai connectors, are called from Anthropic’s side rather than from your runner, so when a self-hosted session uses a connector, that traffic routes throughapi.anthropic.com, not from inside your network boundary. To keep a connector out of self-hosted sessions, filter it like any other MCP server with the allowedMcpServers and deniedMcpServers policy settings, which apply to server-delivered connectors too. An allowlist you deploy for other servers also blocks delivered connectors. To keep connectors available alongside a URL-based allowlist, add an entry matching the session proxy URL, such as https://api.anthropic.com/v2/ccr-sessions/*. If tool traffic must stay inside your network, run the equivalent tools as local MCP servers on the runner image instead. See MCP servers.
Some sessions don’t count as idle
A session holding a background task that never finishes doesn’t count as idle, so--release-idle-session-min won’t release that session’s slot. A session that’s waiting on an approval requested from inside a running tool call also doesn’t count as idle. Always set --kill-session-after-min alongside it as a hard backstop so no session can hold a slot indefinitely.
Additional limitations
- Resumed sessions lose unpushed work: when a session is released, on idle timeout or on a runner restart, and the user sends another message, the session resumes on a fresh runner that clones the repository again from its starting branch, so work the session hadn’t pushed is gone. Set
--push-outcome-on-releaseto have the runner make a best-effort push of the session’s outcome branches before it releases, so the resumed session starts from those commits instead; this preserves committed work, not a dirty working tree. Before enabling it, restrict who can push toclaude/*refs on the source remote, for example with a branch ruleset: on resume, the runner fetches the previously pushed branch without verifying who pushed it, so anyone with push access to those refs can place content into the resumed workspace. The runner also discards per-session configuration on resume, meaning the session’s Claude config directory and any shell state the session wrote;--push-outcome-on-releasedoesn’t cover those. - Private repositories can’t be added mid-session: a repository added to a session after it has started isn’t cloned with credentials on a self-hosted runner, so the add fails. Select every repository the session needs when you create it.
- Some connectors don’t appear in self-hosted sessions: a connector you haven’t yet connected in claude.ai Settings isn’t listed in a self-hosted session, and the session won’t prompt you to connect it. Connect it in Settings first, then start a fresh session. Adding a connector to an already-running session also doesn’t make its tools available to Claude; start a fresh session to pick up a newly added connector.
Report an issue
For issues with self-hosted environments, contact your Anthropic account team.Troubleshooting
For guided diagnosis, run the doctor subcommand on the runner host. It starts an interactive Claude Code session with read-only access to the runner’s logs and state; the only change it can make is requeuing a stuck session. Sign in withclaude auth login on that host first so the session can query your environment, its runners, and its queued sessions. Without that sign-in, for example when the host authenticates with an API key, it’s limited to the local health endpoint, metrics, and the runner’s log, and it reads the log only if you started the runner with --log-file.
- Runner doesn’t appear in the environment: confirm the host can reach
api.anthropic.comover HTTPS, the environment secret is current, and the host clock is within five minutes of real time; larger skew causes authentication to fail. The runner logs[runner:fatal]with the rejection reason on auth failure. - Sessions stay queued: every online runner may be locked to a different account. Check each runner’s
claude_code_self_hosted_runner_locked_accountmetric or itsPicked up sessionlog lines to see which account holds it. Add replicas, or wait for an existing runner to drain and restart. If the environment uses on-demand runners, check the orchestrator instead; see On-demand runners. - Sessions fail immediately after pickup: open the session in claude.ai/code to see the error. The most common causes are missing git credentials in the runner image, build tools that aren’t installed, and a base directory the runner’s user can’t write to. For the last one the error is
EACCESon a path under--base-dir, default/workspace; fix the directory’s ownership or point--base-dirat a writable path, as described in Keep the base directory and capacity identical across runners. - A session’s branch no longer exists on the remote: for a git source the session only reads from, the runner skips that source and continues on the remaining ones. For the source the session pushes results to, a deleted branch, typically because it was merged and auto-deleted, fails the session with an error naming the repository and branch and asking you to restore the branch and retry.
- Sessions take minutes to start: the initial clone usually dominates. Watch the
claude_code_self_hosted_runner_session_init_duration_secondsmetric to confirm, and cut the clone with a pre-warmed checkout or a smallerCLAUDE_RUNNER_FETCH_DEPTH. - Pod is killed mid-drain: raise
terminationGracePeriodSecondsto at least the value the runner logs at startup. See Shutdown timing.
[runner:fatal] lines, to stdout, and debug output to stderr, all as plain-text lines rather than JSON; capture both streams with --log-file, which also lets self-hosted-runner doctor tail them, or with your platform’s log collection. Each session’s child process writes a separate debug log; on failure the runner preserves the log, prints the log’s path in the runner log, and surfaces the log’s tail alongside the session in claude.ai/code.
What’s next
- Customize sessions: wrapper scripts, lifecycle hooks, on-demand runners, MCP servers, and permissions
- Test end to end: verify a new runner image from CI before promoting it
- Reference: every CLI flag, environment variable, and metric