Skip to content

Self-Hosted Claude Code: Run on Your Infrastructure

Core Concept LearningAugust 18, 202611 min read

Some teams cannot use cloud services: legal holds, data residency rules, or air-gapped networks. For them, Claude Code must run on-premises. This means building a complete Claude Code runner stack inside your own infrastructure—handling authentication, credential management, session persistence, and monitoring.

This guide covers self-hosted Claude Code: why you might choose it, how to set up runners, handle git credentials and network egress, run on Kubernetes or Docker Compose, and troubleshoot common problems.

Why Self-Hosted vs. Cloud

Cloud deployments (AWS SageMaker, Azure ML, GCP Vertex) are convenient but violate some organizations' requirements. Self-hosted means you control every layer: compute, storage, network, and credentials stay in your data centers.

Trade-offs: self-hosted requires more ops work. You manage infrastructure, patching, backups, and failover. But it gives you:

  • Data residency: all code and context stay in your country/region.
  • Air-gapped security: no internet egress unless you explicitly allow it.
  • Compliance: HIPAA, SOC 2, PCI-DSS audits see your infrastructure, not a third-party's.
  • Custom networking: integrate with your VPN, reverse proxies, and legacy systems.

Quick reference

  • Use self-hosted if: regulated industry, data residency laws, airgap requirement, or custom infra integration.
  • Avoid self-hosted if: you want operational simplicity, multi-region scale, or minimal ops overhead.
  • Hybrid: self-hosted runners for sensitive work; cloud runners for general purpose.
  • Compliance: document runner deployment, access controls, and audit logs for regulators.

Remember this

Self-hosted Claude Code runs entirely on your infrastructure, giving you full control over data, compliance, and networking at the cost of operational complexity.

Runner Setup and Image Building

A Claude Code runner is a Docker container or VM that executes code in isolation. It receives a request (user code, context, model), spawns a sandbox, runs the code, captures output, and returns it to the control plane.

Runners need: Docker or a container runtime, Python 3.10+, git client, and the Claude Code agent SDK. Build a base image with these, then run it as a pod (Kubernetes) or container (Docker Compose).

The image contains: dependencies, configuration templates, health check scripts, and logging agents. Size it 2–4 GB to keep startup latency under 5 seconds.

Quick reference

  • Base image: Linux (Ubuntu 22.04 or RHEL 8), Python 3.10+, Docker (or container runtime), git.
  • Claude Code agent SDK: installed via pip; version pinned for reproducibility.
  • Security: run as non-root; use multi-stage build to exclude build deps from final image.
  • Health check: liveness probe returns 200 if runner is ready; readiness probe confirms connectivity to control plane.
  • Logging: all output to stdout (JSON); container runtime or logging agent captures for centralization.
Dockerfile
1# Dockerfile for Claude Code runner2 3FROM ubuntu:22.044 5# Install dependencies6RUN apt-get update && apt-get install -y \7    python3.10 \8    python3-pip \9    git \10    curl \11    ca-certificates \12    && rm -rf /var/lib/apt/lists/*13 14# Install Claude Code agent SDK15RUN pip install claude-code-agent==2.0.1 --no-cache-dir16 17# Create non-root user18RUN useradd -m -u 1000 runner19USER runner20 21# Health check22HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \23    CMD curl -f http://localhost:8080/health || exit 124 25EXPOSE 808026CMD ["python3", "-m", "claude_code_runner"]
Kubernetes StatefulSet
1# kubernetes/runner-statefulset.yaml2 3apiVersion: apps/v14kind: StatefulSet5metadata:6  name: claude-code-runners7spec:8  serviceName: claude-code-runners9  replicas: 310  selector:11    matchLabels:12      app: claude-code-runner13  template:14    metadata:15      labels:16        app: claude-code-runner17    spec:18      containers:19      - name: runner20        image: company.azurecr.io/claude-code-runner:v1.0.021        ports:22        - containerPort: 808023          name: http24        volumeMounts:25        - name: cache26          mountPath: /home/runner/.cache27        env:28        - name: RUNNER_ID29          valueFrom:30            fieldRef:31              fieldPath: metadata.name32        - name: CONTROL_PLANE_URL33          value: http://control-plane:800034        livenessProbe:35          httpGet:36            path: /health37            port: 808038          initialDelaySeconds: 1039          periodSeconds: 3040  volumeClaimTemplates:41  - metadata:42      name: cache43    spec:44      accessModes: [ "ReadWriteOnce" ]45      resources:46        requests:47          storage: 10Gi

