El comando docker run documentado no funcionaba realmente desde dentro del contenedor: /DATA/AppData es un symlink absoluto a /media/HDD-Storage/AppData, que queda roto si solo se monta /DATA. Agrega el mount que falta, el git safe.directory necesario (baked en el arranque, no persiste en el volumen de credenciales), y documenta ambos gotchas con como se detectaron.
9.0 KiB
Sesión persistente de Claude Code (tmux en Docker)
Problema que resuelve
Correr Claude Code directo en una shell SSH sobre el NAS lo ata al ciclo de vida de esa conexión: un corte de red, cambiar de laptop a tablet, o cerrar la terminal por accidente mata el proceso y con él todo el contexto de trabajo en curso — no hay forma de "retomar donde quedó" porque el proceso ya no existe.
Esto es un problema distinto al de los límites de uso/cuota de Claude Code. Este setup no aumenta cuota de mensajes ni tokens — eso lo controla el backend de Anthropic sin importar dónde corra el CLI. Lo único que resuelve es que la sesión de terminal sobreviva a cortes de red o cambios de dispositivo; si la cuota se agota, sigue agotada exactamente igual dentro de este setup.
Por qué Docker y no instalación nativa
ZimaOS no tiene gestor de paquetes tradicional utilizable para esto:
/ está montado como squashfs de solo lectura (imagen del SO
inmutable), y /usr es un overlay de solo lectura sobre eso —
/usr/local/bin ni siquiera existe y no se puede crear, tampoco como
root (mkdir falla con "Read-only file system"). No hay dónde
instalar Node.js, npm ni tmux directamente sobre el host.
Un contenedor Docker sortea esto por completo: tiene su propio
filesystem de escritura (overlay2), independiente del squashfs del
host — ahí es donde npm install -g deja el binario de claude sin
tocar el host en absoluto. Lo único que el contenedor toma del host
es lo que se monta explícitamente como volumen.
Arquitectura
flowchart LR
SSH["Cliente SSH<br/>(laptop / tablet / lo que sea)"] -->|docker exec| C["Contenedor tmux-host<br/>--network host, --restart unless-stopped"]
C --> T["tmux server<br/>sesión 'lab'"]
T --> CC["proceso claude<br/>(Claude Code)"]
C -.->|bind mount| DATA["/DATA (host)<br/>repos y configs del lab"]
C -.->|bind mount| SOCK["/var/run/docker.sock (host)<br/>control de Docker/k3d"]
C -.->|bind mount| HOME["/DATA/AppData/k3d-lab-v3/claude-code-home (host)<br/>-> /root/.claude"]
El contenedor sigue vivo aunque la conexión SSH se corte — es el
servidor tmux, no la shell del cliente, el que sostiene el proceso de
Claude Code. --restart unless-stopped además lo revive si el NAS
reinicia.
Contenedor: tmux-host
docker run -d \
--name tmux-host \
--restart unless-stopped \
-v /DATA:/DATA \
-v /media/HDD-Storage:/media/HDD-Storage \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /DATA/AppData/k3d-lab-v3/claude-code-home:/root/.claude \
--network host \
alpine sh -c "apk add --no-cache tmux docker-cli bash curl git kubectl nodejs npm && npm install -g @anthropic-ai/claude-code && git config --global --add safe.directory '*' && tail -f /dev/null"
| Volumen | Destino | Por qué |
|---|---|---|
/DATA |
/DATA |
Acceso a los repos (apps-registry, platform-infra, scripts) y configs del lab, mismos paths que en el host |
/media/HDD-Storage |
/media/HDD-Storage |
Ver "Gotcha: el symlink /DATA/AppData" abajo — sin esto, los repos no se ven dentro del contenedor pese a montar /DATA |
/var/run/docker.sock |
/var/run/docker.sock |
Seguir controlando Docker y k3d (contenedores, k3d node, etc.) desde dentro del contenedor |
/DATA/AppData/k3d-lab-v3/claude-code-home |
/root/.claude |
Persistir login/config de Claude Code — sobrevive no solo a un docker restart, sino a recrear el contenedor entero |
Gotcha: el symlink /DATA/AppData
Montar -v /DATA:/DATA no alcanza para ver los repos dentro del
contenedor. /DATA/AppData en el host es un symlink absoluto:
/DATA/AppData -> /media/HDD-Storage/AppData
Los repos del lab (lab-cruzcloud) viven físicamente en el disco
btrfs /media/HDD-Storage, no en el ext4 de /DATA — ZimaOS lo
expone dentro de /DATA como submount (/DATA/.media/HDD-Storage/...,
que sí viaja al contenedor porque Docker hace bind recursivo por
defecto) pero el path "bonito" /DATA/AppData/... que usan CLAUDE.md
y todos los scripts del lab llega ahí vía ese symlink absoluto, no vía
el submount. Un symlink absoluto que apunta fuera del árbol montado
queda roto dentro del contenedor aunque el submount subyacente esté
perfectamente accesible por otra ruta.
Cómo se detectó: todo el trabajo de configuración inicial de este
mismo setup se hizo con git/docker exec corriendo en el host, no
dentro del contenedor — nadie había probado cd /DATA/AppData/k3d-lab-v3/lab-cruzcloud desde una sesión lab real
hasta que se preguntó explícitamente por esto. ls devolvía "No such
file or directory" pese a que el volumen de /DATA estaba montado y
"andando".
Fix: montar también -v /media/HDD-Storage:/media/HDD-Storage,
para que el symlink resuelva exactamente igual que en el host.
Gotcha: git "dubious ownership" dentro del contenedor
Con el mount anterior ya arreglado, git status/git log dentro del
contenedor fallan con:
fatal: detected dubious ownership in repository at '/media/HDD-Storage/...'
Los archivos en /DATA//media/HDD-Storage pertenecen al usuario
devops del host (via samba), pero los procesos dentro del
contenedor corren como root (UID 0) — git, desde la versión que
introdujo la protección CVE-2022-24765, rechaza operar sobre un repo
cuyo dueño no coincide con el UID del proceso, a menos que el path
esté explícitamente marcado como seguro.
Fix: git config --global --add safe.directory '*' dentro del
contenedor. El wildcard (en vez de listar cada repo) es intencional
acá: este contenedor ya tiene acceso root-equivalente al host vía el
volumen de /var/run/docker.sock, así que restringir safe.directory
por repo no suma aislamiento real.
Por qué está en el comando de arranque y no como paso manual
aparte: igual que los paquetes apk, esta configuración vive en el
filesystem efímero del contenedor (/root/.gitconfig, que no está
bajo el volumen de /root/.claude) — se perdería si el contenedor se
recrea. Ponerla en el sh -c de arranque la deja recreándose sola
cada vez, sin pasos manuales que recordar.
Sobre paquetes: alpine (imagen alpine sin pin de versión, la misma
que uso este setup) sí trae kubectl en sus repos — no hizo falta
cambiar de imagen base ni instalarlo por binario aparte, contrario a
lo que se sospechaba inicialmente. nodejs/npm (v24/v11 al momento
de escribir esto) también están en los repos de alpine y cumplen de
sobra el mínimo que pide Claude Code.
Nota sobre --network host + docker.sock: el contenedor tiene,
en la práctica, control equivalente a root sobre el NAS (puede lanzar
cualquier otro contenedor con cualquier mount). Es una concesión
deliberada — es exactamente lo que hace falta para seguir operando
Docker/k3d desde dentro — no un descuido.
Reconexión: un solo comando
/media/HDD-Storage/AppData/k3d-lab-v3/lab-cruzcloud/scripts/lab-session.sh
El script (scripts/lab-session.sh) es intencionalmente el mismo
comando sin importar el estado previo:
docker exec -it "$CONTAINER" tmux attach -t "$SESSION" || \
docker exec -it "$CONTAINER" tmux new -s "$SESSION"
Si la sesión lab existe, se adjunta a ella (mismo shell, mismo
claude corriendo, mismo directorio de trabajo). Si no existe —por
ejemplo justo después de que el contenedor se recreó desde cero— la
crea. No hay que recordar cuál de los dos casos aplica.
Qué sobrevive a qué
| Evento | ¿Sobrevive la sesión tmux (lab)? |
¿Sobrevive el login de Claude Code? |
|---|---|---|
| Corte de SSH / cerrar la terminal / cambiar de dispositivo | Sí — es exactamente el caso que este setup resuelve | Sí |
docker restart tmux-host (reinicio del NAS) |
No — el proceso tmux server muere con el contenedor; lab-session.sh crea una sesión nueva automáticamente |
Sí, vía el volumen montado en /root/.claude |
docker rm + recrear el contenedor desde cero |
No | Sí, vía el mismo volumen — no hace falta volver a autenticar |
Validación en vivo
Confirmado en este mismo lab (2026-08-27): se dejó una variable de
entorno (export MARCA_PERSISTENCIA=ok_<timestamp>) corriendo dentro
de la sesión lab vía tmux send-keys, y una invocación
completamente separada de docker exec (equivalente a una nueva
conexión) leyó de vuelta el mismo valor — confirma que el estado vive
en el servidor tmux del contenedor, no en el cliente que se conecta.
claude --version respondió 2.1.250 dentro de esa misma sesión, y
un archivo de prueba escrito en /root/.claude dentro del contenedor
apareció de inmediato en
/DATA/AppData/k3d-lab-v3/claude-code-home en el host, confirmando
el bind mount de credenciales.
Después de agregar el mount de /media/HDD-Storage y el
safe.directory, se validó además: md5sum de CLAUDE.md idéntico
leído desde el host y desde dentro del contenedor (vía el path con
symlink /DATA/AppData/...), y git log/git status corriendo sin
error sobre apps-registry y scripts desde una shell dentro de
tmux-host.