New to Claude Skills? Learn how to install them →

Deploying a Self-Hosted Claude Code Runner to Production

The quickstart gets one runner talking to Anthropic. This guide covers what production actually needs: egress control, git credentials, Kubernetes and Compose recipes, and drain timing.

August 16, 2026
Get Claude Skills
11 min read

What production adds beyond the quickstart

Self-hosted environments let Claude Code cloud sessions run on infrastructure you control instead of Anthropic's, by starting claude self-hosted-runner on a host of your own. The quickstart gets one runner registered and taking sessions in a few minutes. What it deliberately leaves out, because a first test doesn't need it, is everything that matters once real repositories and real credentials are involved: what to lock down before connecting production systems, exactly which hosts the fleet needs to reach, how sessions actually authenticate to your git host, and how to keep the fleet running under an orchestrator rather than as one process that exits when it's done. This guide covers that ground, verified against Anthropic's own production deployment documentation.

One thing worth saying up front: a self-hosted runner executes arbitrary, model-directed code on your infrastructure, on behalf of any member of your Anthropic organisation. Dispatch is organisation-wide by default, there's no per-environment access control on who can send a session to a given runner host, so treat every runner host as reachable by every org member, and don't place data or credentials on it that not everyone should be able to read.

Harden the deployment first

Work through this before connecting an environment to anything that matters:

  • Run ephemeral, per-session containers. Start the runner with --capacity 1 and the default --drain-grace-sec 0 so each container serves exactly one session, then destroy it. A higher capacity or a positive drain grace means one container serves multiple sessions from the same locked account, which is fine for evaluation but not for isolation.
  • Keep broad credentials out of the image. No long-lived SSH keys, cloud credentials, or personal access tokens that grant more than a single session needs. Mint what a session needs from your wrapper script instead, covered under Configure git below.
  • Keep the environment secret off session-running hosts. The secret registers runners and can pick up any org member's queued sessions. On a fixed fleet it has to live on every runner host, where any session's code can read it. On-demand runners keep it on the orchestrator host instead, which never itself runs user code.
  • Default-deny egress at your network boundary, restricting runner and session containers to the hosts in the network table below plus your git host and whatever internal services sessions genuinely need. Anthropic's tooling can't enforce this for you; it has to be a network-layer control on every environment.
  • Block the cloud metadata endpoint from inside the session container. Subnet-level egress rules don't intercept link-local traffic to 169.254.169.254, so block it explicitly: IMDSv2 with a hop limit of one, GKE Workload Identity with metadata concealment, or an explicit deny in the container's network namespace.
  • Make host identity least-privilege. The instance profile or node service account attached to the runner host should grant only what the runner itself needs; sessions should get their own credentials through your wrapper script rather than inheriting the host's.
  • Enforce the repo-settings guard with --confine-repo-settings enforce rather than the default warn, so a session refuses to start rather than merely logging a violation when a repository's committed settings grant something that resolves outside the session's own workspace, such as an additionalDirectories entry or a sandbox.filesystem.allowWrite rule.

Network requirements

Restrict session-container egress to exactly what the runner and its session children need, plus your git host. Two hosts are always required:

HostPortUsed for
api.anthropic.com443 (HTTPS; WSS for the SCM connector)Runner control plane, session streaming, model inference, JWKS key fetches, commit signing, and the git proxy when --use-anthropic-git-proxy is set
Your git host (github.com, or your GitHub Enterprise host)443 or 22Cloning and pushing. Not needed when the runner uses the Anthropic git proxy instead

Several more are conditionally required, depending on configuration:

HostWhen it's needed
downloads.claude.aiInstalling or updating Claude Code with the native installer; sessions installing plugins from the official marketplace
storage.googleapis.comMarketplace plugin catalog fetches and Artifact publishing (falls back to api.anthropic.com if blocked)
code.claude.com and claude.comDocumentation lookups by the built-in claude-code-guide agent. Blocking these only affects doc lookups
*.frame.claudeusercontent.comOnly if the Artifact tool is available to your organisation; set CLAUDE_CODE_DISABLE_ARTIFACT=1 on the runner to keep it off regardless
raw.githubusercontent.comThe /release-notes changelog fetch; suppressed by CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
registry.npmjs.orgInstalling an npm-source plugin, or an npx-launched MCP server

Anthropic's guidance is explicit that the runner never needs statsig.anthropic.com, *.sentry.io, claude.ai, or platform.claude.com for runner or session traffic, some older enterprise checklists list them, but feature flags route through api.anthropic.com and the runner authenticates with the environment secret rather than interactive OAuth. claude.ai and platform.claude.com only come up for two host-side flows that don't need session-container egress at all: the one-line installer, and interactive claude auth login for the doctor subcommand or CI dispatch.

