Mi libro tiene sala de servidores

Mi libro tiene un stack. Lo digo igual que otros dicen que su perro tiene cuenta de Instagram.
The New Coder vive en un repo de GitHub. Cada capítulo es un fichero Markdown. Un script de Node los convierte en una sala de lectura estática, Cloudflare Pages la sirve, una Pages Function lleva un chat que solo responde con lo que dice el manuscrito, y otro pipeline genera los ficheros de Kindle y KDP. Es un libro con historial de CI/CD. Tiene commits que se titulan “Reduce chat token growth in stored conversations”. Cervantes nunca tuvo uno de esos.
Durante un tiempo, ese stack tuvo una dependencia incómoda, y era yo. Más exactamente, mi MacBook.
Codex y Claude Code vivían en el portátil. Son los dos agentes del capítulo 7, los que llamo “delegación” e “inmersión”. Si cerraba la tapa, el libro dejaba de escribirse. Si estaba en el sofá con el iPad pensando en un arranque mejor para el capítulo 9, el libro ni se enteraba. Se quedaba en un repo de GitHub y en el edge de Cloudflare, esperando a que me levantara, fuera hasta la mesa y abriera la tapa. Siempre. Como un ritual.
Ese era el bug. Arreglarlo me llevó un sábado por la mañana: trece commits en unos ochenta minutos. Sé la cifra porque git guarda la hora de cada commit, y yo la miro.
Esto es la historia y la receta a la vez. Están todos los ficheros, completos. Si los copias en orden, tienes la misma máquina. Si solo lees los chistes, también vale. No lo voy a comprobar.
La regla que escribí y luego tuve que cumplir
El capítulo 7 de mi propio libro dice, muy seguro de sí mismo: la infraestructura duradera va después de la prueba, no antes. No montes un servidor para un proyecto que no se lo ha ganado.
Odio tener razón por escrito.
Pero el proyecto se lo había ganado. El libro existe, está en Amazon, su chat responde preguntas y su autor no para de intentar editarlo desde dispositivos sin terminal. Eso es una prueba. O por lo menos el tipo de prueba que acepto después de un café.
Así que, los requisitos, escritos como los escribo en el trabajo:
- Claude Code y Codex corren en un servidor, siempre encendidos.
- Nadie en internet puede llegar a ellos. Ni una página de login, ni un puerto, nada.
- Yo llego desde tres dispositivos: MacBook Air, iPad e iPhone.
- Desde cualquiera de ellos caigo en la misma sesión, justo donde la dejé.
- Si algo se rompe ahí dentro, se rompe dentro de una caja, no en el servidor.
En lenguaje de reunión: “Sí, pero habría que revisar las dependencias”.
Las revisamos. Trece commits de revisión.
Paso 0: Lo que necesitas antes de empezar
- Un servidor Linux con Docker. El mío es un servidor ARM de Hetzner Cloud (
aarch64) que ya tiene otras cosas detrás de Traefik. Necesitas Docker Engine y el plugin de Compose. Nada más en el host. El host no necesita Tailscale: el contenedor entra en el tailnet por su cuenta. - Una cuenta de Tailscale, con la app instalada y la sesión iniciada en cada dispositivo que deba entrar. En mi caso, el Mac, el iPad y el iPhone.
- Una suscripción a Claude (Pro o Max) para Claude Code y una cuenta de ChatGPT para Codex.
- Una cuenta de GitHub, si tus agentes van a trabajar sobre un repo. Los míos trabajan sobre un libro, que es un repo con sentimientos.
Si tu servidor es x86, cambian dos descargas del Dockerfile: ttyd y el paquete de Codex. Lo indico más abajo.
Paso 1: Preparar el tailnet
Antes de construir nada, el tailnet necesita unos cuantos ajustes. Todos están en la consola de administración de Tailscale.
MagicDNS y certificados HTTPS. En DNS, activa MagicDNS y luego HTTPS Certificates. MagicDNS te deja escribir nobody-home en lugar de una IP. HTTPS permite que Tailscale consiga más adelante un certificado de verdad para la terminal web. Tu tailnet tiene un nombre del estilo tail1234ab.ts.net; lo verás en esa misma página.
Una etiqueta para el servidor. En Access controls, la política tiene que decir quién puede usar la etiqueta. Añade este bloque ya. El resto de la política la cerramos en el paso 5:
"tagOwners": {
"tag:broom-cupboard": ["autogroup:admin"]
},
La etiqueta importa más de lo que parece. Un nodo etiquetado es del tailnet, no tuyo. No puede abrir conexiones hacia tus dispositivos personales, y su clave deja de depender de tu sesión. El servidor pasa a ser un empleado, no un familiar.
Paso 2: Tres ficheros y una carpeta
Primero, una palabra sobre los nombres. En este artículo el contenedor se llama broom-cupboard (el cuarto de las escobas) y su nodo en el tailnet, nobody-home (no hay nadie en casa). No son sus nombres reales. Los reales son aburridos, y además son míos. Tailscale saca un certificado público para el nombre del nodo. Ese nombre acaba en los registros de Certificate Transparency, y cualquiera puede buscar en ellos. Así que no te voy a dar un término de búsqueda. Elige tus propios nombres. Que sean sosos o que tengan gracia. Pero no los llames my-ai-agents-with-github-access.
El título dice sala de servidores. Es un cuarto de las escobas. Siempre lo es.
En el servidor:
mkdir -p ~/stacks/broom-cupboard
cd ~/stacks/broom-cupboard
El Dockerfile
Debian slim, Tailscale y la CLI de GitHub desde sus repos apt oficiales, ttyd para la terminal web, Codex, un usuario sin privilegios llamado janitor (el conserje: ¿quién si no trabaja en un cuarto de las escobas?) y Claude Code con su instalador nativo. Cada binario que viene de una release de GitHub va fijado a una versión y comprobado contra su SHA-256. No me fío de las descargas. Casi no me fío ni de mi portapapeles, como verás.
FROM debian:bookworm-slim
ENV LANG=C.UTF-8 \
TZ=Europe/Madrid
# Base tooling + Tailscale and GitHub CLI from their official apt repos.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl gnupg tini tmux git openssh-client ripgrep less procps jq nano bash-completion tzdata \
&& curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.noarmor.gpg \
-o /usr/share/keyrings/tailscale-archive-keyring.gpg \
&& curl -fsSL https://pkgs.tailscale.com/stable/debian/bookworm.tailscale-keyring.list \
-o /etc/apt/sources.list.d/tailscale.list \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends tailscale gh \
&& rm -rf /var/lib/apt/lists/*
# ttyd web terminal (not packaged in bookworm): pinned static build, checksum
# from the release's SHA256SUMS. The server is aarch64.
ARG TTYD_VERSION=1.7.7
ARG TTYD_SHA256=b38acadd89d1d396a0f5649aa52c539edbad07f4bc7348b27b4f4b7219dd4165
RUN curl -fsSL "https://github.com/tsl0922/ttyd/releases/download/${TTYD_VERSION}/ttyd.aarch64" \
-o /usr/local/bin/ttyd \
&& echo "${TTYD_SHA256} /usr/local/bin/ttyd" | sha256sum -c - \
&& chmod 0755 /usr/local/bin/ttyd
# OpenAI Codex CLI: pinned full package (codex-package.json + bin/ + resources),
# checksum matches the release asset digest on GitHub. The bare binary is not
# enough: the interactive TUI refuses to start without the package layout.
# Lives outside /home/janitor so rebuilds update it (the janitor_home volume only
# seeds on first start). Bump both ARGs to upgrade.
ARG CODEX_VERSION=0.160.0
ARG CODEX_SHA256=7f0fe42ff22ecfa3a47bc4a34f5b22c4218b431a4ec0aba51c7d98299f07900c
RUN curl -fsSL "https://github.com/openai/codex/releases/download/rust-v${CODEX_VERSION}/codex-package-aarch64-unknown-linux-musl.tar.gz" \
-o /tmp/codex.tar.gz \
&& echo "${CODEX_SHA256} /tmp/codex.tar.gz" | sha256sum -c - \
&& mkdir -p /opt/codex \
&& tar -xzf /tmp/codex.tar.gz -C /opt/codex \
&& ln -s /opt/codex/bin/codex /usr/local/bin/codex \
&& rm -f /tmp/codex.tar.gz
# Unprivileged user that Tailscale SSH logs in as.
RUN useradd -m -u 1000 -s /bin/bash janitor \
&& echo 'export PATH="$HOME/.local/bin:$PATH"' > /etc/profile.d/claude-path.sh \
&& printf '%s\n' \
'# Long-lived subscription token from `claude setup-token`, if present.' \
'if [ -r "$HOME/.claude-oauth-token" ]; then' \
' export CLAUDE_CODE_OAUTH_TOKEN="$(tr -d "[:space:]" < "$HOME/.claude-oauth-token")"' \
'fi' > /etc/profile.d/claude-token.sh
# Claude Code (native installer). Lands in /home/janitor/.local, which seeds the
# janitor_home volume on first start; afterwards Claude Code auto-updates itself.
USER janitor
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& mkdir -p /home/janitor/work
USER root
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"]
En x86, usa ttyd.x86_64 y codex-package-x86_64-unknown-linux-musl.tar.gz, y saca los dos checksums de sus páginas de release. No copies mis checksums para otro fichero. Eso no es fijar versiones, es tener fe.
Hay dos detalles que importan después. Codex vive en /opt/codex, fuera del home del usuario, así que se actualiza reconstruyendo la imagen. Claude Code vive en el home, así que se actualiza solo, y la actualización sobrevive a las reconstrucciones porque el home es un volumen.
El entrypoint
Aquí arranca Tailscale. Lanza tailscaled en modo userspace networking. Normalmente Tailscale quiere un dispositivo TUN y la capability NET_ADMIN. Es una forma educada de pedir casi root sobre la red del servidor. En modo userspace no necesita ninguna de las dos. En teoría es más lento. Para una terminal, no lo vas a notar nunca.
Después entra en el tailnet con Tailscale SSH activado. Así no hay servidor SSH que mantener ni claves que gestionar. No vuelvo a editar un authorized_keys en mi vida. Por último arranca ttyd como el usuario janitor, solo en 127.0.0.1 y dentro de un bucle. Si ttyd se cae, vuelve a levantarse, y el contenedor no se cae con él.
#!/bin/sh
# Starts tailscaled in userspace mode (no TUN, no NET_ADMIN) and joins the
# tailnet with Tailscale SSH enabled. On first boot `tailscale up` prints a
# login URL to the container logs and waits; afterwards the state volume keeps
# the node logged in.
#
# Also runs ttyd (web terminal) as the `janitor` user on 127.0.0.1:7681. It is
# only reachable through `tailscale serve` (tailnet-only HTTPS). Auth is the
# Tailscale identity: Serve adds Tailscale-User-Login to every proxied request
# (and strips any copy sent by the client), and ttyd refuses requests without it.
# Which devices can reach this node at all is decided by the tailnet ACLs.
set -eu
SOCK=/var/run/tailscale/tailscaled.sock
mkdir -p /var/run/tailscale /var/lib/tailscale
tailscaled \
--tun=userspace-networking \
--state=/var/lib/tailscale/tailscaled.state \
--socket="$SOCK" &
TSD_PID=$!
i=0
until [ -S "$SOCK" ]; do
i=$((i + 1))
[ "$i" -gt 30 ] && { echo "tailscaled did not start" >&2; exit 1; }
sleep 1
done
tailscale --socket="$SOCK" up \
--ssh \
--hostname="${TS_HOSTNAME:-nobody-home}"
echo "Tailscale up: $(tailscale --socket="$SOCK" ip -4 2>/dev/null | head -1)"
# Restart ttyd if it ever exits; it must not take the container down.
# No --credential: Safari and every iOS browser omit basic auth on the
# WebSocket upgrade, so ttyd denied them. No --check-origin either: behind
# tailscale serve it denied real browser WebSockets.
(
while true; do
setpriv --reuid=janitor --regid=janitor --init-groups \
env HOME=/home/janitor USER=janitor SHELL=/bin/bash \
ttyd --interface 127.0.0.1 --port 7681 --writable \
--auth-header Tailscale-User-Login \
--client-option titleFixed=nobody-home \
bash -lc 'tmux new -A -s main -c ~/work' || true
sleep 2
done
) &
wait "$TSD_PID"
Los comentarios sobre Safari son cicatrices de guerra. Esa historia llega en el paso 6.
El fichero compose
Esta es la parte de la que estoy más orgulloso. Es, sobre todo, una lista de cosas que el contenedor no puede hacer.
services:
agents:
build: .
image: broom-cupboard:latest
container_name: broom-cupboard
hostname: nobody-home
restart: unless-stopped
mem_limit: 1536m
cpus: 1.50
pids_limit: 512
# tailscaled runs as root in userspace mode; Tailscale SSH needs to drop to
# the `janitor` user, hence SETUID/SETGID. No NET_ADMIN, no /dev/net/tun.
cap_drop:
- ALL
cap_add:
- SETUID
- SETGID
- CHOWN
- DAC_OVERRIDE
- FOWNER
- KILL
- AUDIT_WRITE
security_opt:
- no-new-privileges:true
environment:
TS_HOSTNAME: nobody-home
TZ: Europe/Madrid
volumes:
# Tailscale node identity. Deleting it means re-login and a new node.
- tailscale_state:/var/lib/tailscale
# Whole home: Claude Code binary, ~/.claude (login), ~/.claude.json, ~/work.
- janitor_home:/home/janitor
# No published ports and not on the reverse-proxy network: reachable only via the tailnet.
networks:
- broom_cupboard
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD-SHELL", "tailscale --socket=/var/run/tailscale/tailscaled.sock status >/dev/null 2>&1 || exit 1"]
interval: 60s
timeout: 5s
retries: 3
start_period: 120s
volumes:
tailscale_state:
janitor_home:
networks:
broom_cupboard:
Léelo como un contrato de trabajo:
- No hay sección
ports:. Nada en el servidor escucha para este contenedor. Un escaneo de puertos encuentra lo mismo que ayer. - Su propia red. Traefik, que ve todo lo demás en la máquina, no sabe que este contenedor existe.
- Todas las capabilities eliminadas. Luego recupera solo las pocas que Tailscale SSH necesita para cambiar al usuario
janitor.no-new-privilegesimpide que nada de dentro escale más. - 1,5 GB de RAM, 1,5 CPUs y 512 procesos. Si un agente se emociona y lanza una compilación enorme, solo gasta lo suyo. Los demás servicios de la máquina ni se enteran.
- Dos volúmenes.
tailscale_statees la identidad del nodo. Si lo borras, el servidor olvida que alguna vez entró en tu tailnet.janitor_homees el home entero: logins, ajustes, el propio Claude Code y~/work, donde viven los repos. Reconstruye la imagen las veces que quieras. Esto se queda.
Es el introvertido del servidor. Solo te habla si ya estás en el grupo de WhatsApp.
Paso 3: Construirlo y dejar que entre
docker compose build
docker compose up -d
docker logs -f broom-cupboard
En el primer arranque, tailscale up imprime una URL de login y se queda esperando. Ábrela en un navegador, entra en tu cuenta de Tailscale, y el nodo aparece en la consola como nobody-home. A partir de ahí la identidad vive en el volumen tailscale_state, y los reinicios y reconstrucciones ya no preguntan.
Compruébalo desde el Mac:
tailscale status | grep nobody-home
Paso 4: Ponerle la etiqueta y quitarle la caducidad
En la consola, en Machines, abre el menú ⋯ de nobody-home:
- Edit ACL tags → añade
tag:broom-cupboard. Es el momento en el que el servidor deja de ser “uno de mis dispositivos” y pasa a ser “un servidor del tailnet”. - Disable key expiry. Por defecto, las claves de los nodos caducan a los pocos meses y el nodo desaparece sin avisar. Yo esperaba que la etiqueta se encargara de esto. No lo hizo. Comprueba que la máquina ya no muestra fecha de caducidad.
Después reinicia el contenedor una vez, para asegurarte de que el nodo vuelve con su etiqueta:
docker compose restart
Paso 5: Cerrar la puerta y probar la cerradura
No hay contraseña en ningún sitio, así que otra cosa tiene que controlar quién entra. Esa otra cosa es la política del tailnet, en Access controls. Aquí tienes una política que hace lo mismo que la mía. Pon las IPs de tus dispositivos (las sacas de tailscale status o de la página Machines) y tu propio login:
{
"tagOwners": {
"tag:broom-cupboard": ["autogroup:admin"]
},
// Policies don't accept MagicDNS names, so the allowed devices go by IP.
"hosts": {
"macbook-air": "100.64.0.11",
"ipad": "100.64.0.12",
"iphone": "100.64.0.13",
"mac-mini": "100.64.0.14"
},
"acls": [
// Personal devices keep reaching each other as before.
{"action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:member:*"]},
// Only three devices reach the agent box, and only for SSH and the web terminal.
{"action": "accept", "src": ["macbook-air", "ipad", "iphone"], "dst": ["tag:broom-cupboard:22,443"]}
],
"ssh": [
// Your own devices, as before.
{"action": "check", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot", "root"]},
// The agent box: only the unprivileged `janitor` user, with a periodic re-check.
{"action": "check", "src": ["autogroup:member"], "dst": ["tag:broom-cupboard"], "users": ["janitor"]}
],
// If an edit ever breaks these, the console refuses to save it.
"tests": [
{"src": "macbook-air", "accept": ["tag:broom-cupboard:22", "tag:broom-cupboard:443"]},
{"src": "iphone", "accept": ["tag:broom-cupboard:22", "tag:broom-cupboard:443"]},
{"src": "mac-mini", "deny": ["tag:broom-cupboard:22", "tag:broom-cupboard:443"]}
],
"sshTests": [
{"src": "you@example.com", "dst": ["tag:broom-cupboard"], "check": ["janitor"], "deny": ["root"]}
]
}
Mi política real tiene unos cuantos dispositivos más en el lado de los denegados. Si tu tailnet ya tiene una política, mete estos bloques en ella. No la pegues encima entera.
Lo que consigues:
- Tres dispositivos llegan a la máquina, por dos puertos. Todo lo demás de mi tailnet, incluido el Mac mini de mi mesa, ni la ve. No es nada personal contra el Mac mini. Es que no pinta nada en esa habitación.
- SSH solo como
janitor. Piderooty Tailscale contesta, con la educación de una ventanilla de Hacienda, “tailnet policy does not permit you to SSH as user root”. - Como la máquina está etiquetada, no puede abrir conexiones hacia mi portátil ni hacia mi móvil. Si alguien consigue entrar en el contenedor, se encuentra un callejón sin salida. No un camino hacia la red de mi casa.
- Los bloques
testsysshTestsson mi parte favorita. Si alguien (es decir, yo, a medianoche) cambia las reglas de forma que entre otro dispositivo o que vuelva a funcionar el SSH como root, la consola se niega a guardar. Una política que se pone a prueba a sí misma. Quiero una para mi dieta.
Una trampa: no etiquetes tus dispositivos cliente. Tailscale Serve solo envía la cabecera de identidad (paso 6) para dispositivos que pertenecen a un usuario. Etiqueta tu iPad y la terminal web lo rechazará. Y si alguna vez reinstalas Tailscale en un dispositivo, le toca una IP nueva, así que actualiza hosts.
Por esta parte me pillé dibujando un modelo de amenazas para decidir si debía permitirme editar el capítulo 4 desde la cama. Columnas de probabilidad, impacto y mitigaciones. Entonces caí en que la cama ya estaba cubierta: el iPad está en la lista de permitidos, y el iPad va conmigo a todas partes. Borré la tabla.
Paso 6: Una terminal web para el iPhone
SSH en un portátil es una maravilla. En un iPhone, un castigo. Por eso el contenedor también ejecuta ttyd, una terminal dentro de una página web. Solo escucha en 127.0.0.1 dentro del contenedor. Ejecuta esto una vez para publicarla en el tailnet, y solo en el tailnet:
docker exec broom-cupboard tailscale --socket=/var/run/tailscale/tailscaled.sock \
serve --bg --https=443 http://127.0.0.1:7681
Ahora Tailscale Serve responde en https://nobody-home.<tu-tailnet>.ts.net/ con un certificado de verdad y reenvía a ttyd. El ajuste se guarda en el volumen de estado, así que sobrevive a las reconstrucciones. No uses nunca funnel en lugar de serve. Funnel publica cosas en internet, y la gracia de todo esto es que nada esté en internet.
Ahora, la historia de Safari. La primera versión de ttyd tenía contraseña. A Chrome en el Mac le encantó. Safari enseñaba una terminal medio segundo y luego entraba en un bucle de “Press Enter to reconnect”, para siempre, con la calma de un monje que nunca se aburre.
Resulta que Safari no envía la autenticación básica en el upgrade a WebSocket. Ningún otro navegador de iOS lo hace, porque por debajo todos usan WebKit. ttyd rechazaba la conexión, la página reintentaba, ttyd volvía a rechazarla. Un sistema muy estable. Solo que inútil. --check-origin provocaba el mismo síntoma detrás de Serve, así que también se fue.
Así que quité la contraseña y dejé que Tailscale hiciera de login. Serve añade una cabecera Tailscale-User-Login con la identidad real del dispositivo que se conecta. ttyd, arrancado con --auth-header Tailscale-User-Login, rechaza cualquier petición que no la lleve (HTTP 407). Por supuesto, intenté hacer trampa. Mandé una petición con una cabecera falsa que decía ser forged@evil.example. Serve la tiró y puso mi identidad real. Pocas veces me ha hecho tanta ilusión que me pillen.
El nombre ts.net no resuelve en el DNS público. Lo único que internet puede saber es el nombre del host, por los registros de Certificate Transparency, porque Tailscale saca un certificado real para él. Saber la dirección de una casa no abre la puerta.
Compruébalo desde el Mac. Un 200 significa que funciona:
curl -s -o /dev/null -w '%{http_code}\n' https://nobody-home.<tu-tailnet>.ts.net/
Paso 7: Iniciar sesión en Claude Code (sin /login)
Conseguir que Claude Code iniciara sesión fue un número cómico aparte.
/login por SSH imprime una URL larga. La copié, la pegué en el navegador y claude.ai respondió “Missing state parameter”. La terminal había cortado la URL por la mitad al copiarla. Las terminales hacen eso. Creo que lo disfrutan.
La solución es un token de suscripción de larga duración. En el Mac:
claude setup-token
Imprime una línea que empieza por sk-ant-oat01-. Cópiala. Después mándala al servidor directamente desde el portapapeles. Añadí una comprobación previa, porque no quería descubrir más tarde que había enviado media lista de la compra a un servidor:
pbpaste | tr -d '[:space:]' | grep -qE '^sk-ant-oat01-' \
&& pbpaste | tr -d '[:space:]' | ssh janitor@nobody-home 'umask 077; cat > ~/.claude-oauth-token' \
&& echo "OK, token sent" \
|| echo "The clipboard doesn't hold a token, nothing sent"
El fichero se guarda con permisos 600. El script de perfil que va dentro de la imagen lo exporta como CLAUDE_CODE_OAUTH_TOKEN en cada shell de login. Abre una sesión nueva de tmux, ejecuta claude y ya estás dentro.
El token dura más o menos un año. Tengo un recordatorio en el calendario. Esa es mi idea de crecimiento personal.
Paso 8: GitHub, con un código en lugar de una URL
Dentro del contenedor, como janitor:
gh auth login --hostname github.com --git-protocol https --web
gh auth setup-git
--web te da un código corto y la dirección https://github.com/login/device. Escribes el código en cualquier dispositivo, así que nada largo tiene que sobrevivir a un copiar y pegar por SSH. Esa lección la había aprendido cuarenta minutos antes. gh auth setup-git hace que git clone y git push por HTTPS usen ese mismo login.
Y llega el momento de la verdad:
cd ~/work
gh repo clone <tu-usuario>/agenticai-book
El repo del libro se clonó como si siempre hubiera vivido ahí.
Una nota sincera. En el contenedor no hay llavero, así que el token queda en texto plano en ~/.config/gh/hosts.yml, dentro del volumen. Quien entre en el contenedor se lleva también ese token. Yo elegí a propósito un login normal con acceso a todos mis repos. La opción más estricta es un token fine-grained limitado a unos pocos repos: gh auth login --with-token < file.
Paso 9: Y entonces llegó Codex
Con Claude ya instalado, la otra mitad del capítulo 7 también tenía que mudarse.
Codex costó más. Mi primera imagen usaba el binario suelto de codex de la release. codex exec funcionaba bien. El codex interactivo se negaba a arrancar y decía que la CLI “has no complete local package”. Quería el paquete completo, con sus carpetas y recursos, no solo el ejecutable. Por eso el Dockerfile de arriba descarga codex-package-…tar.gz y lo descomprime en /opt/codex. Aprende de mi reconstrucción.
El login también va con código de dispositivo:
codex login --device-auth
Falla hasta que activas el inicio de sesión con código de dispositivo para Codex en ChatGPT → Settings → Security. Lo activas, vuelves a ejecutar el comando, escribes el código y listo. Compruébalo con codex login status.
Después, ~/.codex/config.toml. Vive en el volumen, no en la imagen, así que lo escribes una sola vez:
# Codex bwrap sandbox cannot run here (seccomp blocks user namespaces);
# the broom-cupboard container is the sandbox.
sandbox_mode = "danger-full-access"
approval_policy = "never"
# Skip the per-folder "Trust this folder?" prompt for everything under ~/work.
[projects."/home/janitor/work"]
trust_level = "trusted"
# Do not expose the ChatGPT account apps (connectors) inside this container;
# use gh/git here instead.
[features]
apps = false
Cada línea tiene su historia.
danger-full-access. Normalmente Codex ejecuta los comandos dentro de su propio sandbox, hecho con bubblewrap. Bubblewrap necesita user namespaces. El perfil seccomp por defecto de Docker los bloquea (unshare -U → “Operation not permitted”), y mi servidor además los restringe en AppArmor. Podría haber relajado seccomp para todo el contenedor solo para que Codex tuviera su pequeño sandbox dentro del mío. No lo hice. Un sandbox dentro de un sandbox dentro de un servidor. En algún momento hay que parar. El contenedor es el sandbox, para Codex y para Claude. Por eso el paso 2 era tan estricto.
apps = false. Por defecto, Codex se trae las apps que tienes conectadas en ChatGPT. Una de las mías era una conexión de GitHub con permiso de escritura, y yo quería que esta máquina usara solo el login de gh del paso 8. Una línea y desactivado. La herencia está bien en las familias. En los permisos, no tanto.
Paso 10: Usarlo
Las tres formas de entrar acaban en la misma sesión de tmux, que se llama main.
Desde el Mac, por Tailscale SSH:
ssh -t janitor@nobody-home 'tmux new -A -s main -c ~/work'
Dentro, ejecuta claude o codex. Te sales con Ctrl-b d. Ejecutas el mismo comando y vuelves. Como la política usa el modo check, a veces Tailscale te pide confirmar en el navegador. Es la cerradura haciendo su trabajo.
Desde el iPad o el iPhone: abre https://nobody-home.<tu-tailnet>.ts.net/ en Safari. Sin contraseña. Tu dispositivo es la contraseña.
Desde un ordenador prestado: la consola de Tailscale tiene un botón de SSH en cada máquina. Abre una terminal en el navegador, con tu sesión y bajo la misma política. Usa janitor como usuario. En una tableta escribir así es incómodo, pero funciona.
Puedo empezar una frase en el portátil, cerrar la tapa, coger el iPhone y encontrarme el cursor parpadeando justo donde lo dejé. Es lo más parecido a teletransportarme que tengo, y funciona sobre Debian.
Para actualizar más adelante: Claude Code se actualiza solo. Para Codex, cambia CODEX_VERSION y CODEX_SHA256 en el Dockerfile y luego:
docker compose build && docker compose up -d
Los logins, los repos y la identidad de Tailscale viven en volúmenes, así que no cambia nada más.
La misión secundaria que nadie pidió
Ya que estaba en la máquina, vi un Docker Swarm antiguo que no hacía nada. Un solo nodo, ningún servicio y dos secretos huérfanos de un experimento anterior. Me gustan las cosas ordenadas, así que ejecuté docker swarm leave.
El DNS interno de Docker empezó en el acto a devolver SERVFAIL para todos los nombres de contenedor. Los nombres de fuera seguían resolviendo, así que no se cayó nada público, pero los contenedores dejaron de encontrarse entre sí. Reiniciar un contenedor no servía de nada. Solo lo arregló systemctl restart docker, con unos cuarenta segundos de corte y el pulso un poco más rápido.
La lección quedó en negrita en el fichero de auditoría: después de salir de un swarm, reinicia el demonio de Docker en la misma ventana de mantenimiento y comprueba que los contenedores se resuelven entre sí por nombre. La aprendí yo para que no tengas que aprenderla tú. De nada.
El stack, actualizado
Esto es lo que mueve el libro ahora:
| Capa | Antes | Después |
|---|---|---|
| Código fuente | Repo de GitHub | Igual |
| Web y chat | Cloudflare Pages + Pages Functions | Igual |
| Cerebro del chat | OpenAI con un vector store | Igual |
| Agentes | Codex y Claude Code en un MacBook | Codex y Claude Code en una sesión de tmux en Hetzner |
| Forma de entrar | Sentarse en la mesa | Mac, iPad o iPhone, solo por Tailscale |
| Excusas del autor | Muchas | Menos |
La división del capítulo 7 sigue en pie. Codex es donde delego: construir la web, pasar los tests, sincronizar el vector store. Claude Code es donde me siento cerca: rehacer un párrafo, perseguir un enlace roto por el visor, discutir el título de un capítulo. La diferencia es que ahora los dos están siempre encendidos, uno al lado del otro, en una caja que el resto de internet no ve.
Trece commits. Ochenta minutos. Un DNS roto, una cabecera falsificada, cero puertos abiertos.
El libro ya tiene sala de servidores. Vale, un cuarto de las escobas. El autor sigue teniendo sofá, y por primera vez los dos trabajan juntos.
P. D.: Si quieres leer los capítulos que no paro de citar, The New Coder está en Amazon. El enlace pasa por una pequeña Cloudflare Pages Function del propio stack del libro. Mira desde qué país lo abres y te manda a tu tienda de Amazon. Sí, hasta el botón de comprar tiene infraestructura. El capítulo 7 explica por qué no lo puedo evitar. El capítulo 9, por qué debería.