I use Claude Code for everything. Writing code, debugging shit, deploying infrastructure, managing repos, writing articles for this very blog, and even automating browser sessions on the fly — browsing the web, filling out forms, scraping data from sites that hide behind Cloudflare. It’s become the backbone of how I work. But here’s the thing about giving an AI agent full access to your system: it’s fucking terrifying. Not because Claude is malicious, but because it runs with --dangerously-skip-permissions and has the power to do whatever it wants. One bad command and your host system is toast.
The obvious answer is: put it in a container. But actually doing that properly is a different story. You can’t just docker run node && npm i -g @anthropic-ai/claude-code and call it a day. Claude Code needs a real development environment — compilers, linters, formatters, package managers, database clients, infrastructure tools. It needs git with SSH keys. It needs Docker itself for when it builds and runs containers. It needs to understand where it is and what tools are available to it.
So I built docker-claude-code. A fully loaded Docker image that gives Claude Code everything it needs to be a productive developer — isolated from your host, with proper tooling, proper permissions, and a startup script that handles all the annoying setup automatically.
Why Not Just Run It Natively?
Because installing development tools globally on your host machine is for people who enjoy debugging PATH conflicts on a Sunday afternoon.
Here’s what happens when you run Claude Code natively:
- It has access to your entire filesystem. Every SSH key, every
.envfile, every credential stored anywhere - When it installs packages or runs build commands, those affect your global system state
- If it decides to
rm -rfsomething it shouldn’t, that’s your actual system getting nuked - Different projects need different tool versions, and your host becomes a graveyard of conflicting installations
- You can’t easily reproduce your Claude environment on another machine
Running it in a Docker container solves all of this. Claude gets root-level access inside the container, can install whatever the fuck it wants, break whatever it wants, and your host doesn’t care. Blow up the container, spin up a new one, you’re back in 10 seconds.
Two Image Variants
Full — Batteries Included
This isn’t some minimal Alpine image with Python slapped on top. It’s a full development workstation packed into a container. Ubuntu 22.04 base with everything a developer — human or AI — actually needs:
- Go 1.25.5 with the full toolchain — golangci-lint, gopls, delve, staticcheck, gofumpt, gotests, gomodifytags, impl
- Python 3.12 via pyenv with flake8, black, isort, autoflake, pyright, mypy, vulture, pytest, poetry, pipenv
- Node.js LTS with eslint, prettier, typescript, ts-node, yarn, pnpm, nodemon, pm2, framework CLIs, newman, lighthouse, storybook
- C/C++ — gcc, g++, make, cmake, clang-format, valgrind, gdb, strace, ltrace
- Docker CE with Docker Compose — Docker inside Docker, fully enabled
- DevOps — Terraform, kubectl, helm, GitHub CLI
- Database clients — SQLite, PostgreSQL, MySQL, Redis
- Utilities — jq, ripgrep, fd-find, bat, exa, shellcheck, shfmt, httpie
curl -fsSL https://raw.githubusercontent.com/psyb0t/docker-claude-code/master/install.sh | bashMinimal — Lean and Mean
Just the essentials: Ubuntu, git, curl, wget, jq, Node.js, Docker, and Claude Code. Much smaller image, faster pull. Claude has passwordless sudo so it can install whatever it needs on the fly — first run takes longer as it grabs packages, but subsequent sessions reuse the container state.
CLAUDE_MINIMAL=1 curl -fsSL https://raw.githubusercontent.com/psyb0t/docker-claude-code/master/install.sh | bashUse ~/.claude/init.d/*.sh hooks to pre-install your tools on first container create so you don’t wait for Claude to figure it out.
The container auto-generates a CLAUDE.md file in the workspace listing every tool available. Claude reads this on startup and immediately knows what it can use.
How It Actually Works
The magic is in the entrypoint script. When the container starts, it does a bunch of shit that would be painful to do manually every time:
UID/GID matching. The entrypoint detects the owner of the mounted workspace directory and adjusts the container’s claude user to match. Files created inside the container have the correct ownership on the host. No more chown -R bullshit after every session.
Docker socket permissions. If you mount /var/run/docker.sock, the entrypoint matches the docker group GID inside the container to the socket’s GID on the host. Docker commands just work without permission errors.
Git identity. Pass CLAUDE_GIT_NAME and CLAUDE_GIT_EMAIL environment variables and git is configured automatically.
Session continuity. The entrypoint runs Claude with --continue, which resumes the last conversation from the current working directory. Each directory gets its own conversation history. Stop a container, come back hours later, start it again — Claude picks up exactly where it left off. If there’s no previous session, it falls back to starting fresh.
Auto-update. Claude Code updates itself on every interactive container start. Skip it with --no-update. Programmatic runs never auto-update.
Setup
One-liner install:
curl -fsSL https://raw.githubusercontent.com/psyb0t/docker-claude-code/master/install.sh | bashThis creates SSH keys, pulls the image, and drops a wrapper script at /usr/local/bin/claude. After that:
claudeThe wrapper handles container lifecycle — creates a new container if one doesn’t exist for the current directory, restarts and reattaches if one already exists. Each workspace gets its own container named after the directory path.
Install as a different binary name to avoid collisions with a native Claude install:
CLAUDE_BIN_NAME=dclaude curl -fsSL .../install.sh | bashAuthentication
Either log in interactively on first run, or set up a token:
# generate an OAuth token (interactive, one-time)
claude setup-token
# then use it for programmatic runs
CLAUDE_CODE_OAUTH_TOKEN=xxx claude "do stuff"
# or use an API key directly
ANTHROPIC_API_KEY=sk-ant-xxx claude "do stuff"Interactive and Programmatic Modes
Interactive — just run claude. You get a live session. Container persists between runs.
Some commands pass through directly without starting a session:
claude --version # show claude version
claude doctor # health check
claude auth # manage authentication
claude setup-token # interactive OAuth token setupProgrammatic — pass a prompt. -p is added automatically. Uses its own dedicated _prog container per workspace (no TTY, works from scripts, cron, other tools). --continue is passed automatically so programmatic runs share session context:
# JSON output
claude "explain this codebase" --output-format json
# Pick a model
claude "explain this codebase" --model sonnet
claude "explain this codebase" --model opus
# Streaming JSON piped to jq
claude "list all TODOs" --output-format stream-json | jq .
# Custom system prompt
claude "review this" --system-prompt "You are a security auditor"
# Append to default system prompt
claude "review this" --append-system-prompt "Focus on SQL injection"
# Structured output with JSON schema
claude "extract author and title" --output-format json \
--json-schema '{"type":"object","properties":{"author":{"type":"string"},"title":{"type":"string"}}}'
# Reasoning effort (low, medium, high, max)
claude "debug this complex issue" --effort high
claude "quick question" --effort low
# Session control
claude "start over" --no-continue
claude "keep going" --resume abc123-def456Model aliases: opus (Opus 4.6), sonnet (Sonnet 4.6), haiku (Haiku 4.5), opusplan (Opus planning + Sonnet execution), sonnet[1m] (Sonnet with 1M context). Or use full model names to pin specific versions.
Output formats: text (default), json (single JSON result with cost/token breakdown), stream-json (NDJSON with per-event details — system init, assistant responses, tool use, tool results, rate limit events, final result).
API Mode
Set CLAUDE_MODE_API=1 to run the container as an HTTP API server instead of interactive mode. Useful for integrating Claude into other services via docker-compose:
# docker-compose.yml
services:
claude:
image: psyb0t/claude-code:latest
ports:
- "8080:8080"
environment:
- CLAUDE_MODE_API=1
- CLAUDE_MODE_API_TOKEN=your-secret-token
- CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-xxx
volumes:
- ~/.claude:/home/claude/.claude
- /your/projects:/workspaces
- /var/run/docker.sock:/var/run/docker.sockEndpoints:
- POST /run — send a prompt, get JSON back. Fields:
prompt,workspace,model,system_prompt,append_system_prompt,json_schema,effort,no_continue,resume,fire_and_forget. Returns 409 Conflict if workspace already processing. By default, kills the process if the client disconnects — setfire_and_forget: trueto let it finish in the background - GET /files/{path} — list directory or download file
- PUT /files/{path} — upload a file (creates parent dirs)
- DELETE /files/{path} — delete a file
- GET /health — health check (no auth)
- GET /status — show busy workspaces
- POST /run/cancel — kill running process for a workspace
All paths relative to /workspaces. Bearer token auth via CLAUDE_MODE_API_TOKEN (optional).
The Workspace-Per-Container Model
The wrapper creates one container per workspace directory. cd ~/project-a && claude gives you one container. cd ~/project-b && claude gives you another. Each has its own conversation history, installed dependencies, and state.
Claude can install project-specific tools, modify the environment, break things spectacularly — and it only affects that one project’s container. Nuke a container, spin up a fresh one, zero collateral damage.
The ~/.claude directory is mounted as a volume, so configuration, API keys, and settings persist across all containers. SSH keys are similarly shared. Isolation is at the workspace level, not the identity level.
Customization
Custom Environment Variables
Use the CLAUDE_ENV_ prefix to forward arbitrary env vars into the container. The prefix is stripped:
CLAUDE_ENV_GITHUB_TOKEN=xxx CLAUDE_ENV_MY_VAR=hello claude "do stuff"Extra Volume Mounts
Use the CLAUDE_MOUNT_ prefix to mount additional directories:
# Mount at same path
CLAUDE_MOUNT_DATA=/data claude "process the data"
# Explicit source:dest
CLAUDE_MOUNT_STUFF=/host/path:/container/path claude "do stuff"
# Read-only
CLAUDE_MOUNT_RO=/data:/data:ro claude "read the data"Custom Scripts (~/.claude/bin)
Drop executables into ~/.claude/bin/ on the host and they’re in PATH inside every container session. Persists across all sessions.
Init Hooks (~/.claude/init.d)
Scripts in ~/.claude/init.d/*.sh run once on first container create (as root, before dropping to claude user). They don’t run again on subsequent docker start — only on fresh containers. Perfect for installing extra packages or one-time setup that should survive container restarts.
mkdir -p ~/.claude/init.d
cat > ~/.claude/init.d/setup.sh << 'EOF'
#!/bin/bash
apt-get update && apt-get install -y some-package
pip install some-library
EOF
chmod +x ~/.claude/init.d/setup.shDocker-in-Docker
The host's Docker socket is mounted into the container, so Claude can build images, run compose stacks, pull images, manage containers. Since the workspace is mounted at its real host path ($PWD:$PWD), volume mounts from inside Claude's container resolve correctly on the host. Claude can write a docker-compose.yml, run it, and everything just works because the paths are consistent.
Security Model
This runs with --dangerously-skip-permissions. Inside the container, Claude has passwordless sudo and can do whatever it wants.
The security boundary is the container itself. Claude can't touch your host filesystem beyond the mounted workspace and config directories. If it goes rogue, docker stop and docker rm and it's gone.
The Docker socket mount is the one exception — it gives access to the host's Docker daemon. Don't mount it if that concerns you.
SSH keys are mounted from a dedicated ~/.ssh/claude-code directory (configurable via CLAUDE_SSH_DIR). Generate a separate keypair for Claude, keep your personal keys out of the container.
Telegram Mode
Set CLAUDE_MODE_TELEGRAM=1 and you can talk to Claude from your phone. Each Telegram chat gets its own workspace and settings. Send text, files, photos, videos, voice messages. Run shell commands with /bash. Get files back with /fetch.
Configuration is a YAML file that controls which chats the bot responds to, with per-chat model, effort level, workspace, system prompt, and user restrictions:
# ~/.claude/telegram.yml
allowed_chats:
- 123456789 # your DM
- -987654321 # a group
default:
model: sonnet
effort: high
continue: true
chats:
123456789:
workspace: my-project
model: opus
effort: max
system_prompt: "You are a senior engineer"
-987654321:
workspace: team-stuff
model: sonnet
allowed_users:
- 123456789# docker-compose.yml
services:
claude-telegram:
image: psyb0t/claude-code:latest
environment:
- CLAUDE_MODE_TELEGRAM=1
- CLAUDE_TELEGRAM_BOT_TOKEN=123456:ABC-DEF
- CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-xxx
volumes:
- ~/.claude:/home/claude/.claude
- ~/telegram-workspaces:/workspaces
- /var/run/docker.sock:/var/run/docker.sockBot commands: any text message goes to Claude as a prompt. Send files/photos/videos/voice and they're saved to the workspace. /bash <command> runs a shell command. /fetch <path> gets a file back. /cancel kills a running prompt. /status shows busy chats. /config shows the chat's settings. /reload hot-reloads the YAML config without restarting.
Claude can send files back by putting [SEND_FILE: path] in its response — images as photos, videos as videos, everything else as documents.
The Bottom Line
I run every Claude Code session inside this container now. The isolation means I don't think twice about letting it install packages, modify configs, or run whatever commands it needs. The tooling means it rarely needs to install anything because everything's already there. The session continuity means I can close my terminal, come back hours later, and pick up exactly where I left off.
Go grab it: github.com/psyb0t/docker-claude-code
Licensed under WTFPL — because the only thing more dangerous than an AI with root access is an AI with root access and a restrictive license.