Self-hosting a Claude Agent SDK application means running your Python application, the SDK’s Claude Code subprocess and its working state on infrastructure you control. Claude inference remains a provider service. The deployment is therefore both a local process-management problem and an application that depends on an external API—not a way to install Claude’s model weights on a VPS.
Start with a supervised, read-only job before turning your prototype into an unattended worker. The path below pins the Python SDK, restricts the agent to one harmless file, records terminal outcomes and preserves session state. It is a reader-owned acceptance exercise, not a claim that this deployment has been executed or benchmarked here.
Pin the Python package and keep the CLI relationship clear
The Claude Agent SDK for Python is installed as claude-agent-sdk and requires Python 3.10 or newer. This example pins release 0.2.162; its corresponding repository records Claude Code CLI 2.1.284. The normal wheel installation bundles the native CLI, so this path does not require a separate global Claude Code or Node.js installation.
Keep the resulting image identifier and installed dependency record with your application revision. A later SDK upgrade can change the bundled CLI as well as Python behaviour; rebuild a candidate image rather than upgrading a running worker in place.
Authentication is separate. The example uses a dedicated test API key supplied as ANTHROPIC_API_KEY at runtime. The SDK reads the process environment; it does not automatically load a .env file. Documented alternatives include Amazon Bedrock, Claude Platform on AWS, Google Cloud’s Agent Platform and Microsoft Foundry, each with its own routing and credentials. Third-party products must not offer claude.ai login or subscription limits without Anthropic’s prior approval.
Prepare an isolated, non-root runtime
Your customer-managed Linux runtime should use a dedicated account with rootless Docker already configured. Rootless mode runs the daemon without host-root privileges; the image below additionally runs its application as UID 1000. Do not mount the Docker socket, your home directory, cloud credentials or SSH keys into the agent container.
Create a new build directory containing only Dockerfile and the worker.py shown below. Create a separate fixture directory containing fixture.txt with the text training fixture. That is synthetic, non-sensitive test content, not a workload assumption. Make this fixture readable by the container, but mount it read-only. Keep real repositories and credentials outside this first exercise.
FROM python:3.12-slim
RUN python -m pip install --no-cache-dir claude-agent-sdk==0.2.162 \
&& useradd --create-home --uid 1000 agent \
&& mkdir /state /work \
&& chown agent:agent /state /work
COPY worker.py /app/worker.py
ENV HOME=/home/agent CLAUDE_CONFIG_DIR=/state PYTHONUNBUFFERED=1
USER 1000:1000
WORKDIR /app
ENTRYPOINT ["python", "/app/worker.py"]
The named volume used later provides writable session storage without exposing a host home directory. An initially empty Docker volume receives the image directory’s contents and ownership. Keep the fixture’s read-only bind mount and the writable state volume distinct: protecting input files and preserving application state solve different problems.
Configure tool authority before running the task
SDK permission controls distinguish auto-approval from tool availability: allowed_tools is an auto-approval list, not the complete available toolset. An unlisted tool can still be resolved by other permission rules. Bare names in disallowed_tools remove those tools; scoped deny rules block matching calls. A restrictive tools selection and explicit denies therefore have a different role from auto-approval.
The evaluation sequence is hooks, deny rules, ask rules, permission mode, allow rules and finally the permission callback for unresolved requests. Earlier automatic decisions can mean can_use_tool is never called. A hook that allows a request does not override deny or ask rules. Do not use bypassPermissions as an unattended-service default.
This worker uses a global pre-tool hook to enforce an exact file boundary, a small available toolset, explicit removal of write and shell tools, and a deny-only fallback callback. cwd alone is not a filesystem sandbox. The container’s read-only mounts and non-root identity provide a separate operating-system boundary.
Save this as worker.py. Set MAX_TURNS and RUN_TIMEOUT_SECONDS to positive limits for your acceptance run; the code rejects missing or invalid values rather than choosing a production budget for you.
import json
import os
import sys
from pathlib import Path
import anyio
from claude_agent_sdk import (
ClaudeAgentOptions, ClaudeSDKClient, HookMatcher,
PermissionResultDeny, ResultMessage,
)
ROOT = Path("/work")
FIXTURE = ROOT / "fixture.txt"
read_seen = False
def emit(event, **fields):
print(json.dumps({"event": event, **fields}), flush=True)
async def boundary(data, tool_use_id, context):
raw = data.get("tool_input", {}).get("file_path", "")
path = Path(raw)
path = path if path.is_absolute() else ROOT / path
allowed = data.get("tool_name") == "Read" and path.resolve() == FIXTURE
emit("tool_policy", allowed=allowed)
if not allowed:
return {"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Outside the read-only fixture policy",
}}
return {"continue_": True}
async def read_completed(data, tool_use_id, context):
global read_seen
if data.get("tool_name") == "Read":
read_seen = True
emit("read_completed")
return {}
async def deny_unresolved(tool_name, input_data, context):
emit("permission_denied")
return PermissionResultDeny(message="No additional authority granted")
async def main():
if "--policy-test" in sys.argv:
for tool, path in [("Write", str(FIXTURE)), ("Read", "/etc/issue")]:
reply = await boundary(
{"tool_name": tool, "tool_input": {"file_path": path}}, None, {}
)
assert reply["hookSpecificOutput"]["permissionDecision"] == "deny"
emit("policy_test_passed")
return
if os.geteuid() == 0 or not os.environ.get("ANTHROPIC_API_KEY"):
raise ValueError("Non-root runtime and API credential required")
turns = int(os.environ["MAX_TURNS"])
seconds = float(os.environ["RUN_TIMEOUT_SECONDS"])
if turns < 1 or not 0 < seconds < float("inf") or not FIXTURE.is_file():
raise ValueError("Invalid limits or missing fixture")
options = ClaudeAgentOptions(
cwd=str(ROOT), tools=["Read"], allowed_tools=["Read"],
disallowed_tools=["Write", "Edit", "NotebookEdit", "Bash"],
permission_mode="default", can_use_tool=deny_unresolved,
hooks={
"PreToolUse": [HookMatcher(hooks=[boundary])],
"PostToolUse": [HookMatcher(hooks=[read_completed])],
},
setting_sources=[], strict_mcp_config=True, mcp_servers={},
env={"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1"},
max_turns=turns, resume=os.environ.get("RESUME_ID") or None,
)
success = False
with anyio.fail_after(seconds):
async with ClaudeSDKClient(options=options) as client:
await client.query("Use Read on /work/fixture.txt and repeat its text.")
async for message in client.receive_response():
if isinstance(message, ResultMessage):
success = (
not message.is_error and message.subtype == "success"
and message.terminal_reason in (None, "completed")
and "training fixture" in (message.result or "")
)
emit("result", session_id=message.session_id,
subtype=message.subtype, is_error=message.is_error,
terminal_reason=message.terminal_reason,
fixture_matched=success)
if not success or not read_seen:
raise RuntimeError("Acceptance conditions not met")
if __name__ == "__main__":
try:
anyio.run(main)
except Exception as error:
emit("failed", error_type=type(error).__name__)
sys.exit(1)
The SDK fields and hook format above come from the Python interface. The exact-file policy and acceptance conditions are application logic. The example logs decisions and outcome metadata, not the API key, raw tool arguments or full conversation. Treat even session identifiers and operational logs as access-controlled data.
Build and run the harmless job
From the build directory, use Bash and stop on errors. Export your chosen positive execution limits before the run. Supply the test API key through a protected runtime environment, not a Dockerfile, build argument, committed file or pasted command containing the secret.
set -euo pipefail
: "${MAX_TURNS:?Export a positive turn limit}"
: "${RUN_TIMEOUT_SECONDS:?Export a positive deadline in seconds}"
: "${ANTHROPIC_API_KEY:?Supply the dedicated test API key securely}"
test "$(id -u)" -ne 0
test -f fixture/fixture.txt
docker build -t claude-sdk-acceptance:0.2.162 .
docker volume create claude-sdk-acceptance-state
docker run --name claude-sdk-acceptance --init --restart=no \
--cap-drop=ALL --security-opt=no-new-privileges --read-only \
--tmpfs /tmp:rw,nosuid,nodev \
--mount "type=bind,source=$PWD/fixture,target=/work,readonly" \
--mount type=volume,source=claude-sdk-acceptance-state,target=/state \
--env ANTHROPIC_API_KEY --env MAX_TURNS --env RUN_TIMEOUT_SECONDS \
claude-sdk-acceptance:0.2.162
Use fresh names for a repeated exercise rather than deleting an earlier state volume. A passing run produces an allowed tool_policy event, read_completed, a non-error completed result, fixture_matched: true, and exit status zero. A missing fixture, invalid limits, authentication failure, deadline or unsuccessful result produces a failure and non-zero exit. A process merely starting is not acceptance.
No inbound port is published. This demonstration still needs outbound access to the model API; its Docker flags do not implement a destination allowlist. Before using sensitive inputs, enforce the intended outbound destinations outside the agent boundary. For production, an external credential-injecting proxy can keep service keys out of the agent process. A key supplied through the environment is not hidden from that process if it is compromised.
Check denial, failure and restart independently
Run docker run --rm claude-sdk-acceptance:0.2.162 --policy-test without supplying a model key. The same boundary function must reject both a write to the fixture and a read outside it, then emit policy_test_passed. This is a deterministic policy-function check; it does not claim a model attempted either action.
Next test the filesystem layer in a disposable container using the same read-only fixture mount. Override the entrypoint with Python and run open("/work/fixture.txt", "w"), keeping the same read-only bind mount and no model credential. The operation must raise an operating-system error and leave the fixture unchanged. This checks the mount, separately from the SDK policy. A model’s verbal refusal would prove neither condition.
For dependency failure, create a separate run with an intentionally invalid, non-secret test credential. Expect failure metadata and a non-zero exit; never replace a production credential for this exercise. A failed request must not be recorded as a completed job merely because a result object exists. Correct authentication or the dependency configuration before retrying.
Finally, start the completed acceptance container again with docker start --attach claude-sdk-acceptance. It should execute a new harmless job with the same mounted state and permission configuration. That demonstrates process restart, not conversation resumption. Keep --restart=no for this one-job worker so successful completion cannot cause an automatic loop of repeated API work.
Preserve sessions without confusing them with application recovery
With CLAUDE_CONFIG_DIR=/state, local session transcripts live beneath /state/projects/. Record the session ID from the result with the corresponding application job. To resume it, create a new container with the same state volume and pass that ID as RESUME_ID, preserving the same tool policy and fixture mount. The session data must exist; an identifier alone cannot reconstruct it.
Before an update, stop writers, back up the state volume, and restore that backup into a fresh volume for an isolated recovery exercise. Start the pinned old image against the restored copy, resume the known session and inspect its retained messages, then run the harmless read again. Keep production state untouched until the restored transcript, fixture and restrictions meet their expected conditions.
Back up application job records, configuration and working artifacts separately where they are not part of that volume. Session resumption restores conversational context; it is not proof that an external action should be repeated. File checkpointing is also narrower than whole-application recovery: it covers specified file-editing tools, not Bash changes or arbitrary external effects, and it does not rewind the conversation.
Supervise work, not just a container
For continuous operation, place this bounded worker behind your application’s authenticated job intake and durable job record. Record accepted, running, completed and failed outcomes, associate them with sessions, and decide retries from the actual failure and external-effect status. Do not wrap a one-shot task in an unconditional restart policy and call that a reliable queue.
A persistent Python conversation can instead use ClaudeSDKClient across turns. Whichever lifecycle you choose, supervise the process tree, enforce a wall-clock deadline as well as turn limits, and alert on failed or stalled jobs. Promote a candidate image only after its harmless read, policy denial, dependency failure, restart and restored-state checks pass in your environment.
Our AI Agent VPS category supports customer-operated applications on self-managed infrastructure.
You install and maintain the SDK application, its permissions, credentials, monitoring and recovery. The model API remains a separate service, and a deployment is ready only when your application’s operating boundaries—not just its process—work as intended.
Build the server behind your AI workflow
Allow for parallel tasks, browser or coding tools and saved data. Check runtime compatibility before choosing a self-managed server.