
Running Claude Code in Docker: Because SSH is All You Need
Claude Code is excellent. It’s fast, it understands context, and it actually helps you write better code. It even supports remote authentication - you can grab an OAuth token via browser on any device and paste it in. There’s just one tiny problem: I’m weird and want to run it in a Docker container.
Containers don’t persist login state. Every restart, every rebuild - gone. Back to square one.
Why Run Claude Code in a Container?
Fair question. Why not just run it locally like a normal person?
Because I work from way too many machines. Sometimes my desktop, sometimes my laptop - shit, maybe even from my work computer during breaks. I wanted one consistent environment I could SSH into from anywhere, with my notes and context already there.
Also, I’ll be honest: an AI with full access to my machine creeps me out a little. Inside a Docker container? Much better. It can do its thing in there, isolated, and I sleep easier.
And okay, fine - I also just wanted to see if it was possible.
The OAuth Problem
When you start Claude Code, it asks: Claude subscription or API key?
Sounds like a choice. It’s not really. Both paths lead to OAuth - just to different endpoints:
- Claude subscription → OAuth via claude.ai
- API key → OAuth via console.anthropic.com
Either way: browser, login, authentication state that doesn’t survive a container restart.
But here’s the thing - you can set ANTHROPIC_API_KEY as an environment variable, and Claude Code will use it directly. No OAuth. This is actually documented for SDK usage. The catch? It still shows a confirmation dialog asking if you trust this key. In a container that rebuilds, you’d have to confirm every single time.
Unless you know where it stores that trust. That part isn’t in the docs.
The Trust Config Discovery
After some digging (and reading way too much source code), I found that Claude Code stores trusted keys in ~/.claude.json. The interesting part? It doesn’t store the full API key - just the last 20 characters for verification.
This means we can pre-populate the trust config:
{
"customApiKeyResponses": {
"approved": ["...last20chars..."],
"rejected": []
},
"hasCompletedOnboarding": true,
"hasTrustDialogAccepted": true
}
Those flags skip the onboarding flow entirely. No browser needed, no interactive prompts, just straight to work.
Building the Container
The Dockerfile is straightforward - Ubuntu base, SSH server, Claude Code CLI:
FROM ubuntu:24.04
# System packages
RUN apt-get update && apt-get install -y \
openssh-server \
ssh-import-id \
curl git vim htop ripgrep jq \
nodejs npm \
&& rm -rf /var/lists/*
# SSH setup - key-based auth only
RUN mkdir /var/run/sshd \
&& mkdir -p /root/.ssh \
&& chmod 700 /root/.ssh \
&& echo "PermitRootLogin yes" >> /etc/ssh/sshd_config \
&& echo "PasswordAuthentication no" >> /etc/ssh/sshd_config \
&& echo "PubkeyAuthentication yes" >> /etc/ssh/sshd_config
# Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Workspace
RUN mkdir -p /workspace/notes
EXPOSE 22
ENTRYPOINT ["/entrypoint.sh"]
The entrypoint script is where the magic happens - it sets up SSH keys, configures the API key trust, and starts the SSH server:
#!/bin/bash
set -e
# Import SSH keys from GitHub
if [ -n "$GITHUB_USERS" ]; then
ssh-import-id-gh ${GITHUB_USERS//,/ }
fi
# Claude Code API Key Setup
if [ -n "$ANTHROPIC_API_KEY" ]; then
# Make key available in SSH sessions
echo "export ANTHROPIC_API_KEY=\"$ANTHROPIC_API_KEY\"" >> /root/.bashrc
# Extract last 20 characters for trust config
ANTHROPIC_API_KEY_LAST_20="${ANTHROPIC_API_KEY: -20}"
# Create trust config
cat <<EOF > /root/.claude.json
{
"customApiKeyResponses": {
"approved": ["$ANTHROPIC_API_KEY_LAST_20"],
"rejected": []
},
"hasCompletedOnboarding": true,
"hasTrustDialogAccepted": true
}
EOF
fi
# Start SSH server
exec /usr/sbin/sshd -D
The ssh-import-id-gh command is criminally underrated - it pulls your public SSH keys directly from GitHub. No manual key management needed.
Then I Wanted to Push to GitHub
The container worked. Claude Code ran headless. I could SSH in and use it. Perfect.
Then I made some changes, felt productive, typed git push and… right. SSH keys. In a container. That I didn’t mount.
Sound familiar? Non-persistent authentication, round two. Apparently I needed to learn this lesson twice.
The problem is persistence:
volumes:
- ./notes:/workspace/notes # ✅ Persistent
- ./claude-data:/root/.claude # ✅ Persistent
# But /root/.ssh? # ❌ NOT mounted = gone on restart
SSH keys stored in /root/.ssh disappear on container restart. I could mount that directory too, but there’s a simpler solution: Git credential helper with a persistent location.
git config --global credential.helper 'store --file=/root/.claude/.git-credentials'
Since /root/.claude is already bind-mounted, the credentials file survives container restarts. On first push, Git asks for credentials - I enter my GitHub Personal Access Token - and it’s stored. Every subsequent push works automatically.
This actually feels cleaner than SSH keys for containers. The token is revocable from GitHub’s settings, it doesn’t require key generation, and it works identically across any machine. Sometimes the workaround becomes the better solution.
The Final Setup
Here’s the complete docker-compose configuration:
services:
claude-code:
build: ./claude-ssh
container_name: claude-code
ports:
- "2222:22"
volumes:
- ./notes:/workspace/notes
- ./claude-data:/root/.claude
environment:
- GITHUB_USERS=${GITHUB_USERS}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
restart: unless-stopped
With a .env file:
GITHUB_USERS=your-github-username
ANTHROPIC_API_KEY=sk-ant-xxxxx
That’s it. Run docker compose up -d --build, wait about 5 minutes for npm to install Claude Code, then:
ssh -p 2222 root@your-server
cd /workspace/notes
claude
No OAuth flow. No onboarding. No manual SSH key setup. Just Claude Code, ready to use, with working Git integration.
Bonus: Pairing with a Markdown Editor
I run this alongside NoteDiscovery, a simple web UI for browsing Markdown files:
services:
notediscovery:
image: ghcr.io/gamosoft/notediscovery:latest
ports:
- "8800:8000"
volumes:
- ./notes:/app/data
claude-code:
# ... as above
Both containers share the same notes directory. I browse and organize in the web UI, edit with Claude Code via SSH. It’s simple, it works, and I can access it from anywhere.
Current Setup
Container Platform: Docker Compose on TrueNAS Scale → Dockge for management
Claude Code: Latest version via npm global install → API key auth, OAuth bypassed
SSH Access: Port 2222 exposed → Keys automatically pulled from GitHub
Persistence: Two bind mounts → notes/ and .claude/ directory, credentials survive restarts
Git Integration: Personal Access Token → Stored in persistent location, works across rebuilds
Companion Service: NoteDiscovery web UI → Same notes volume, different access method
What’s Next
The setup works. But man, this thing churns through tokens like crazy. From jumping into the SSH session to pushing the GitHub repo - okay, and making it barely presentable to the whole internet - five bucks were gone.
So I’m looking at opencode as an alternative. It has multi-provider support, so I could use cheaper models for simple tasks. Native web UI too, which might eliminate the SSH dance entirely. And maybe a Code Server container bolted on sideways at some point. We’ll see.
The whole setup is published on GitHub if you want the complete config.
What’s your remote development setup look like? Are you running AI coding assistants in containers, or am I the only one who thought SSH-ing into a TUI was a good idea?