Configure git for production

The runner manages checkouts but doesn't configure git identity or credentials on its own, you control the image, so you control this. Three options:

Let the runner configure git. Start with --configure-git (or set SELF_HOSTED_RUNNER_CONFIGURE_GIT=1) to write user.name = Claude and user.email = noreply@anthropic.com, plus SSH-format commit signing routed through a runner-managed shim, matching what Anthropic-hosted sessions do. This needs Git 2.34 or newer and doesn't configure push credentials on its own.

Ship git config in your image. Set identity system-wide in your Dockerfile, or use your own bot identity:

RUN git config --system user.name "Claude" && \
    git config --system user.email "noreply@anthropic.com"

Without an identity, git commit fails with Please tell me who you are. Don't bake a long-lived, broadly-scoped push credential into a shared image, since it becomes readable by every session that image runs, across your whole organisation. Mint a short-lived, least-scoped token per session from your wrapper script instead, paired with an ephemeral per-session container so no credential outlives the session that minted it.

Use the Anthropic git proxy. Start with --use-anthropic-git-proxy (or CLAUDE_RUNNER_USE_GIT_PROXY=1) to clone through Anthropic's proxy, authenticated with the session's own short-lived token, the session creator's GitHub OAuth token for ordinary sessions, or your organisation's GitHub App installation token for bot and agent sessions. The runner image needs no git credentials at all with this option. It requires --capacity 1 and Git 2.32 or newer, and your git host has to be reachable from Anthropic's infrastructure, the same requirement Anthropic-hosted sessions already have.

The runner disables interactive prompts across all three options: GIT_TERMINAL_PROMPT=0, SSH BatchMode=yes, and GCM_INTERACTIVE=never, so whichever credential mechanism you configure has to work without a prompt or the runner retries a few times and then fails repository preparation.

Build the runner image

Anthropic doesn't publish a pre-built image; you build your own around the claude binary. A minimal starting point:

FROM debian:bookworm-slim
ARG CLAUDE_CODE_VERSION
RUN apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates openssh-client \
 && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL "https://downloads.claude.ai/claude-code-releases/${CLAUDE_CODE_VERSION:?set with --build-arg CLAUDE_CODE_VERSION}/linux-x64/claude" \
      -o /usr/local/bin/claude && chmod +x /usr/local/bin/claude
RUN git config --system user.name "Claude" \
 && git config --system user.email "noreply@anthropic.com" \
 && git config --system --add safe.directory '*'
ENTRYPOINT ["claude"]

Swap linux-x64 for linux-arm64 on ARM nodes, or the -musl variants on Alpine. Build with Claude Code 2.1.224 or later:

docker build --build-arg CLAUDE_CODE_VERSION=2.1.224 -t <your-registry>/claude-runner:latest .

Deploy with Kubernetes

The runner serves GET /healthz on port 8080 by default, so standard probes work with no extra setup:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: claude-runner
  namespace: claude-runners
spec:
  replicas: 3
  selector:
    matchLabels:
      app: claude-runner
  template:
    metadata:
      labels:
        app: claude-runner
    spec:
      terminationGracePeriodSeconds: 90
      containers:
        - name: runner
          image: <your-registry>/claude-runner:latest
          args:
            - self-hosted-runner
            - --environment-secret-file
            - /etc/claude/environment-secret
            - --capacity
            - "4"
          volumeMounts:
            - name: environment-secret
              mountPath: /etc/claude
              readOnly: true
          ports:
            - name: health
              containerPort: 8080
          readinessProbe:
            httpGet: { path: /healthz, port: 8080 }
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            initialDelaySeconds: 30
            periodSeconds: 30
      volumes:
        - name: environment-secret
          secret:
            secretName: claude-runner-environment-secret

Create the secret from a local file so the value never lands in shell history:

kubectl create namespace claude-runners
(umask 077 && cat > ./environment-secret)   # paste the secret, Enter, then Ctrl-D
kubectl create secret generic claude-runner-environment-secret \
  -n claude-runners --from-file=environment-secret=./environment-secret

Raise terminationGracePeriodSeconds to at least what the runner logs at startup, Kubernetes' 30-second default is shorter than the runner's drain path, so a rolling update or scale-down kills the runner before it finishes draining. See shutdown timing below for why.

Deploy with Docker Compose

services:
  claude-runner:
    image: <your-registry>/claude-runner:latest
    command:
      - self-hosted-runner
      - --environment-secret-file
      - /run/secrets/environment-secret
      - --capacity
      - "4"
    secrets:
      - environment-secret
    restart: always
    stop_grace_period: 90s

secrets:
  environment-secret:
    file: ./environment-secret