Remember this

Build a container image with Python, Docker, git, and the Claude Code SDK; deploy as Kubernetes pods or containers with health checks and persistent cache.

Network Egress and Git Credential Management

Self-hosted runners need to: (1) pull dependencies (pip packages, git repos), (2) authenticate to private git repos, and (3) optionally send telemetry/logs to central services.

Network egress is controlled. If your network is air-gapped, allow specific egress rules (package registries, git servers, logging services). If not, all traffic flows through a proxy.

Git credentials must not be baked into the image. Use environment variables or a credential helper. For Kubernetes, store credentials in Secrets. For Docker Compose, use a .env file (excluded from Git).

Private git repos require SSH keys or tokens. Mount the SSH key from a Kubernetes Secret, or inject it at container startup. Rotate keys every 90 days.

Quick reference

  • Network policy: restrict egress to approved destinations (pypi.org, git.company.com, logs.company.com).
  • Proxy: if air-gapped, configure pip, git, and Docker to use corporate proxy (HTTP_PROXY env var).
  • Git credentials: mount SSH key from Secret, or use git credential helper with token.
  • SSH key rotation: monthly job updates SSH key in Secret; old key remains valid for 7 days grace period.
  • Telemetry: if allowed, send logs to central logging service (e.g., Splunk, ELK); otherwise log locally and scrape.
Git Credentials Setup
1# kubernetes/runner-secret.yaml2 3apiVersion: v14kind: Secret5metadata:6  name: runner-git-ssh7type: Opaque8data:9  ssh-private-key: <base64-encoded SSH key>10  ssh-public-key: <base64-encoded SSH key public>11 12---13 14apiVersion: v115kind: ConfigMap16metadata:17  name: git-config18data:19  ssh-config: |20    Host git.company.com21        HostName git.company.com22        User git23        IdentityFile /home/runner/.ssh/id_rsa24        StrictHostKeyChecking no
Credential Injection
1# Runner init script (handles credentials)2 3#!/bin/bash4set -e5 6# Mount SSH key from Kubernetes Secret7mkdir -p /home/runner/.ssh8cp /var/run/secrets/ssh/id_rsa /home/runner/.ssh/9chmod 600 /home/runner/.ssh/id_rsa10 11# Configure git to use SSH12git config --global core.sshCommand "ssh -i /home/runner/.ssh/id_rsa"13 14# Add git server to known_hosts15ssh-keyscan -H git.company.com >> /home/runner/.ssh/known_hosts16 17# Export for pip (if using private pypi with token auth)18export PIP_INDEX_URL="https://token:\${PYPI_TOKEN}@pypi.company.com/simple/"19 20# Start runner21exec python3 -m claude_code_runner

Remember this

Git credentials and API tokens are mounted from Kubernetes Secrets or injected at startup, never baked into images; rotate regularly and audit access.

Kubernetes Recipes: DaemonSet and Job

For Kubernetes deployments, two patterns:

DaemonSet: one runner pod per node. Good if every node should be able to run code. Runners share node resources (CPU, memory). Use this for uniform clusters.

Job: ephemeral pods spawned on-demand. Each request spawns a new pod, runs code, and exits. Good if traffic is bursty or you want strict isolation per request.

