No description
  • Shell 64%
  • Dockerfile 36%
Find a file
2026-08-30 21:31:14 +02:00
claude fix: change default uid/gid 2026-08-30 21:31:14 +02:00
host feat: bind the zettelmesh MCP server to the container bridge only 2026-08-30 19:16:15 +00:00
proxy feat: disable proxy filter 2026-08-29 07:59:33 +02:00
.env.example feat: disable proxy filter 2026-08-29 07:59:33 +02:00
.gitignore feat: disable proxy filter 2026-08-29 07:59:33 +02:00
docker-compose.yml feat: bind the zettelmesh MCP server to the container bridge only 2026-08-30 19:16:15 +00:00
README.md feat: bind the zettelmesh MCP server to the container bridge only 2026-08-30 19:16:15 +00:00

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/24 mit etwas auf deinem Host (ip route, docker network ls)? Dann ein freies /24 eintragen — an drei Stellen: ipam + extra_hosts in docker-compose.yml und ZETTEL_MCP_BIND in host/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, ~/.ssh oder 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-permissions fahren.
  • Working Directory: Standardmäßig kann Claude Code nur innerhalb von /workspace (und seinem Temp-Verzeichnis) schreiben - dank Docker-Bind- Mount plus dem separaten internal-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-permissions komplett auszuschalten: https://code.claude.com/docs/en/permission-modes#eliminate-prompts-with-auto-mode
  • Telemetrie: CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 ist 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.