A Docker restart policy restarts the same container with its writable layer intact, so the runner comes back on a reused filesystem rather than a fresh one. That's the right tradeoff for evaluation, but the hardening section above recommends a fresh filesystem per run in production, so either recreate the container on every run or move to an orchestrator that does.

Shutdown timing matters more than it looks

On SIGTERM, 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, then runs the post-session lifecycle hook. The full drain path needs --session-stop-grace-sec + --drain-wait-sec + --post-session-hook-timeout-sec, plus 15 seconds of fixed overhead, plus 30 more seconds when --push-outcome-on-release is set, which is 80 seconds at defaults. The runner logs this total at startup, and it's the number your terminationGracePeriodSeconds or stop_grace_period needs to cover, or the host kills the runner mid-drain and interrupts whatever turn was still running. At the default --drain-wait-sec 0, a rolling restart interrupts in-flight turns outright; each session resumes on another runner, but loses unpushed work unless --push-outcome-on-release is set.

Reuse a pre-warmed checkout for large repositories

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, fetching the requested ref and resetting hard to it rather than re-cloning. For a large repository where the cold clone dominates startup time, bake a clone into your runner image at that same path, or point --base-dir at a persistent volume paired with --lock-to-account so the disk only ever serves one account. Any clone shape works, full, shallow, or single-branch, and the runner never deepens or reshapes an existing clone.

Pin the version across the fleet

Every session's child process runs the runner host's own binary, and the runner turns off auto-update inside the sessions it spawns, so build the image with a pinned CLAUDE_CODE_VERSION (or install a specific version on a bare host and disable auto-updates there). To upgrade, rebuild the image and restart the runners. Plugin marketplaces don't auto-update either; set FORCE_AUTOUPDATE_PLUGINS=1 if you want plugins to update while the binary stays pinned.

Scale the fleet

Because of the one-user-per-runner lock, the minimum replica count is the number of users you expect active concurrently, --capacity controls parallelism within one user's sessions, not across users. Two approaches:

  • Fixed fleet: a static set of replicas, scaled by watching the Prometheus metrics each runner exposes.
  • On-demand runners: run claude self-hosted-runner orchestrator, which polls for sessions queued with no runner available and boots one per session through your own spawn-runner hook. This keeps the environment secret on the orchestrator host only, which never runs user code itself, closing the gap the hardening section flags for a fixed fleet.

Known limitations worth planning around

  • Connector traffic leaves your network. Tools like GitHub, Slack or Linear connectors are called from Anthropic's side, not from your runner, so that traffic routes through api.anthropic.com regardless of your egress rules. Filter a connector with allowedMcpServers/deniedMcpServers policy settings if it needs to stay out of self-hosted sessions, or run the equivalent tool as a local MCP server on the runner image instead.
  • Resumed sessions lose unpushed work by default. Set --push-outcome-on-release to have the runner best-effort push outcome branches before releasing a session, and restrict who can push to claude/* refs with a branch ruleset before you do, since the runner fetches a resumed session's branch without verifying who pushed it.
  • A repository added mid-session isn't cloned with credentials. Select every repository a session needs when you create it; adding one afterward fails silently on a self-hosted runner.

Troubleshooting

Runner doesn't appear in the environment. Confirm the host can reach api.anthropic.com over HTTPS and that the host clock is within five minutes of real time, larger skew fails authentication and the runner logs [runner:fatal] with the reason.

cannot create or write to base directory. The runner can't create or write --base-dir (default /workspace). Fix ownership or point at a writable path. Before v2.1.225 the runner didn't check this at startup, so the failure showed up later, after session pickup, instead.

Sessions stay queued. Every online runner may be locked to a different account. Check each runner's claude_code_self_hosted_runner_locked_account metric, or add replicas.

Sessions fail immediately after pickup. Open the session in claude.ai/code for the actual error. The two most common causes are missing git credentials in the image and a build tool the repository needs that isn't installed.

Pod killed mid-drain. Raise terminationGracePeriodSeconds to at least the total the runner logs at startup; see shutdown timing.

For guided diagnosis on the host itself, run claude self-hosted-runner doctor, an interactive session with read-only access to the runner's logs and state, signed in with claude auth login first so it can query your environment and queued sessions.

Where to go next

This guide assumes the quickstart is already done, one runner registered and a session routed to it. What doesn't change once you're running in production is how Agent Skills are discovered: a project-scoped skill in .claude/skills/ travels with the repository the runner clones, exactly as it would on a laptop, and nothing about egress control or git credentials touches that. Before trusting any skill on a runner with access to your internal network, read the security guide, the stakes are higher here than on a personal machine. For the permission model governing what a session can do once it's running, see Claude Code's auto mode explainer. Browse the current Claude Code skill catalog at getclaudeskills.com/platforms/claude-code, or everything cataloged at getclaudeskills.com/skills.

Frequently asked questions