We recommend StatefulSet (covered earlier) for production: named pods, persistent cache, and orderly scaling.

Quick reference

  • DaemonSet: one pod per node; use for clusters where every node is identical.
  • Job: ephemeral pods; use for bursty traffic or strict per-request isolation.
  • StatefulSet: named pods (runner-0, runner-1, runner-2); persistent cache; orderly scaling.
  • Resource requests/limits: request 1CPU, 2GB memory; limit 2CPU, 4GB; adjust based on workload.
  • Pod disruption budget: allow at most 1 runner to be evicted during maintenance.
DaemonSet
1# DaemonSet pattern: one runner per node2 3apiVersion: apps/v14kind: DaemonSet5metadata:6  name: claude-code-runners7spec:8  selector:9    matchLabels:10      app: claude-code-runner11  template:12    metadata:13      labels:14        app: claude-code-runner15    spec:16      nodeSelector:17        runner: "true"  # Only run on nodes labeled runner=true18      containers:19      - name: runner20        image: company.azurecr.io/claude-code-runner:v1.0.021        resources:22          requests:23            cpu: "1"24            memory: "2Gi"25          limits:26            cpu: "2"27            memory: "4Gi"
Job
1# Job pattern: ephemeral pods on-demand2 3apiVersion: batch/v14kind: Job5metadata:6  name: claude-code-run-{{ uuid }}7spec:8  backoffLimit: 19  ttlSecondsAfterFinished: 30010  template:11    spec:12      containers:13      - name: runner14        image: company.azurecr.io/claude-code-runner:v1.0.015        env:16        - name: JOB_ID17          value: "{{ uuid }}"18        - name: USER_CODE19          value: "{{ user_code }}"20      restartPolicy: Never

Remember this

DaemonSet runs one runner per node; Job spawns ephemeral runners on demand; StatefulSet offers balance with persistent cache and named identity.

Docker Compose for Small Teams

For teams <50 engineers, Docker Compose on a single machine is simpler than Kubernetes. Define all services (runners, control plane, Redis, database) in one file, and manage with docker-compose commands.

Caveats: single-node failure is total outage. No auto-scaling. Good for dev/test or on-prem teams with low traffic.

For production <50 engineers, consider: Docker Compose on a machine with SSD and 32GB RAM, daily snapshots to S3, and a hot-standby machine ready to restore.

Quick reference

  • Single docker-compose.yml: runners, control-plane, redis, postgres, nginx.
  • Volumes: runner cache, database data, logs all mounted on host.
  • Networking: all services on a shared network; runners reach control-plane via service name.
  • Scaling: scale runners with 'docker-compose up --scale runner=5'.
  • Backup: cron job nightly snapshots database and runner cache to S3.
