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?

This article was updated on