- Shell 64%
- Dockerfile 36%
| claude | ||
| host | ||
| proxy | ||
| .env.example | ||
| .gitignore | ||
| docker-compose.yml | ||
| README.md | ||
Claude Code auf dem VPS - Docker Compose Setup
Architektur
Internet
│
▼
┌───────────────────────┐
│ claude-code │
│ (direkter, unge- │
│ filterter Zugang, │
│ Netz: egress) │
└───────────────────────┘
claude-code hängt standardmäßig direkt am egress-Netz (normale Docker-
Bridge mit NAT ins Internet, kein Proxy dazwischen). Eine optionale
domainbasierte Allowlist per Squid-Proxy liegt vorbereitet im Repo, ist aber
per Compose-Profil deaktiviert - Details siehe Abschnitt "Netzwerk" unten.
Setup
cp .env.example .env
# .env anpassen: PROJECT_DIR, SSH_DIR, GITCONFIG
mkdir -p workspace # oder PROJECT_DIR auf dein bestehendes Repo zeigen lassen
docker compose build
docker compose up -d
Erstes Login
Claude Code braucht für Remote Control ein vollwertiges claude.ai/Console-Login
(claude auth login), keinen Long-Lived-Token aus claude setup-token -
solche Tokens können zwar Modellanfragen stellen, aber keine Remote-Control-
Session aufbauen.
docker compose exec --user claude claude-code tmux attach -t claude
(--user claude ist nötig, weil exec sonst als root verbindet - der
tmux-Server läuft aber unter dem claude-User und sucht sein Socket unter
/tmp/tmux-<uid-von-claude>/, root findet unter /tmp/tmux-0/ nichts und
meldet "no sessions".)
Da der VPS headless ist, zeigt der Login-Flow einen Link + Code an. Öffne den
Link auf deinem Laptop/Handy, logge dich ein und trage den angezeigten Code
zurück im Terminal ein. Danach mit Strg+B, D aus tmux aussteigen (die
Session läuft weiter).
Login-Daten liegen im Volume claude-config (gemountet auf
/home/claude/.claude, per CLAUDE_CONFIG_DIR gesetzt) und überleben
Rebuilds/Restarts.
settings.default.json setzt "remoteControlAtStartup": true, damit jede
neue Session automatisch für die App/claude.ai/code sichtbar ist - kein
/remote-control von Hand nötig.
Von der App/claude.ai aus verbinden
Sobald eingeloggt und Remote Control aktiv ist, taucht die Session in der Claude-App (iOS/Android) bzw. unter claude.ai/code auf. Auf Team/Enterprise- Plänen muss ein Owner Remote Control vorher global unter claude.ai/admin-settings/claude-code aktivieren.
Weitere Sessions starten (Skill spawn-rc-session)
Die Primär-Session (tmux -t claude) wird vom Entrypoint verwaltet. Für
zusätzliche, unabhängig fernsteuerbare Sessions liegt ein Skill bei
(claude/skills/spawn-rc-session/, wird beim Container-Start nach
CLAUDE_CONFIG_DIR/skills/ gespiegelt). Aus einer laufenden Session heraus:
/spawn-rc-session mein-experiment /workspace/src/foo
Er startet claude --remote-control "<name>" in einer neuen detached
tmux-Session, wartet auf die claude.ai/code-URL und meldet sie zurück
(bzw. den Login-Link, falls der Container noch nicht eingeloggt ist). Jede
Session ist ein eigener claude-Prozess mit eigenem Kontext und eigenem
Token-Verbrauch; vom Skill gestartete Sessions werden nicht automatisch
neu gestartet. --remote-control ist immer interaktiv — headless (claude -p) und Remote Control schließen sich aus.
Einen MCP-Server auf dem Host erreichen
Läuft ein MCP-Server auf dem Host (z.B. der zettelmesh-MCP-Server), muss
der Container den Host adressieren können. Unter Linux gibt es
host.docker.internal nicht automatisch — docker-compose.yml legt es beim
claude-code-Service über extra_hosts an:
extra_hosts:
- "host.docker.internal:172.28.0.1"
Die IP ist fest gesetzt (nicht der Sonderwert host-gateway): sie zeigt auf
das fixierte Gateway des egress-Netzes — genau das Interface, an das der
MCP-Server unten bindet. host-gateway würde auf das Default-Bridge-
Gateway 172.17.0.1 zeigen, wo ein nur an 172.28.0.1 gebundener Server
nicht lauscht.
Server nur ans Container-Bridge-Interface binden
Der zettelmesh-MCP-Server hat keine eigene Authentifizierung — wer den
Port erreicht, hat den vollen Lesezugriff des Prozesses. --host 0.0.0.0
würde ihn auf allen Interfaces des Hosts öffnen. Besser: gezielt nur an
die Bridge-Gateway-IP des egress-Netzes binden. Dann ist der Server
ausschließlich aus genau diesem Docker-Netz erreichbar, ganz ohne
Firewall-Regeln.
Damit diese IP stabil ist, fixiert docker-compose.yml Subnetz und Gateway
des egress-Netzes:
networks:
egress:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/24
gateway: 172.28.0.1 # <- daran bindet der MCP-Server
driver_opts:
com.docker.network.bridge.name: claude-egress0
Kollidiert
172.28.0.0/24mit etwas auf deinem Host (ip route,docker network ls)? Dann ein freies/24eintragen — an drei Stellen:ipam+extra_hostsindocker-compose.ymlundZETTEL_MCP_BINDinhost/zettel-mcp.env.
Server auf dem Host starten — als systemd-Dienst, damit er Reboots und
docker compose down/up überlebt:
sudo cp host/zettel-mcp.sh /usr/local/bin/zettel-mcp
sudo chmod +x /usr/local/bin/zettel-mcp
sudo cp host/zettel-mcp.env /etc/zettel-mcp.env # dann anpassen
sudo cp host/zettel-mcp.service /etc/systemd/system/
sudo systemctl daemon-reload && sudo systemctl enable --now zettel-mcp
zettel-mcp.sh bindet an ZETTEL_MCP_BIND aus /etc/zettel-mcp.env
(Default 172.28.0.1); ist die Variable leer, löst es die Gateway-IP zur
Laufzeit aus docker network inspect auf. Die Unit wartet per
ExecStartPre, bis das Docker-Netz existiert.
Für einen schnellen Test ohne systemd:
ZETTEL_MCP_ENV=./host/zettel-mcp.env ./host/zettel-mcp.sh
Im Container einbinden:
claude mcp add --transport http zettel http://host.docker.internal:8765/mcp
claude mcp list
Dank des extra_hosts-Eintrags löst host.docker.internal im Container auf
172.28.0.1 auf — genau die Bind-Adresse des Servers. http://172.28.0.1:8765/mcp
direkt geht genauso.
Sauberere Alternative: zettelmesh als eigenen Service in dieses Compose-
Projekt aufnehmen und per Service-Namen ansprechen (http://zettel:8765/mcp) —
dann ist gar kein Host-Port nötig. Nur dann sinnvoll, wenn der MCP-Server nicht
zwingend auf dem Host laufen muss.
Netzwerk
Standardmäßig hat claude-code direkten, ungefilterten Internetzugang
(Netz egress, kein Proxy dazwischen). Grund: Der Versuch, den Traffic über
einen Squid-Egress-Proxy mit domainbasierter Allowlist zu leiten, ist am
Remote-Control-"Bridge"-Endpoint gescheitert (api.anthropic.com/v1/environments/bridge,
eine langlebige Streaming-Verbindung) - Squid hat diese Verbindung mit
TCP_MISS_ABORTED/503 abgebrochen, weil Claude Code diesen Endpoint offenbar
nicht per CONNECT-Tunnel anspricht, sondern Squid direkt als HTTPS-Gateway
nutzt. Ohne Proxy funktioniert Remote Control einwandfrei.
Netzwerk-Allowlist wieder aktivieren (optional)
Die Proxy-Konfiguration (proxy/squid.conf, proxy/allowed_domains.txt) ist
weiterhin im Repo, aber per Compose-Profil deaktiviert. Zum Testen:
docker compose --profile proxy up -d egress-proxy
Dann in docker-compose.yml bei claude-code das egress-Netz gegen
internal tauschen und HTTPS_PROXY/HTTP_PROXY/NO_PROXY env-Variablen
wieder ergänzen (siehe Git-Historie bzw. weiter unten in dieser Datei
auskommentiert, falls vorhanden). Falls du das nochmal angehst: eine
denkbare Lösung für das Bridge-Endpoint-Problem wäre, gezielt nur für
api.anthropic.com ssl_bump/CONNECT-Passthrough zu erzwingen statt des
generischen HTTPS-Gateway-Modus, oder einen transparenten TLS-durchreichenden
Proxy (z.B. reines iptables+ipset, siehe unten) statt eines HTTP-Proxies
zu verwenden, der bei Streaming-Verbindungen tendenziell unauffälliger ist.
Sicherheitshinweise
- SSH-Key-Mount: Die offizielle Doku rät explizit davon ab,
~/.sshoder Cloud-Credential-Files in einen Container zu mounten, besonders in Kombination mit--dangerously-skip-permissions(https://code.claude.com/docs/en/devcontainer#add-claude-code-to-your-dev-container). Falls möglich, stattdessen ein repo-scoped Deploy-Key oder ein kurzlebiges Token (z.B. GitHub App/PAT mit begrenztem Scope) statt deines persönlichen SSH-Keys mounten. Wenn du unbedingt deinen normalen Key brauchst: read-only mounten (wie hier vorbereitet) und den Container nicht mit--dangerously-skip-permissionsfahren. - Working Directory: Standardmäßig kann Claude Code nur innerhalb von
/workspace(und seinem Temp-Verzeichnis) schreiben - dank Docker-Bind- Mount plus dem separateninternal-Netzwerk ist das schon eine solide Isolationsgrenze, auch ohne die interne Claude-Code-Sandbox (bubblewrap) zu aktivieren, die im Docker-Container zusätzliche Capabilities bräuchte. - Permission-Modus: Standardmäßig fragt Claude Code vor riskanten
Aktionen nach Bestätigung - das funktioniert genauso über die App/Remote
Control. Für unbeaufsichtigten Betrieb kannst du stattdessen den
klassifizierergestützten Auto-Mode in Betracht ziehen statt
--dangerously-skip-permissionskomplett auszuschalten: https://code.claude.com/docs/en/permission-modes#eliminate-prompts-with-auto-mode - Telemetrie:
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1ist bereits gesetzt, das schaltet die optionalen Datadog-Telemetrie-Hosts ab und spart entsprechende Allowlist-Einträge.
Alternative: IP-basierte iptables/ipset-Firewall statt Proxy
Falls du lieber alles innerhalb des Claude-Containers per Firewall statt
über einen separaten Proxy lösen willst (dafür braucht der Container dann
NET_ADMIN/NET_RAW), orientiert sich das offizielle Referenz-Setup an
diesem Skript, das Domains beim Start per DNS auflöst und per ipset
freigibt:
https://github.com/anthropics/claude-code/blob/main/.devcontainer/init-firewall.sh
Nachteil: Bei Domains mit häufig wechselnden IPs (CDNs) muss die Liste
regelmäßig neu aufgelöst werden - der Proxy-Ansatz hier umgeht das, da er
direkt auf dem Hostnamen filtert.