docker-compose.yml
1# docker-compose.yml for small teams2 3version: '3.8'4 5services:6  runner:7    build: ./runner8    image: claude-code-runner:latest9    environment:10      CONTROL_PLANE_URL: http://control-plane:800011      RUNNER_ID: runner-{{ .Task.Slot }}12    volumes:13      - runner-cache:/home/runner/.cache14      - /var/run/docker.sock:/var/run/docker.sock15    networks:16      - claude-net17    restart: always18    deploy:19      replicas: 320 21  control-plane:22    build: ./control-plane23    image: claude-code-control-plane:latest24    ports:25      - "8000:8000"26    environment:27      REDIS_URL: redis://redis:637928      DATABASE_URL: postgresql://postgres:password@postgres:5432/claude_code29    volumes:30      - ./config/settings.yaml:/etc/claude-code/settings.yaml31    depends_on:32      - redis33      - postgres34    networks:35      - claude-net36    restart: always37 38  redis:39    image: redis:7-alpine40    volumes:41      - redis-data:/data42    networks:43      - claude-net44    restart: always45 46  postgres:47    image: postgres:15-alpine48    environment:49      POSTGRES_PASSWORD: password50      POSTGRES_DB: claude_code51    volumes:52      - postgres-data:/var/lib/postgresql/data53    networks:54      - claude-net55    restart: always56 57  nginx:58    image: nginx:alpine59    ports:60      - "80:80"61      - "443:443"62    volumes:63      - ./nginx.conf:/etc/nginx/nginx.conf:ro64      - ./ssl:/etc/nginx/ssl:ro65    depends_on:66      - control-plane67    networks:68      - claude-net69    restart: always70 71volumes:72  runner-cache:73  redis-data:74  postgres-data:75 76networks:77  claude-net:78    driver: bridge
Backup Script
1#!/bin/bash2# Nightly backup script3 4BACKUP_DIR=/backups5S3_BUCKET=s3://company-claude-code-backups6 7# Snapshot database8docker exec claude-code-postgres_1 pg_dump -U postgres claude_code | gzip > $BACKUP_DIR/postgres-$(date +%Y%m%d-%H%M%S).sql.gz9 10# Backup runner cache11tar czf $BACKUP_DIR/runner-cache-$(date +%Y%m%d-%H%M%S).tar.gz /docker/volumes/runner-cache/_data/12 13# Upload to S314aws s3 sync $BACKUP_DIR $S3_BUCKET --delete --storage-class GLACIER15 16# Keep local backups for 7 days17find $BACKUP_DIR -type f -mtime +7 -delete

Remember this

Docker Compose is ideal for <50 engineers; add nightly snapshots to S3 and a hot-standby machine for production resilience without Kubernetes complexity.

Session Identity Verification: JWT Validation

Runners must trust that requests come from authenticated users. When the control plane forwards a request to a runner, it includes a JWT signed by the control plane. The runner validates the signature to confirm the control plane is legitimate (not a man-in-the-middle).

JWT payload includes: user_id, team, session_id, issued_at, expires_at. The runner checks: signature is valid, token is not expired, user is allowed to run code.

For mutual TLS (mTLS), both control plane and runners exchange certificates. This is more secure but requires certificate management.

Quick reference

  • JWT token: signed by control plane's private key; runner verifies with public key.
  • Payload: user_id, team, session_id, timestamp, expires_at (5-min expiry).
  • Verification: check signature, expiration, and user is in allowed_users list.
  • mTLS: both control-plane and runner use client certificates; verify on connection.
  • Certificate rotation: CA re-signs certs every 90 days; old certs valid for 30-day grace.
JWT Issuance
1# Control plane issues JWT to runner2 3import jwt4from datetime import datetime, timedelta5 6SECRET_KEY = open("/run/secrets/jwt_secret").read()7 8def create_session_token(user_id: str, team: str) -> str:9    payload = {10        "user_id": user_id,11        "team": team,12        "session_id": generate_uuid(),13        "iat": datetime.utcnow(),14        "exp": datetime.utcnow() + timedelta(minutes=5)15    }16    token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")17    return token18 19# Forward request to runner with JWT20headers = {21    "Authorization": f"Bearer {create_session_token(user_id, team)}"22}23response = requests.post(RUNNER_URL, json=request, headers=headers)
JWT Validation
1# Runner validates JWT2 3import jwt4from functools import wraps5 6PUBLIC_KEY = open("/etc/runner/jwt_public_key").read()7 8def verify_jwt(token: str) -> dict:9    try:10        payload = jwt.decode(token, PUBLIC_KEY, algorithms=["HS256"])11        if datetime.fromtimestamp(payload["exp"]) < datetime.utcnow():12            raise ValueError("Token expired")13        return payload14    except jwt.InvalidSignatureError:15        raise ValueError("Invalid token signature")16 17@app.post("/run")18def run_code(request, authorization: str):19    token = authorization.replace("Bearer ", "")20    payload = verify_jwt(token)21    user_id = payload["user_id"]22    # Execute code for user23    ...

