Arquitectura ajolote: cuando regenerar sale más barato que tener réplicas
Cuando se cayeron los dos nodos primarios al mismo tiempo, no usé failover. Regeneré.
El 5 de junio de 2026, alrededor de las 03:14 UTC, se fue la luz en
el departamento donde vivían ash (el server pesado:
Postgres, Forgejo, n8n) y ine (la torre vieja: koa-auth,
Stalwart admin). Más de 50 horas después seguían apagados. La luz volvió
hasta el 10 de junio a las 13:55 CST. En el camino aprendí —de manera
empírica— que el patrón de resiliencia que ya tenía no era HA, no era
failover, y no necesitaba serlo. Le terminé poniendo nombre:
arquitectura ajolote. Regeneración elástica desde el substrate.
Este post es la explicación que me hubiera gustado leer antes de empezar a operar mi stack personal con 47 sitios y unos 30 servicios distribuidos en siete nodos (atl, ash, ine, bob, koa-files, lar, elo). Va para founders y devs que llevan stacks no-triviales sin equipo SRE detrás.
El problema concreto
Setup al momento del corte:
atl: laptop Lenovo P14 con Arch, la workstation principal. Tiene el workspace canonical~/koa/.ash: torre pesada en el depa. Postgres (supabase-catrina, supabase-project), Forgejo, n8n, OpenWebUI, Metabase, Chatwoot, Mattermost. Tier 1.ine: torre vieja en el mismo depa. koa-auth (SSO de 16+ sitios), Stalwart admin, varios servicios menores.bob: VPS Vultr. OpenClaw gateway, bots de Telegram.koa-files: VPS Hetzner público. Caddy para todos los dominios, restic offsite, Headscale.lar: VPS. Sitios NDNS auxiliares y otros estáticos.elo: VPS independiente. Stalwart mail prod, fuera del scope del depa.
47 sitios bajo koa-files/Caddy. Cuando
ash + ine cayeron, no se cayó solo “Postgres y SSO”. Cayó
la mitad del DNS interno: cada sitio con forward_auth
empezó a devolver 502. Cada landing que dependía de un FastAPI corriendo
en ash devolvió cert OK pero upstream muerto. Cada bot de WhatsApp sin
n8n encoló mensajes que Meta iba a reintentar por 4-6 horas y luego
tirar.
El backup era restic offsite en koa-files. El código estaba en
Forgejo, que vivía en ash. La cadena se rompió en el peor
punto posible: el servidor que tenía el código también era el que estaba
caído.
Tres breakthroughs previos que no bastaban
Antes del corte ya había acumulado tres ideas sobre resiliencia, ninguna suficiente:
1. atl como standby (2026-06-05). La laptop puede hacer trabajo de cómputo durante el corte. Drené 52 items de macroplan en tres rondas usando atl mientras el depa estaba muerto. Conclusión: atl es tier compute esencial, no solo dev box. Limitación: nadie de afuera puede entrar a mi laptop con IP residencial cambiante. Cubre mi workflow, no a usuarios externos.
2. Distribución parcial (2026-06-06). Levanté
koa-proxy en atl:4000 con ocho providers de LLM, validator
v0.2, event bus. Funcionó para mí, pero seguía siendo “atl es Miguel, el
resto sigue caído”.
3. Meta-layer (2026-06-03). Tres días antes del corte ya había acuñado el “meta-motor”: el workspace mismo es el sistema operativo. Cualquier herramienta (koa-todo, koa-decisions, koa-topics, koa-secrets) debe ser regenerable desde el workspace. Salió de explorar HyperCard, q/kdb+ y Emacs como modelos de “sistema vivo donde todo es navegable”.
Los tres prepararon el terreno conceptual. Lo que no tenía era el insight operativo de la fase 3.
El cuarto breakthrough: ajolote
El ajolote es una salamandra mexicana que regenera extremidades, médula espinal, partes del corazón. No es inmortal. Si lo tiras al agua sin oxígeno, se muere igual. Lo que tiene es que si pierde un brazo, el cuerpo lo re-crece desde el tejido. La identidad del animal no vive en el brazo: vive en el genoma.
Apliqué la metáfora al stack:
No hay HA. No tengo réplicas vivas de Postgres en bob. No tengo un n8n shadow corriendo en lar listo para tomar el lugar. Mantener réplicas vivas para 30+ servicios es costo operativo permanente que no puedo absorber.
No hay failover automático. No quiero que un health check con falso positivo (un reboot legítimo de 5 minutos) dispare un switch que después necesita reconcile manual de horas. El costo de pulsar “switch” manualmente es 2 minutos. El costo de un false-positive es horas.
Regeneración deliberada. Cuando pierdes algo, lo re-creces desde el substrate. El substrate es la trinidad workspace + restic + Forgejo:
- El workspace
~/koa/está replicado entreatly los nodos vivos víakoa-sync.sh. - El restic offsite en
koa-filestiene los snapshots de Postgres, sqlite y volumes que el workspace no contiene. - Forgejo es source of truth para code que no está en el workspace local (porque vivía solo en un repo ash, por ejemplo).
- El workspace
El substrate es la identidad del sistema. Los nodos son intercambiables.
ashno es “el servidor de Postgres”.ashes el contenedor actual donde corre Postgres. Siashmuere, levanto Postgres en otro nodo a partir del pg_dump. La identidad de “mi Postgres” vive en los snapshots restic, no en el hierro.
El ajolote no es magia. No te salva de un apocalipsis donde pierdes substrate. Lo que hace es desacoplar “cuántos nodos sobreviven” de “qué tan rápido vuelvo a estar vivo”. Si tienes el substrate, regeneras. Si no, no había manera.
La receta concreta
El pattern probado cinco veces en la sesión nocturna del 7 al 8 de junio:
# 1. rsync del código del workspace al nodo destino. EXCLUIR .env siempre.
# atl no tiene .env (solo .env.example) → --delete-after borraría el .env del destino.
ssh [email protected] 'mkdir -p ~/srv/<app>'
rsync -a \
--exclude=".env" --exclude="data/db.sqlite" \
--exclude=".git" --exclude=".venv" --exclude="__pycache__" \
~/koa/11_work/<app>/ [email protected]:srv/<app>/
# 2. venv + install local en el nodo destino.
ssh [email protected] 'cd ~/srv/<app> && python3 -m venv .venv && .venv/bin/pip install -r api/requirements.txt'
# 3. Sembrar .env con secrets. Si los reales solo viven en el nodo caído,
# usar placeholders del .env.example y rotar después.
Luego un systemd user unit:
[Service]
WorkingDirectory=/home/inge/srv/<app>
EnvironmentFile=/home/inge/srv/<app>/.env
ExecStart=/home/inge/srv/<app>/.venv/bin/uvicorn api.main:app --host 127.0.0.1 --port 8211
Restart=on-failure
El detalle importante es --host 127.0.0.1. Los VPS
tienen firewall puerto-específico: lar solo abre
8201/8205/8210/8220/8221/8231/8250/8275/8770; bob solo
22/80/443/8080/8765. Si bindeas a 0.0.0.0 en un puerto nuevo,
simplemente no responde desde fuera. La salida es que
koa-files corre Caddy en NetworkMode=host, lo
que le permite llegar a 127.0.0.1:<puerto> del host
sin pasar por firewall ni Tailscale.
Luego el switch de Caddy:
# Editar el Caddyfile canónico en koa-files.
ssh inge@koa-files
cd /srv/koa/services/caddy
sed -i 's|reverse_proxy 100.64.0.1:8221|reverse_proxy 127.0.0.1:8211|' Caddyfile
docker restart caddy # NO caddy reload.
Este último paso es el detalle que me costó una hora la primera vez.
sed -i cambia el inode del archivo. El container de Caddy
tiene el Caddyfile montado como bind mount, y el bind mount sigue
apuntando al inode original. caddy reload re-lee el
archivo, pero el archivo dentro del container es el inode viejo. Solo
docker restart caddy rebindea correctamente. Verificar con
md5sum host vs container.
Caso real: ndns.club en 90 minutos
ndns.club es el sitio público de un bar/comedia que
coopero con dos socios. Postgres con menú, reservas, eventos del Mundial
2026, signage. Cayó al mismo tiempo que ash. La gente intentando comprar
boletos veía 502.
Lo que hicimos en 90 minutos:
Encontrar el código. El backend de
ndns.club(10,263 líneas FastAPI) no estaba en atl. Vivía solo en Forgejo, eninge/11_work.git/ndns/backend/. Forgejo estaba en ash, también caído. Restauré el repo desde el snapshot restic6103506b(2026-06-05 03:00) — el último cron antes del corte. Ventana de pérdida potencial: ~14 minutos.Levantar Supabase. Recreé
catrina-pg(Postgres 15.8) +catrina-postgrest(PostgREST v14.5) enkoa-filesdesde el pg_dump del 2026-06-04 (RPO ~33h). 265 tablas con data real: 1754 bank_transactions, dashboard users, CFDIs.JWT_SECRET,ANON_KEYySERVICE_ROLE_KEYoriginales restaurados desdesupabase-project/.env, que afortunadamente tenía copia en el workspace.Levantar el FastAPI. rsync del backend desde
/tmp(donde restauré el repo Forgejo) al nodokoa-files, venv, uvicorn en127.0.0.1:8220(puerto canónico ndns-menu), systemd user unit.Switch de Caddy. sed del Caddyfile para apuntar
ndns.clubyndns.koa.deva127.0.0.1:8220.docker restart caddy.Bug subtle: 20 segundos de latencia por request. El handler hacía fetches paralelos a Supabase para cargar menú, cartelera, reservas y eventos. La variable
SUPABASE_URL(distinta deSUPABASE_URL_INTERNAL) seguía apuntando a100.64.0.1:54321, que era el ash caído. Cada fetch esperaba el timeout de 10 segundos antes de fallar. Fix: apuntarSUPABASE_URLa127.0.0.1:8221. Latencia 20s → 200ms. Speedup de 100×, que no era performance work sino debug puro.
El sitio quedó vivo a las 02:47 UTC del 8 de junio. Reservas funcionando, menú visible, los partidos del Mundial 2026 listados (9 de fase de grupos + 32 de KO, 41 filas en total).
Lo que no repliqué: la mecánica financiera del bar (CFDIs, conciliación bancaria). Esa data vive en el Supabase regenerado, pero los dashboards que la muestran solo corren en atl, accesibles vía Tailscale. Es deliberado.
Tres reglas duras que no rompí (y por qué)
1. Nada financiero en dominios públicos. Tuve un
incidente de unos 30 minutos: monté el build de hub-v2 (mi
dashboard interno) en koa.dev pensando que era un landing
público. hub-v2 incluye paneles de finanzas y secrets. Lo
apagué cuando me di cuenta, puse maintenance, purgué Cloudflare. La
regla feedback_finanzas_no_public existía antes pero no la
había internalizado. Ahora sí: si dudas, Tailscale-only. Si necesitas
exponerlo, prueba antes que no hay leak.
2. Firewall puerto-específico en VPS.
lar y bob no son “Linux con todos los puertos
abiertos”. Tienen reglas específicas. Cualquier puerto nuevo está
bloqueado desde fuera. Solo koa-files con Caddy en
NetworkMode=host te deja improvisar puertos altos como
127.0.0.1:<puerto> y rutear desde Caddy. Si tu plan
de regeneración asume “puedo bindear 8888 y curlearlo de afuera”, revisa
el firewall primero.
3. Caddy running vs Caddy disk. sed -i
rompe el bind mount. caddy reload no rescata.
docker restart caddy sí. Esta es la pata clásica de “edité
el archivo, no se nota”. Cualquier sistema con bind mounts y editores
que reescriben inodes tiene la misma trampa. Regla operativa: después de
tocar config en disco, valida con md5sum host vs container
que el contenido se haya propagado.
Cuándo el ajolote no aplica
No es bala de plata. Hay escenarios donde la regeneración deliberada es mala respuesta:
HA real con SLA externo estricto. Si firmaste un SLA de 99.99% (52 minutos al año), no puedes darte el lujo de un RTO de 90 minutos para regenerar. Necesitas réplicas vivas activas, anycast, multi-region. El ajolote es para stacks donde 1-4 horas de downtime son aceptables o donde tú decides el SLA.
Latencias <100ms estrictas. Si el sistema tiene que responder en milisegundos a usuarios externos consistentemente, no puedes meter una capa de “y si esto cae, regenero”. Necesitas activos calientes y health checks rápidos.
Data divergence imposible. En banking, ledger contable estricto, sistemas con concurrencia escritural alta y exigencia de consistencia exacta, no te puedes dar el lujo de un RPO de 24-36h. Necesitas log-shipping, sync replication, transaction logs.
Para todo lo demás —que en mi experiencia es 90% de los stacks personales y de startups en etapa temprana— el ajolote gana en costo/beneficio. El costo recurrente de mantener réplicas vivas es alto. El costo recurrente de mantener el substrate sano (backups, sync, docs del pattern) es bajo.
Hacia self-healing
El siguiente paso es automatizar partes del ajolote sin caer en el anti-pattern del failover automático con false-positives.
Lo que estoy explorando:
Auto-detect del primary regresando. Cron en
koa-filesque pingueaashcada 60s vía Tailscale. Cuando vuelve después de >30min down, notifica Telegram con “ash regresó. ¿Iniciar reconcile playbook? Reply RECONCILE-1 / RECONCILE-2 / ABORT”. No actúa solo. Da el contexto.Auto-reconcile selectivo. Para append-only stores con IDs idempotentes (koa-todo, koa-decisions, koa-topics, koa-relay clicks), el merge post-regeneración es algorítmico. Para Postgres pesado (catrina, supabase-project), sigue siendo manual con playbook humano. La división es importante: lo que se puede auto-mergear sin riesgo, automatiza; lo que tiene revenue path o riesgo de duplicate, déjalo manual y ofrece la herramienta.
Pre-baked targets. El switch toma 90 minutos cuando es bootstrap desde cero. Si pre-cocinas
bobconkoa-authvenv + env file listos, el switch tarda 10 minutos. Si pre-cocinaslarcon docker-compose de catrina + n8n y los volúmenes Postgres vacíos pero listos parapg_restore, el switch tarda 20 minutos. Pre-baking es inversión one-time que paga cada incidente. El plan formaldistribuir-spofs-ajolote.mdya lo documenta como Wave 0 bloqueante.Substrate audit continuo. Si el substrate se corrompe en silencio (restic deja de respaldar
supabase-projectporque es root-owned, koa-sync salta una rama, Forgejo deja de hacer pg_dump diario), el ajolote se vuelve mentira. Necesito un health check diario que valide: ¿hay snapshot restic de cada DB en las últimas 36h? ¿El workspace en atl y en koa-files está dentro de 6h de diferencia? ¿Forgejo tiene snapshot reciente? Sin esto, el día que regenere voy a descubrir que el substrate estaba podrido.
Epílogo: lo que pasó el 10 de junio
La luz volvió al depa el 10 de junio a las 13:55 CST, cinco días después del corte. Lo que aprendí cuando el primary regresó:
1. La data sobrevivió, pero no estaba garantizado.
El volumen de Docker de n8n quedó intacto en ash (corte de
luz, no falla de hardware). Saqué backup completo antes de tocar nada:
28 workflows + 6 credentials + .n8n completo (475 MB,
incluyendo encryptionKey) a
ash:~/koa-backups/n8n-backup-20260610/ y copia offsite
inmediata en koa-files:~/srv/ash-return/. Detalle previo
identificado durante el corte: el volumen de n8n no estaba en la lista
de paths que restic respalda. Si el disco hubiera muerto, esos 28
workflows se perdían. Gap P0 documentado.
2. Forgejo no levantó solo. Container
Exited (255) al boot. Causa: race conocido entre Docker y
Tailscale. Docker intenta bind a 100.64.0.1 (la IP de
Tailscale) antes de que tailscaled tenga la IP asignada.
Fix temporal: docker compose up -d para recrear el
container con los puertos correctos. El runner reconectó tras restart.
Fix raíz pendiente: dependencia systemd o restart-retry. Se va a repetir
en cada boot hasta que se arregle.
3. El push masivo funcionó. 198 commits acumulados
en el workspace durante los 5 días. koa-sync solo empuja
branches plan/* automáticamente; los main de los submódulos
se empujan a mano. Los hooks de frontmatter bloquearon varios commits —
los fixes fueron reales, no bypass (excepto un
KOA_SKIP_RELATED_CHECK=1 justificado en
koa.cards, donde el índice de relaciones quedó stale
post-outage).
4. La reconciliación de datos fue desigual. Tres
casos distintos: - Mundial 2026: 41 filas (9 grupos +
32 KO) que se crearon en el regenerado de koa-files.
Mergeadas a ash vía staging table sin preservar
id, usando \copy client-side (el usuario
postgres no era superuser, no podía usar COPY
server-side). - Sociales (sociales.lat): la DB en
ash estaba stale — el rsync de deploy excluye
data/db.sqlite, así que el ash original solo tenía 3 events
viejos. La DB regenerada en koa-files (111 events + 5
artists) era superset, así que se reemplazó completa con backup previo
del ash. - Comedia (ndns.club): durante el corte el
runbook mencionaba “93 eventos promovidos” en el regenerado, pero al
volver ash tenía 135 draft / 4 live, igual que
koa-files. Los 93 promovidos nunca persistieron — rollback
silencioso por error en trigger de vault. Lección: el runbook no es
evidencia; verificar contra DB.
5. Caddy revert ordenado. Flip del symlink a
Caddyfile.normal (base = último Caddyfile pre-corte,
Caddyfile.bak-d16-20260602-2250) más 3 “ajolote-keeps”:
sociales.lat → 127.0.0.1:8204,
koalabs.ai → 127.0.0.1:8205,
koadro.com → 127.0.0.1:8212. Los tres son sitios donde
tiene sentido dejar la versión regenerada en koa-files (VPS
público estable) en lugar de migrar de vuelta. Validar siempre con
caddy validate --adapter caddyfile antes del restart.
Post-restart hay unos 2 minutos de timeouts transitorios (TLS warmup +
Tailscale path) — reintentar antes de asumir falla.
6. Resultado final: smoke check de 25/25 hosts públicos OK. Cero pérdida de data. RTO total: 5 días para el primary; 50 horas para tener 30 de 47 sitios regenerados.
Pendientes que el incidente expuso y siguen abiertos: pg_dump de
supabase-project (finanzas/todo/decisions/topics) sigue sin
backup formal; fix raíz del race Docker/Tailscale en ash;
rotación de placeholders en sociales,
lacartelera, ndns-menu; UPS físico para evitar
la próxima ronda. El ajolote regeneró bien; el primary también
sobrevivió bien; lo que falta es cerrar el gap entre los dos.
Cierre
50 horas sin luz en el depa. 47 sitios afectados. Pasé de 7 vivos a 30 vivos en una sesión nocturna usando un pattern que probé cinco veces y que ahora tiene nombre y runbook. Cinco días después la luz volvió y la data primary estaba intacta — lo que confirmó que el ajolote y el primary no son competidores, son capas.
El insight que me pareció no trivial es que la resiliencia no se compró con redundancia. Se compró con substrate sano. Mientras el workspace esté replicado, los snapshots restic estén frescos y Forgejo tenga su pg_dump diario, cualquier nodo es desechable. Los nodos son contenedores temporales del sistema. El sistema vive en otro plano.
El ajolote regenera porque su identidad no vive en la extremidad.