Remember this

Runners verify JWT tokens signed by the control plane, ensuring only authenticated requests execute code; short TTL (5 min) limits exposure.

Cost Considerations: Compute, Bandwidth, Maintenance

Self-hosted isn't free. Compute: each runner pod costs money (server, power, colocation). Bandwidth: egress to git servers, package registries, and logging. Maintenance: patching, backups, monitoring, on-call support.

For 500 engineers, estimate: 3–5 large servers ($20K/year), bandwidth ($5K/year), managed database ($10K/year), backup storage ($2K/year), and 1 FTE for ops ($150K/year). Total: ~$200K/year.

Cloud deployment: similar traffic on AWS = $150K/year in compute + management by AWS. If you have on-prem infrastructure already, self-hosted marginal cost is lower; otherwise, cloud is more cost-effective.

Quick reference

  • Compute: 3–5 high-memory servers for 500 engineers; each $200–300/month if collocated.
  • Bandwidth: 100 GB/month outbound = ~$500–1000/month; negotiate tier rates with ISP.
  • Database: managed PostgreSQL (AWS RDS or self-hosted) ~$500–2000/month.
  • Storage: daily snapshots to S3/GCS = $100–200/month.
  • Operations: 1 FTE full-time, or 0.5 FTE if using managed services.

Remember this

Self-hosted costs ~$150–250K/year for 500 engineers; only economical if you already have on-prem infra or need strict compliance.

Troubleshooting and Monitoring

Common issues:

1. Runners crash on startup: check logs for missing dependencies or credentials. Verify Docker image includes all required packages.

2. Slow code execution: runner CPU/memory limits too low, or underlying node is overloaded. Check Kubernetes metrics (kubectl top nodes); increase resource requests.

3. Git credential failures: SSH key expired or not mounted. Verify Secret exists; check runner logs for "Permission denied" errors.

4. Control plane can't reach runners: network policy blocking traffic, or runners not healthy (failed health checks). Use kubectl port-forward to test connectivity.

5. Data residency violation: code/context sent outside your network. Check firewall rules; enable egress logging to audit traffic.

Monitor: runner CPU, memory, disk (cache), latency per request, error rate, and unauthorized access attempts.

Quick reference

  • Logs: kubectl logs -f deployment/claude-code-runners; check for errors on startup.
  • Metrics: kubectl top nodes/pods; alert if runner memory > 80% or CPU > 70%.
  • Health: kubectl get pods; check READY and RESTARTS columns.
  • Network: tcpdump -i any to diagnose packet loss or firewall blocks.
  • Audit: log all requests to runners, including JWT subject and timestamp.

Remember this

Monitor runner health, logs, and metrics; common failures are missing dependencies, resource contention, or network connectivity issues.

Key takeaway

Self-hosted Claude Code gives you full control over data, compliance, and infrastructure. The cost is operational complexity: managing runners, credentials, backups, and monitoring.

Start small: Docker Compose for dev/test. Migrate to Kubernetes if traffic grows. Always version your infrastructure code and test failover.

The tradeoff is clear: cloud is simpler; self-hosted is more controlled.

Next: understand how to track spending across self-hosted or cloud runners, or dive into enterprise security patterns for regulated environments.

Share:
PK

Polo Khan

Lead Author & Systems Architect

Software engineer and distributed systems architect specializing in backend scalability, cloud-native infrastructure, databases, and AI engineering workflows. Author and maintainer of Core Concept Learning.

Human-Engineered & Fact-CheckedOriginal Visual DiagramsEditorial Standards →Send Feedback

Related Articles

AI coding agents change where engineering effort is spent: the hard problem moves from typing code to defining boundarie

Read

An agent can turn a small ticket into a working patch quickly. It can also turn an ambiguous ticket into a convincing pi

Read

Claude Code is an agentic coding tool that can inspect a repository, propose edits, run permitted commands, and work thr

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.