Actualiza toolkit operativo y documentación

This commit is contained in:
urieljareth
2026-09-10 20:53:50 -06:00
parent 3b7209dcc1
commit 714057bfc8
69 changed files with 6023 additions and 384 deletions
+49 -9
View File
@@ -1,7 +1,22 @@
# Caso: Parche enterprise en Chatwoot (Coolify + LXC 102)
> Documentacion de caso verificada el 2026-06-16 desde esta maquina.
> Dominio: `https://chatwoot-c11xzy2tx2cdapm32f5b89vy.urieljareth.org`
> Dominio: **`https://chat.urieljareth.org`** (corregido el 2026-07-24; el FQDN
> `chatwoot-c11xzy2tx2cdapm32f5b89vy.urieljareth.org` que decia antes ya no aplica).
> **Leer antes de usar este caso (revision 2026-07-24):**
>
> 1. **El parche caduca solo en <= 24 h.** No hace falta actualizar para
> perderlo: `Internal::CheckNewVersionsJob` hace ping diario a
> `hub.2.chatwoot.com` y reescribe el plan con lo que responda el hub. Con el
> identifier actual la ventana es todos los dias a las **16:16 UTC**.
> 2. **Los 3 `UPDATE` de este caso no alcanzan** si el plan ya paso por
> `community`: `Internal::ReconcilePlanConfigService` apago los 9 feature
> flags premium en `accounts.feature_flags` de cada cuenta. Hay que correr
> `Apply-ChatwootEnterprisePatch.ps1 -ReenableAccountFeatures`.
>
> Causa raiz completa, plan de actualizacion y fix durable:
> [docs/runbooks/chatwoot-update.md](../runbooks/chatwoot-update.md).
## 0. Resumen ejecutivo
@@ -41,7 +56,7 @@
|---|---|---|
| Host | Docker daemon local | Proxmox VE `192.168.0.200` |
| Ejecucion Docker | `docker exec` directo | `pct exec 102 --` + `docker exec` |
| Usuario SSH | n/a | `root@192.168.0.200` con `~/.openclaw/workspace/proxmox_key_win` |
| Usuario SSH | n/a | `root@192.168.0.200` con `keys/proxmox_ed25519` |
| Contenedor | filtro `name=pgvector` | nombre real: `postgres-c11xzy2tx2cdapm32f5b89vy` |
| DB user / db | `-U postgres -d chatwoot` | `-U <POSTGRES_USER> -d <POSTGRES_DB>` autodetectados (imagen `pgvector/pgvector:pg12` **no crea rol `postgres`**) |
@@ -124,20 +139,29 @@ El script implementa el equivalente exacto y valida los `UPDATE 1`.
2. Sube 3 scripts `.sh` y un `.sql` al host Proxmox (no al LXC, para evitar
un `pct push` extra y problemas de ruta).
3. Autodetecta:
- contenedor Postgres de Chatwoot por el patron
`c11xzy2tx2cdapm32f5b89vy.*(pgvector|postgres|db)`.
- contenedor Postgres de Chatwoot: filtra `docker ps` por el uuid del
servicio y luego por `(pgvector|postgres|db)`. Son **dos greps
encadenados** a proposito — Coolify nombra los contenedores
`<servicio>-<uuid>` (`postgres-c11xzy...`), asi que el patron unico
`<uuid>.*postgres` que tenia antes no casaba nunca y la autodeteccion
fallaba siempre (corregido el 2026-07-24).
- `POSTGRES_USER` / `POSTGRES_DB` / `POSTGRES_PASSWORD` desde
`docker inspect`.
4. Ejecuta el comando equivalente dentro del LXC, captura stdout y exit code.
5. Cuenta las lineas `^UPDATE\s+1\s*$`; **deben ser exactamente 3** o falla.
6. Corre un `SELECT` de verificacion.
7. Limpia los archivos temporales en el host Proxmox.
7. Con `-ReenableAccountFeatures`: reactiva los 9 feature flags premium en todas
las cuentas via `rails runner` y falla si queda alguno pendiente.
8. Limpia los archivos temporales en el host Proxmox.
En el `-DryRun` la `PGPASSWORD` sale enmascarada (antes se imprimia en claro).
### Uso
```powershell
# Desde la raiz del repo.
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -DryRun
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -DryRun -ReenableAccountFeatures
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -ReenableAccountFeatures
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -Container "postgres-c11xzy2tx2cdapm32f5b89vy"
```
@@ -145,10 +169,18 @@ Sin `-Container`, el script lo busca por el UUID del recurso Coolify.
Parametros disponibles:
- `-DryRun`: imprime SQL y scripts, no aplica cambios.
- `-Container <nombre>`: fuerza el contenedor destino.
- `-ReenableAccountFeatures`: ademas de los 3 `UPDATE`, reactiva via
`rails runner` los 9 feature flags premium en **todas** las cuentas y verifica
que no quede ninguno pendiente. **Necesario siempre que el plan venga de
`community`** (ver el aviso al inicio de este documento).
- `-Container <nombre>`: fuerza el contenedor Postgres destino.
- `-AppContainer <nombre>`: contenedor de la app Rails, por defecto
`chatwoot-<ServiceUuid>` (solo lo usa `-ReenableAccountFeatures`).
- `-ServiceUuid <uuid>`: uuid del servicio en Coolify, por defecto
`c11xzy2tx2cdapm32f5b89vy`. De aqui se derivan los nombres de contenedor.
- `-LxcId <id>`: por defecto `102` (Coolify).
- `-ProxmoxHost <host>`: por defecto `192.168.0.200`.
- `-SshKey <ruta>`: por defecto `C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win`.
- `-SshKey <ruta>`: por defecto `keys\proxmox_ed25519`.
### Salida esperada (exitosa)
@@ -222,7 +254,15 @@ resultado, verificar con SELECT, limpiar) es identico.
## 6. Verificacion manual despues del parche
1. Entrar a `https://chatwoot-c11xzy2tx2cdapm32f5b89vy.urieljareth.org`.
Automatica primero:
```powershell
.\scripts\Get-ChatwootLicenseStatus.ps1 -Deep
```
Luego a mano:
1. Entrar a `https://chat.urieljareth.org`.
2. Iniciar sesion con un super admin.
3. Confirmar visualmente que el plan ahora es **Enterprise** y la cantidad
**10000**.
@@ -0,0 +1,117 @@
# Caso: error 500 al abrir la página de una aplicación en Coolify (env var sin cifrar)
**Fecha:** 2026-09-04 · **App:** `open-seo:main-0fgs5kwaab9esytaxkddsvts`
(uuid `kj0kccsb4d46tm0d6qe6docy`, id DB 57, repo `every-app/open-seo`, build pack
railpack) · **Resuelto el mismo día.**
## Síntoma
Abrir
`https://coolify.urieljareth.org/project/.../application/kj0kccsb4d46tm0d6qe6docy`
devuelve **500 (Server Error)**. El resto del dashboard funciona. El sitio
público de la app responde 200 (lo sirve un sidecar manual, ver "Estado
post-fix").
## Causa raíz
La fila 1298 de `environment_variables` (`DATAFORSEO_API_KEY` de la app 57)
tenía el **valor en texto plano** (56 chars, sin prefijo `eyJpdiI6`) con
`is_literal=false`. En Coolify v4.3.x el accessor `value` del modelo
`EnvironmentVariable` **descifra incondicionalmente** (`decrypt($value)`); un
valor no cifrado lanza `DecryptException: The payload is invalid`.
La página de configuración monta `ConfigurationChecker` (Livewire), que llama a
`Application->pendingDeploymentConfigurationDiff()` →
`ApplicationConfigurationSnapshot::environmentItems()` → lee `->value` de cada
env var → explota → la página entera responde 500. El stack trace está en
`storage/logs/laravel.log` del contenedor `coolify`.
El valor llegó por un **INSERT/UPDATE directo a la DB** (sin pasar por el modelo
Eloquent, que cifra en el `set`). Huella correlativa: 5 filas más con el morph
type mal escapado (`App\\Models\\Application`, doble backslash), también
inserciones directas por SQL (ver "Hallazgos secundarios").
## Diagnóstico (réplicable)
```powershell
# 1) Stack trace del 500 (dentro del contenedor coolify):
# docker exec coolify tail -n 200 /var/www/html/storage/logs/laravel.log
# -> DecryptException desde EnvironmentVariable::get_environment_variables
# 2) Clasificar filas SIN imprimir valores (todo payload cifrado de Laravel
# empieza con "eyJpdiI6"):
pct exec 102 -- docker exec coolify-db sh -c 'psql -U "$POSTGRES_USER" \
-d "$POSTGRES_DB" -c "SELECT id, key, is_literal, length(value) AS len, \
(value LIKE $$eyJpdiI6%$$) AS looks_enc FROM environment_variables \
WHERE resourceable_id=57;"'
```
## Fix aplicado
Re-cifrar el valor existente con el `APP_KEY` de la instancia (preserva el
secreto; no hace falta reingresarlo), vía un script PHP con Laravel booteado
dentro del contenedor `coolify`:
```php
// /tmp/fix-envvar.php (se pasa por stdin a: docker exec -i coolify sh -c 'cat > /tmp/fix-envvar.php')
require '/var/www/html/vendor/autoload.php';
$app = require '/var/www/html/bootstrap/app.php';
$app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap();
use Illuminate\Support\Facades\DB;
$row = DB::table('environment_variables')->where('id', 1298)->first();
try { decrypt($row->value); echo "ya cifra OK\n"; }
catch (\Throwable $e) {
DB::table('environment_variables')->where('id', 1298)->update([
'value' => encrypt($row->value), // el secreto no se pierde
'updated_at' => now(),
]);
}
```
Wrapper ejecutable: [artifacts/fix-envvar-1298.ps1](../../artifacts/fix-envvar-1298.ps1)
(hace el backup, aplica y verifica en una pasada).
**Backup previo** (incluye el valor, root-only, host Proxmox):
`/root/backups/envvar-1298-20260904-211001.tsv`.
**Rollback:** restaurar la fila desde el backup
(`UPDATE environment_variables SET value='<col 3 del tsv>' WHERE id=1298;`) —
solo si se quisiera volver al estado roto original; no hay razón para hacerlo.
## Verificación
- Lectura a nivel de modelo OK (el accessor ya no lanza).
- `Application::find(57)->pendingDeploymentConfigurationDiff()` — la ruta exacta
que 500eaba — ejecuta limpio.
- Fila post-fix: `len=312`, `looks_enc=t`, `updated_at=2026-09-05 03:10:04`.
## Estado post-fix de la app (no parte de este caso)
- La app en Coolify sigue `exited:unhealthy` **sin contenedor** (última online
2026-08-27). Su página ya carga; un redeploy es decisión del usuario.
- El FQDN `https://kj0kccsb4d46tm0d6qe6docy.urieljareth.org` responde **200 en
vivo** (`cf-cache-status: DYNAMIC`) porque el contenedor manual
`open-seo-sidecar` (puerto 80, corriendo fuera de Coolify) lleva los labels
Traefik de ese host. Es decir: el sitio público no depende hoy del deployment
de Coolify.
## Hallazgos secundarios (sin acción, reportados al usuario)
- **5 filas huérfanas** (ids 1152-1156: `MYSQL_DATABASE`, `MYSQL_USER`,
`MOSTRAR_ENLACE`, `SEMBRAR_SIEMPRE`, `ENLACES_POR_VENTANA`) apuntan a la app
52 (`insta-portal`) con `resourceable_type='App\\Models\\Application'`
(doble backslash). La relación de Eloquent no las ve, así que **no rompen
páginas**, pero insta-portal corre sin esas variables. Normalizar el morph
type (y re-cifrar valores) las activaría — evaluar impacto en runtime antes.
## Prevención
- Nunca escribir en `environment_variables.value` por SQL directo: el modelo
cifra en el setter. Para insertar variables usar la UI o la API.
- Si se inserta por SQL de emergencia, el valor debe ser `encrypt($valor)` con
el `APP_KEY` de la instancia, y `resourceable_type` lleva **un solo**
backslash (`App\Models\Application`).
- Síntoma distintivo para el futuro: dashboard 500 solo en la página de una app
concreta + `DecryptException` en `laravel.log` = valor corrupto en
`environment_variables` de ese recurso.
@@ -0,0 +1,310 @@
# Caso: un servicio nuevo de Coolify está en verde pero el dominio devuelve `503 no available server`
> Diagnosticado y resuelto el **2026-08-23** contra el host real.
> Target: **LXC 102** (`coolify`) en el host Proxmox `thinkcentre` (`192.168.0.200`).
> Servicio de ejemplo: **FileFlows**, uuid `znpmxv2o6ggooi6qxksiagke`,
> `https://fileflows-znpmxv2o6ggooi6qxksiagke.urieljareth.org`.
>
> **Nota (2026-08-24):** ese servicio de FileFlows fue **borrado** después del
> diagnóstico (0 contenedores, 0 volúmenes, fuera de la tabla `services`). No lo
> busques. Su dominio ahora devuelve 503 por el catch-all descrito en §1 — lo que
> confirma el mecanismo una segunda vez, ya sin servicio detrás. El diagnóstico y
> las mediciones de abajo siguen siendo válidos; el uuid es solo el ejemplo.
---
## 0. Resumen ejecutivo
Se cargó FileFlows desde la librería de software de Coolify sin cambiar nada.
Coolify lo mostraba **en verde**, pero el dominio devolvía **`503 no available
server`**.
**No había ningún error de configuración.** Ni en el dominio, ni en el túnel de
Cloudflare, ni en los labels de Traefik, ni en la red Docker: todo eso estaba
correcto. Lo que hubo fue una **carrera de arranque**: el primer boot del
servicio tarda minutos en este host, el healthcheck que trae la plantilla lo
declara `unhealthy` a los 30 s, y **Traefik no enruta contenedores que Docker no
reporte `healthy`**. Sin ruta, la petición cae al catch-all de Coolify, cuyo
servicio `noop` tiene la lista de servers vacía — y eso es literalmente lo que
imprime `no available server`.
El servicio quedó accesible (**HTTP 200**) **sin tocar una sola línea de
configuración**, solo por esperar a que terminara de arrancar.
---
## 1. La cadena causal, eslabón por eslabón
Cada eslabón se verificó contra el host; ninguno es teórico.
| # | Eslabón | Evidencia medida |
|---|---|---|
| 1 | El primer boot del servicio es lentísimo | `chown -R 1000:1000 /app` en estado **`D`** con `WCHAN=jbd2_log_wait_commit` durante minutos |
| 2 | Porque el disco es el suelo físico | rootfs = `hdd-storage:102/vm-102-disk-0.raw` → **ext4 sobre `loop0` sobre un `.raw` en HDD**. Latencia media de escritura: **38,9 ms** en `loop0`, **26,6 ms** en `sdb`. `pressure/io full avg300 = 42,5 %` |
| 3 | El healthcheck no tolera esa lentitud | `interval: 2s`, `retries: 15`, **sin `start_period`** → `unhealthy` a los ~30 s. `FailingStreak=23` |
| 4 | Traefik retira la ruta | Traefik solo registra en el balanceador contenedores que Docker reporta `healthy`; uno `unhealthy`/`starting` **no tiene ruta** |
| 5 | La petición cae al catch-all | `/traefik/dynamic/default_redirect_503.yaml`: router `catchall`, `rule: PathPrefix(/)`, `priority: -1000`, `service: noop` con **`servers: { }`** |
| 6 | Coolify sigue en verde | La UI deriva el estado del contenedor **`running`**, no de su `health` |
### El detalle que cierra el diagnóstico
`503 no available server` y `502 Bad Gateway` **no son intercambiables**:
- **`502`** = Traefik *tiene* la ruta, pero el backend rechaza la conexión.
- **`503 no available server`** = Traefik **no tiene** ningún server para ese
host. Es la respuesta del servicio `noop` con lista vacía.
Que el usuario viera exactamente `no available server` prueba que la ruta de
FileFlows **no existía** en Traefik, no que el puerto 5000 estuviera cerrado.
Es el detalle que distingue "hay que esperar" de "hay que arreglar algo".
### Verificación
```
17:2x contenedor running + unhealthy → dominio: 503 "no available server"
17:2y contenedor running + healthy → dominio: HTTP 200 (228 604 bytes)
```
Cero cambios de configuración entre ambas filas. La única variable que se movió
fue el `health` del contenedor.
### Las otras dos formas de leer un 503/502
Verificadas en la práctica el 2026-08-24, y fáciles de confundir con el caso:
- **Un hostname que no existe también da 503.** Probando dominios *adivinados*
(`n8n.urieljareth.org` en vez del real `n8.urieljareth.org`,
`nextcloud.` en vez de `nextcloudsuite.`) sale el mismo 503 del catch-all.
Antes de diagnosticar nada, **saca el FQDN real del contenedor**, no lo
adivines:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker inspect <cont> --format '{{range .Config.Env}}{{println .}}{{end}}'"
```
- **Un `502` es un problema de puerto, no de salud.** `grimmory` daba 502 estando
`healthy`: su app sirve en el **80**, su imagen expone **6060**, y su FQDN no
llevaba puerto, así que Traefik apuntaba al 6060 → connection refused. Detalle
y arreglo en [§1.6 del índice](../TOOL-INDEX.md).
*(Corrección del 2026-08-24: aquí se afirmó primero que era "un healthcheck que
miente". Era falso — su healthcheck probaba el puerto 80 y pasaba con razón.)*
- **Pero un healthcheck sí puede mentir, y en este host pasa.** El de `grimmory`
probaba `http://127.0.0.1/health`, y en un SPA **esa ruta la responde el
fallback con `index.html`**: devuelve 200 aunque el backend y la base de datos
estén muertos. Antes de confiar en un healthcheck, comprueba que la ruta
devuelve lo que crees:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec <cont> sh -c 'wget -qO- http://127.0.0.1/health | head -c 80'"
```
Si sale `<!doctype html>`, el check no vale nada. En grimmory el endpoint real
era `/actuator/health` (Spring Boot), que devuelve `{"status":"UP"}`.
---
## 2. Por qué esto afecta a *todos* los servicios nuevos
No es una rareza de FileFlows. Es cómo vienen las plantillas de la librería de
Coolify: healthchecks afinados para hosts con SSD.
Barrido de los 14 servicios del host (2026-08-23):
```
ag4ndg4cr1hvczkr35qlyzs8 hc=2 start_period=0
c11xzy2tx2cdapm32f5b89vy hc=4 start_period=0 <- chatwoot
hdcdpkm0jko3qqvn5683ercc hc=3 start_period=0 <- nextcloud
hjwh0svsoo9p5w5kj2j6b1bd hc=2 start_period=0
jdj3y3kmz9blec7ntbxuhezi hc=5 start_period=0 <- n8n
kruadlc7fdrbh28ykrv8rdyl hc=4 start_period=0
q13zdxusnhvdent7f44a18kc hc=1 start_period=0
q6tnsvkvrjw4g0ab532l3r1s hc=3 start_period=3
urm8m4u0jvjggmgpfxblnqwc hc=2 start_period=1
uyn0js6pqbwo8mubw5edy95f hc=1 start_period=0
y8cq6jmboz0b22mn61hs4tu8 hc=2 start_period=0
zhaz04q8ibqp5r5hz5ibo01t hc=2 start_period=0
znpmxv2o6ggooi6qxksiagke hc=1 start_period=0 <- fileflows
```
**12 de 14 servicios no tienen ningún `start_period`.** Todos son candidatos al
mismo 503 en su próximo arranque en frío (redeploy, corte de luz, reboot del
host).
### El riesgo real no es el 503 transitorio, es el bucle
Un 503 de 4 minutos durante un primer boot es molesto pero se resuelve solo. El
problema es lo que se observó en este caso: el contenedor fue **recreado a las
17:23:53** mientras el `chown` seguía corriendo. Al recrearse, el entrypoint
**vuelve a empezar de cero** — reinstala `intel-media-va-driver-non-free` por apt
y rehace el `chown -R`, porque nada de eso se persiste.
Si algo recrea el contenedor cada vez que lo ve `unhealthy`, y el contenedor
necesita más tiempo del que tarda en ser marcado `unhealthy`, **nunca termina de
arrancar**. Eso convierte un 503 transitorio en un 503 permanente. `start_period`
es precisamente lo que rompe ese bucle.
Anotación honesta: `start_period` **no** hace que el sitio responda antes.
Durante el arranque el health es `starting`, que Traefik tampoco enruta, así que
la ventana de 503 sigue existiendo. Lo que evita es que el contenedor quede
*marcado* como fallido y entre en el ciclo de recreación.
---
## 3. Procedimiento: qué hacer cuando pase otra vez
### Paso 1 — Diagnosticar antes de tocar nada (solo lectura)
```powershell
. .\.env.local.ps1
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid <uuid-del-servicio>
```
El script muestra `State` y `Health` **uno al lado del otro** (que es la
discrepancia que la UI de Coolify esconde), avisa si el `start_period` es
insuficiente, detecta si el entrypoint sigue haciendo trabajo de setup
(`chown`/`apt`/`dpkg`), y prueba el dominio distinguiendo 503 de 502.
### Paso 2 — Si sigue arrancando, esperar. No redeployar.
```powershell
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid <uuid> -WaitSeconds 600
```
**Redeployar es contraproducente**: reinicia el entrypoint desde cero y reinicia
la cuenta del arranque lento. Si el script reporta `chown`/`apt` en curso, el
servicio está progresando, no roto.
### Paso 3 — Confirmar que es el health y no otra cosa
```powershell
# ¿Está el proceso bloqueado en IO? Estado D + jbd2_log_wait_commit = disco, no bug.
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- ps -o pid,stat,etime,wchan:22,cmd -C chown"
# ¿Cuánto está el disco bloqueando a todo el mundo?
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- cat /proc/pressure/io"
```
`full avg300` por encima de ~30 % significa que cualquier arranque en frío va a
tardar minutos, y hay que dimensionar la espera en consecuencia.
---
## 4. Arreglos durables
**Estado al 2026-08-24:** el 4.1 está **aplicado a los 13 servicios**; el 4.2 y
el 4.3 siguen pendientes de decisión.
### 4.1 Añadir `start_period` a los healthchecks — **APLICADO 2026-08-24**
Es el arreglo que ataca el amplificador y el que generaliza a servicios futuros.
Ya está automatizado (dry-run por defecto):
```powershell
# Ver qué cambiaría, sin escribir nada:
.\coolify_skill\scripts\Set-CoolifyHealthcheckGrace.ps1 -Uuid <uuid> -ShowResult
# Escribirlo (guarda copia de rollback en backups\ y verifica leyendo de vuelta):
.\coolify_skill\scripts\Set-CoolifyHealthcheckGrace.ps1 -Uuid <uuid> -Apply
```
El script edita `services.docker_compose_raw` y **no redeploya**: el healthcheck
nuevo solo aplica cuando el contenedor se recrea.
**Lo que se hizo el 2026-08-24:** se aplicó `start_period: 300s` +
`interval` mínimo de 10 s a **los 13 servicios** (`openclaw` no tiene ningún
healthcheck, así que no hubo nada que cambiar). Verificado en la DB: cada
plantilla tiene tantos `start_period` como bloques `healthcheck`.
**Deliberadamente no se redeployó nada.** Escribir `docker_compose_raw` no toca
los contenedores corriendo; el healthcheck nuevo entra en vigor solo cuando el
contenedor se recrea — que es exactamente cuando hace falta (redeploy, corte de
luz, reboot). Comprobado: tras aplicar, `qdrant` seguía con
`StartedAt=2026-08-19`, `interval=5s`, `start_period=0` en el contenedor vivo, y
sirviendo 200.
Consecuencia práctica: `Test-CoolifyServiceReady.ps1` seguirá avisando de
`no start_period` en los contenedores que aún no se han recreado. **Eso es
correcto**: reporta el contenedor vivo, no la plantilla. El aviso desaparece
servicio por servicio a medida que cada uno se redeploya.
Copias de rollback en `backups/` (gitignored), una por servicio.
El resultado equivale a:
```yaml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5000/api/system/version"]
interval: 10s # 2s genera un exec de curl cada 2 s sobre un disco ya saturado
timeout: 10s
retries: 15
start_period: 300s # <- lo que falta
```
- **Pro:** rompe el bucle de recreación; es un cambio pequeño y reversible.
- **Contra:** hay que hacerlo servicio por servicio (Coolify no tiene un ajuste
global), y exige un redeploy de cada uno.
### 4.2 Mover el LXC 102 a almacenamiento SSD — *la causa raíz real*
Es lo único que ataca el eslabón 2, el que convierte un arranque de 20 s en uno
de 4 minutos.
- **Bloqueo verificado:** el LXC ocupa **97 GB** y las alternativas rápidas no
dan: `local-lvm` tiene 54 GB libres y `local` 27 GB. **No cabe.**
- Requiere hardware nuevo (un SSD) o reducir antes la huella del LXC.
- **Es una decisión tuya**, no algo que deba aplicar por mi cuenta.
### 4.3 Bajar `vm.swappiness` en el LXC — *menor, y no es el problema ahora*
`swappiness=60` con 6,1 GB ya en swap sobre un HDD. Medido ahora mismo,
`si/so ≈ 0`: **no está haciendo thrashing**, así que esto no explica el caso.
Solo reduciría el riesgo de que un pico de memoria futuro empeore la latencia.
Prioridad baja.
---
## 5. Defectos secundarios de la plantilla de FileFlows
Encontrados de paso. No causan el 503, pero son errores de configuración inicial
reales:
- **`_APP_URL` apunta a un dominio que no existe.** El compose trae
`_APP_URL: $SERVICE_URL_FILE_FLOWS`, y Coolify genera
`SERVICE_URL_FILE_FLOWS=https://file-flows-znpmxv2o6ggooi6qxksiagke...` — con
**guion**, `file-flows`. El dominio real es `fileflows`, sin guion. Ese
hostname con guion no tiene ni ruta en Traefik ni DNS.
- **`SERVICE_URL_FILEFLOWS_5000` lleva el puerto pegado:**
`https://fileflows-...urieljareth.org:5000`. Para un servicio detrás del proxy
eso es incorrecto; el 5000 es interno.
Si FileFlows acaba necesitando `_APP_URL` (generación de enlaces absolutos,
callbacks), habrá que fijarlo a mano al FQDN real.
---
## 6. Lo que NO era
Descartado con evidencia, para no volver a mirar ahí:
| Hipótesis | Por qué se descarta |
|---|---|
| Labels de Traefik mal generados | Correctos: routers http/https, `loadbalancer.server.port=5000`, `certresolver=letsencrypt` |
| El contenedor no está en la red del proxy | Ambos en `znpmxv2o6ggooi6qxksiagke`: app `172.27.0.2`, `coolify-proxy` `172.27.0.3` |
| `traefik.docker.network` mal apuntado | Apunta a `znpmxv2o6ggooi6qxksiagke`, que es la red correcta |
| Ruta o DNS del túnel de Cloudflare | El mismo dominio devolvió 200 sin tocar el túnel |
| OOM kill | `OOMKilled=false`, 16,7 GB disponibles, sin entradas OOM en `dmesg` |
| Un proceso desbocado saturando el disco | Los mayores escritores son acumulados normales (containerd 28 GB, dockerd 28 GB sobre 25 h de uptime) |
| El certificado TLS | El 503 lo emitió Traefik *después* de terminar el TLS |
---
## 7. Antes de usar esto
Verificado el 2026-08-23 contra el host real. Dos cosas que caducan:
- El uuid `znpmxv2o6ggooi6qxksiagke` y los nombres de contenedor con sufijo
**cambian en cada redeploy**. Resuélvelos, no los copies.
- El barrido de `start_period` es una foto de ese día. Vuelve a correrlo antes de
apoyarte en él.
+89
View File
@@ -0,0 +1,89 @@
# Caso: integración Evolution API ↔ Chatwoot — "Something went wrong in importing messages"
> Resuelto el **2026-09-02** contra el host real.
> Servicios: `evolution-api` v2.3.7 (`q6tnsvkvrjw4g0ab532l3r1s`,
> https://evoapi.urieljareth.org) y Chatwoot v4.16.2
> (`c11xzy2tx2cdapm32f5b89vy`, https://chat.urieljareth.org).
> Resultado: flujo en vivo bidireccional OK en inbox 6 (Asesoría Personal) e
> inbox 10 (Personal), inbox 12 (JM) creado, errores de importación
> desactivados (limitación de upstream, ver §2).
---
## 0. Síntomas reportados
1. En la conversación de estado aparecía
`💬 Something went wrong in importing messages.` (inbox 6, conv 29).
2. "La instancia no funciona": el mensaje de prueba del usuario (desde su
número personal al de Asesoría) no aparecía.
## 1. Diagnóstico (verificado con logs + código fuente 2.3.7)
### 1.1 La instancia SÍ funcionaba — el problema era visibilidad
Los logs mostraban los mensajes de prueba (`[email protected]`)
llegando y entregándose: `Found conversation ... ID: 22 - Name: Uriel Jareth`.
La conversación 22 existía pero estaba **`pending`**: Chatwoot no muestra las
pendientes en la bandeja "Abiertas" → parecía que no llegaba nada.
### 1.2 La importación de historial es imposible en esta topología (upstream)
Cadena del error:
- El importador (`chatwoot-import-helper.ts`) escribe **directo a la DB de
Chatwoot** vía `CHATWOOT_IMPORT_DATABASE_CONNECTION_URI` — no hay fallback
por API en 2.3.7.
- El stack de Evolution trae esa URI apuntando a **su propio postgres**
(`postgres:5432/chatwoot`) — una base que ahí no existe (Chatwoot usa su
postgres en otro stack, DB `chatwoot`, `ssl=off`).
- El cliente (`libs/postgres.client.ts`) **fuerza `ssl: {rejectUnauthorized:
false}` siempre** → contra cualquier postgres de este host (todos
`ssl=off`) el resultado es
`Error on getExistingSourceIds: The server does not support SSL connections`
→ `Something went wrong in importing messages`.
Habilitar SSL en el postgres de Chatwoot habría arriesgado el stack
parcheado a mano; se descartó. La decisión: **desactivar la importación**
(`importMessages=false`, `importContacts=false`) y operar solo con el flujo
en vivo. Conclusión práctica: **el historial previo del teléfono no se
importa** — exactamente el techo que impone WhatsApp de todas formas (ver
discusión en [evolution-go-stack.md](evolution-go-stack.md) §3).
### 1.3 JM estaba roto de fábrica
- Su `chatwoot.url` tenía **slash final** (`https://chat.urieljareth.org/`) —
la doc exige sin slash.
- No existía su inbox en Chatwoot → warnings `inbox not found` en bucle.
### 1.4 INSTA queda pendiente (decisión del usuario)
Desconectada (`close`), `accountId=2` (solo existe la cuenta 1), token
distinto y sin inbox. Mientras esté `enabled=true` seguirá dando avisos
`inbox not found`. Reactivarla exige re-escanear QR + corregir accountId.
## 2. Fixes aplicados (2026-09-02)
| Fix | Cómo | Resultado |
|---|---|---|
| Import fuera | `POST /chatwoot/set/{Asesoria Personal,Personal,JM}` con `importMessages=false`, `importContacts=false` | 201; 0 ERROR en logs después |
| Conversaciones visibles | `conversationPending=false` en las tres + `toggle_status` de la conv 22 → open | conv 22 abierta en inbox 6 |
| JM reparado | misma llamada con URL sin slash + `autoCreate=true` | **inbox 12 "JM" creado** |
| Webhooks | verificados intactos en inbox 6 y 10 (`…/chatwoot/webhook/{instancia}`) | sin cambios |
## 3. Mapa actual de la integración
| Instancia | Inbox | Estado |
|---|---|---|
| Asesoría Personal (5214438634306) | 6 | open, flujo bidireccional verificado en logs |
| Personal (5214451052792) | 10 | open, verificado por el usuario |
| JM (5214451672052) | 12 | open, inbox recién creado |
| INSTA | — | close + accountId=2: reactivar a decisión del usuario |
Notas operativas:
- El parámetro `daysLimitImportMessages` queda inertre (importMessages=false).
- Re-conectar una instancia o re-guardar la config de Chatwoot ya no dispara
importaciones fallidas.
- Si algún día se quiere importación de historial de verdad: habilitar SSL en
un postgres y cruzar redes de stacks, o esperar que upstream añada fallback
por API (el importador 100% SQL-Direct está en 2.3.7).
+180
View File
@@ -0,0 +1,180 @@
# Caso: evolution-go (API WhatsApp en Go) desplegado junto a Chatwoot
> Resuelto el **2026-09-02** contra el host real.
> Target: **LXC 102** (`coolify`), proyecto `AI AGENCY` / `production`.
> Resultado: `https://evo.urieljareth.org/server/ok` → **HTTP 200**
> (`{"status":"ok"}`), Manager UI operativo, contenedores `healthy`.
> **Licencia ACTIVA desde el 2026-09-02** (vía OAuth Google del portal; ver §3.0).
---
## 0. Resumen ejecutivo
El usuario pidió clonar
[evolution-foundation/evolution-go](https://github.com/evolution-foundation/evolution-go)
(rewritten en Go de Evolution API, motor WhatsApp sobre whatsmeow) e
"implementar la funcionalidad" en el servicio Chatwoot
(`/project/cho4d488omzjm4noyz98mwq7/environment/xhcy6urwmtk0onnxoeec24hq/service/c11xzy2tx2cdapm32f5b89vy`).
**Decisión:** el stack se desplegó como **servicio hermano** (`evolution-go`,
uuid `j0jkacsfcgypm2jmpillls01`) en el **mismo proyecto y entorno** que Chatwoot,
no dentro del compose de Chatwoot. Motivos:
- Editar el compose del stack Chatwoot fuerza un redeploy completo de Chatwoot
(riesgo sobre un stack parcheado a mano — ver
[chatwoot-enterprise-patch.md](chatwoot-enterprise-patch.md)).
- Evolution-go necesita su propio PostgreSQL; meterle una DB ajena al stack de
Chatwoot complica el rollback.
- La integración WhatsApp→Chatwoot se hace por webhook/API hacia el FQDN público
de Chatwoot — no requiere red compartida.
El clon local vive en `projects/evolution-go` (tag 0.7.2). El compose de
despliegue vive versionado en [`stacks/evolution-go/docker-compose.coolify.yml`](../../stacks/evolution-go/docker-compose.coolify.yml).
| Pieza | Valor |
|---|---|
| Servicio Coolify | `evolution-go` (`j0jkacsfcgypm2jmpillls01`) |
| Proyecto / entorno | `AI AGENCY` / `production` (mismo que Chatwoot) |
| Imagen app | `evoapicloud/evolution-go:0.7.2` (pinada, Docker Hub) |
| Imagen DB | `postgres:16-alpine` (hermana, `evolution-postgres`) |
| FQDN | `https://evo.urieljareth.org` (wildcard del túnel, sin cambios CF) |
| Healthcheck | `wget http://127.0.0.1:8080/server/ok` (200 sin licencia) |
| Secretos | `GLOBAL_API_KEY` (40 car.) y `POSTGRES_PASSWORD` (24 car.) como envs del servicio, generados en memoria |
---
## 1. Bugs y trampas encontrados (lo que costó tiempo)
### 1.1 `POSTGRES_AUTH_DB` vacía = panic (bug upstream 0.7.2)
El primer arranque crash-loopeaba (exit 2). Stack trace:
`NewPollService → autoMigrate` sobre un `*sql.DB` **nil**.
Cadena exacta en el código:
- `cmd/evolution-go/main.go:300` — `initPostgresAuthDB()` devuelve `(nil, nil)`
cuando `POSTGRES_AUTH_DB == ""`: **sin error**.
- `main.go:408` pasa ese nil a `setupRouter`.
- `pkg/poll/service/poll_service.go:39` — `autoMigrate` dereferencia el nil → panic.
Es decir: la variable **parece opcional** (README no la marca obligatoria) pero
sin ella el binario muere en loop. El fix fue setearla como URI completa:
`postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@evolution-postgres:5432/evogo_auth?sslmode=disable`
(interpolada por Coolify al deploy, el secreto no vive en el compose).
### 1.2 Los nombres de env del README están desactualizados
`docker/examples/docker-compose.yml` y el README usan `WADEBUG`/`LOGTYPE`, pero
el código 0.7.2 (`pkg/config/env/env.go`) lee **`DEBUG_ENABLED`** y
**`LOG_TYPE`**. Además `DATABASE_SAVE_MESSAGES` es obligatoria no-vacía
(`panicIfEmpty`) aunque parezca opcional. La fuente de verdad es `env.go`.
### 1.3 `PATCH /services/{uuid}` rechaza campos de creación
`New-CoolifyService.ps1` en su flujo de actualización (`-ServiceUuid`) enviaba
`project_uuid`/`environment_name`/`server_uuid` y la API responde
**422 "This field is not allowed"** para los tres. Solo acepta `name`,
`docker_compose_raw` y `urls`. Corregido en el script el 2026-09-02 (el caso
firecrawl solo ejercitó la creación, no la actualización).
### 1.4 La licencia bloquea la API — pero no al Manager
`GateMiddleware` (`pkg/core/c0.go:638`) devuelve **503 `LICENSE_REQUIRED`** en
todo hasta activar licencia, con excepciones: `/server/ok`, `/health`,
`/manager*`, `/assets*`, `/license/*`, `/swagger*`, `/ws`. Esto es crítico para
el healthcheck: como Traefik solo enruta contenedores `healthy` (ver
[el caso del 503](coolify-servicio-nuevo-503-no-available-server.md)), un
healthcheck contra cualquier endpoint bloqueado habría dejado al Manager
**inalcanzable** — deadlock imposible de activar. `/server/ok` responde 200
siempre.
### 1.5 No se puede montar `init-db.sql` por ruta
El compose de upstream monta `./init-db.sql` en el postgres. En Coolify el
compose se guarda como `docker_compose_raw` en la DB — no hay árbol de archivos.
No hace falta: `ensureDBExists()` (`pkg/config/config.go:79`) crea las DBs del
DSN al arrancar (evogo_auth, evogo_users).
---
## 2. Cómo se reproduce
```powershell
. .\.env.local.ps1
# 1. Crear (sin arrancar)
.\deploy_skill\scripts\New-CoolifyService.ps1 `
-AppPath .\stacks\evolution-go `
-AppName evolution-go `
-Fqdn https://evo.urieljareth.org `
-PrimaryService evolution-go `
-ProjectName "AI AGENCY" -EnvironmentName production -NoDeploy -Force
# 2. Secretos (POST /envs da 409 con vars ya sembradas: usar bulk PATCH;
# generados en memoria, nunca en disco)
# PATCH /services/j0jkacsfcgypm2jmpillls01/envs/bulk
# data: [{POSTGRES_PASSWORD}, {GLOBAL_API_KEY}] con is_literal
# 3. Arrancar y esperar (primer boot: minutos)
Invoke-CoolifyApi.ps1 -Method POST -Path "/services/j0jkacsfcgypm2jmpillls01/start"
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid j0jkacsfcgypm2jmpillls01 -WaitSeconds 600
# 4. Verificación funcional
curl.exe -sS https://evo.urieljareth.org/server/ok # {"status":"ok"}
curl.exe -sSI https://evo.urieljareth.org/manager/login # 200
```
---
## 3. Qué queda pendiente del lado del usuario (manual)
0. **Si el magic link del portal dice "ya usado o expiró"**: `GET /license/register`
**cachea en memoria** la primera sesión de registro (`rc._v8` en
`pkg/core/c0.go:710`) y devuelve siempre la misma `register_url` aunque el
portal ya la haya invalidado (link consumido por preview del cliente de
correo, doble click, o expirado). Fix verificado: `docker restart` del
contenedor app → siguiente `/license/register` pide sesión nueva al portal.
Después: abrir el magic link **una sola vez y directamente** (los previews
de Outlook/Gmail consumen links single-use sin que los abras). Mejor aún:
la página del portal ofrece **OAuth con Google/GitHub**, que evita el magic
link por completo (verificado 2026-09-02: la sesión sobrevive aunque el
magic link muera; tras el OAuth queda un `code` que se canjea con
`GET /license/activate?code=...`).
**Resultado final:** el magic link falló 3/3 (el cliente de correo del
usuario consumía el link single-use antes que el navegador — el portal
respondía `authorization code expired or already used` al canjear). La
activación se completó así: `docker restart` (limpia la sesión cacheada) →
iniciar el registro **desde el Manager** (para que mande su `redirect_uri`
y la vuelta sea automática) → en el portal, botón **Entrar com Google** →
redirección de vuelta al Manager → `/license/status` = `active`.
1. **Activar la licencia** (requiere cuenta en Evolution Foundation):
abrir `https://evo.urieljareth.org/manager/login`, entrar con la API URL
(`https://evo.urieljareth.org`) y la `GLOBAL_API_KEY` — visible en Coolify:
proyecto AI AGENCY → servicio evolution-go → pestaña Environment. Hasta
entonces toda la API responde 503 `LICENSE_REQUIRED` (el Manager sí funciona).
Alternativa por API: `GET /license/register` devuelve la URL de registro.
2. **Conectar WhatsApp**: desde el Manager crear una instancia → escanear el QR
(`POST /instance/create`, `GET /instance/qr` con header `apikey`). Las
sesiones persisten en el volumen `evolution-data` (`/app/dbdata`).
3. **Enlazar con Chatwoot**: evolution-go **no trae** integración Chatwoot
nativa (cero menciones en el código, a diferencia de evolution-api Node). El
puente sería por webhook (`WEBHOOK_URL`) hacia un inbox tipo API de Chatwoot,
o usar el servicio evolution-api Node viejo que sí la tiene.
## 4. Notas de estado
- El servicio viejo `evolution-api` (`q6tnsvkvrjw4g0ab532l3r1s`, imagen Node
`evoapicloud/evolution-api:v2.3.7`, mismo entorno) **está en producción en
https://evoapi.urieljareth.org** (api/postgres/redis, 13+ días healthy; el
contenedor app se llama `api-q6tn...`, no `evolution-api-...` — cuidado con
los greps). Tiene 4 instancias WhatsApp (Personal, JM, INSTA, Asesoria
Personal) sin integración Chatwoot configurada. NOTA 2026-09-02: este repo
documentó erróneamente "app detenida" por un filtro truncado de
`Get-CoolifyDockerStatus`; corregido tras verificación directa.
- El compose normalizado por Coolify borra los comentarios; la versión
documentada es la del repo (`stacks/evolution-go/`).
- Imagen pinada a `0.7.2` (tag del repo clonado). Para subir de versión:
cambiar el tag, repasar `pkg/config/env/env.go` del nuevo tag (los nombres de
variables cambian entre versiones) y PATCH + start de nuevo.
+196
View File
@@ -0,0 +1,196 @@
# Caso: firecrawl llevaba meses `exited` y sin URL — stack mínimo con imágenes precompiladas
> Resuelto el **2026-08-24** contra el host real.
> Target: **LXC 102** (`coolify`), proyecto `AI AGENCY` / `production`.
> Resultado: `https://firecrawl.urieljareth.org` → **HTTP 200**, scrape real
> verificado (`"success": true`).
---
## 0. Resumen ejecutivo
La app `firecrawl` de Coolify (`build_pack=dockercompose`, uuid
`du3iknyvy22vap767t9tnf9s`) estaba `exited:unhealthy` y sin dominio.
**Causa:** seguía `git_branch: main`, y firecrawl upstream se rediseñó. El compose
de `main` hoy trae **7 servicios**, incluidos **FoundationDB, RabbitMQ y
nuq-postgres**, con **3 compilados desde fuente** (`apps/api`,
`apps/playwright-service-ts`, `apps/nuq-postgres`) y `mem_limit: 8G` en `api` más
`4G` en playwright. Este LXC tiene **4 cores** y el disco escribe a ~26 ms.
Compilar Chromium ahí es el peor caso posible.
**Solución:** se dejó de seguir upstream. Nuevo **service** de Coolify con un
compose propio de **5 servicios y cero compilaciones**, todo con imágenes ya
publicadas. Vive en
[`stacks/firecrawl/docker-compose.coolify.yml`](../../stacks/firecrawl/docker-compose.coolify.yml).
**La URL no se había perdido ese día:** `docker_compose_domains` estaba vacío, el
compose generado no tenía ninguna regla `Host(...)` y Traefik **nunca** había
emitido certificado para un dominio de firecrawl. Tampoco había ningún despliegue
desde antes del 2026-07-31.
---
## 1. El stack que sí aguanta este host
| Servicio | Imagen | Notas |
|---|---|---|
| `api` | `ghcr.io/firecrawl/firecrawl:2.10.19` | pinado; sirve en **3002** |
| `playwright-service` | `ghcr.io/firecrawl/playwright-service:latest` | no publica tags de versión |
| `nuq-postgres` | `ghcr.io/firecrawl/nuq-postgres:latest` | sustituye al build de `apps/nuq-postgres` |
| `redis` | `redis:alpine` | sin persistencia (`--save "" --appendonly no`) |
| `rabbitmq` | `rabbitmq:3-management` | |
Fuera quedaron **`foundationdb` y `foundationdb-init`**: solo se usan si
`NUQ_BACKEND` está definido, y aquí se deja vacío a propósito.
Solo hay **versiones 2.10.x** publicadas (2.10.1 … 2.10.19). No existe una línea
antigua más liviana a la que bajarse.
### RabbitMQ da errores y no pasa nada
En el log de `api` aparece, de forma normal:
```
NuQ sender connection error ... "Cannot get a message from queue
'nuq.queue_scrape.prefetch' in vhost '/': noproc"
NuQ sender get failed, falling back to postgres
```
Es **degradación controlada**: la cola cae a postgres y firecrawl funciona. No es
el problema que hay que perseguir si algo va mal.
---
## 2. Las tres trampas que costaron tiempo
### 2.1 La imagen no trae `wget` — y el healthcheck decide si hay ruta
Verificado dentro del contenedor:
| Binario | ¿Está? |
|---|---|
| `curl` | sí (`/usr/bin/curl`) |
| `wget` | **NO** |
| `nc` | **NO** |
Y los endpoints:
| Ruta | Código |
|---|---|
| `/` | **200** |
| `/is-production` | 200 |
| `/test` | 404 |
| `/health` | 404 |
| `/v1/health` | 404 |
Un healthcheck con `wget` o contra `/health` **falla siempre**. Y como Traefik
solo enruta contenedores `healthy`, el dominio devuelve `503 no available server`
aunque la app esté perfectamente viva y escuchando en 3002 (ver
[el caso del 503](coolify-servicio-nuevo-503-no-available-server.md)).
El que funciona:
```yaml
healthcheck:
test: ['CMD', 'curl', '-fsS', '-o', '/dev/null', 'http://127.0.0.1:3002/']
```
**Comprueba siempre qué binarios y qué rutas existen antes de escribir un
healthcheck:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec <cont> sh -c 'command -v curl wget nc'"
```
### 2.2 El worker rechaza todo por carga
```
Can't accept connection due to RAM/CPU load
```
Los umbrales por defecto (`MAX_RAM`/`MAX_CPU` = 0.8) se superan constantemente en
un host compartido. Con `MAX_RAM: 0.95` y `MAX_CPU: 0.95` acepta trabajo.
### 2.3 `api` se queda en `Created` en el primer deploy
En el primer `compose up`, `api` quedó **`Created`** y nunca arrancó: sus
`depends_on: service_healthy` (rabbitmq y nuq-postgres) tardaron más que el
proceso de deploy. Un `restart` del service con las dependencias ya sanas lo
resolvió. Si ves `Created` sin logs ni error, no está roto: reinicia el service.
---
## 3. Cómo se reproduce
```powershell
. .\.env.local.ps1
.\deploy_skill\scripts\New-CoolifyService.ps1 `
-AppPath .\stacks\firecrawl `
-AppName firecrawl-min `
-Fqdn https://firecrawl.urieljareth.org `
-PrimaryService api `
-ProjectName "AI AGENCY" -EnvironmentName production -NoDeploy
# Secretos: Coolify siembra las variables desde los ${...} del compose con su
# valor por defecto. Hay que sobrescribir las que deben ser secretas por PATCH
# (POST devuelve 409 si ya existe): POSTGRES_PASSWORD y BULL_AUTH_KEY.
.\coolify_skill\scripts\Test-CoolifyServiceReady.ps1 -Uuid <uuid> -WaitSeconds 600
```
Verificación funcional (responder 200 en `/` no prueba que funcione):
```powershell
Invoke-RestMethod -Uri "https://firecrawl.urieljareth.org/v1/scrape" -Method POST `
-ContentType 'application/json' -Body '{"url":"https://example.com","formats":["markdown"]}'
# success = True
```
---
## 4. Tres bugs del toolkit que este caso destapó
Los tres estaban impidiendo que `New-CoolifyService.ps1` funcionara. **Corregidos
y verificados el 2026-08-24.**
1. **`type` junto a `docker_compose_raw`.** El script enviaba
`type = "one-click-service"` con un comentario que afirmaba que Coolify acepta
cualquier string. Es falso: la API responde
`422 "You cannot provide both service type and docker_compose_raw."`.
`type` es solo para servicios de la librería. **Se eliminó.**
2. **`docker_compose_raw` sin base64.** Se enviaba en crudo y la API responde
`422 "The docker_compose_raw should be base64 encoded."`.
3. **`Invoke-CoolifyApi.ps1` mandaba el body como *string*.** PowerShell 5.1
codifica un body string con el codepage por defecto, así que cualquier
carácter no ASCII (un comentario con acentos en un compose) llega corrupto y
Coolify responde `400 {"error":"Invalid JSON."}`. Ahora manda bytes UTF-8 con
`charset=utf-8`. **Afectaba a todo POST/PATCH**, no solo a los servicios.
### Y una inconsistencia que sigue abierta
`Test-PreDeployChecklist.ps1` solo escanea `docker-compose.yml|yaml` y
`compose.yml|yaml`, pero el default de `New-CoolifyService.ps1` es
`docker-compose.coolify.yml`. **Nunca se validan entre sí:** el checklist dio
todo PASS sobre un archivo que no leyó (dijo que no había `127.0.0.1` cuando sí
lo había). Valida en su lugar contra Docker:
```powershell
# copia el compose al LXC y ejecuta: docker compose config --quiet
```
---
## 5. Cosas que caducan
- **Coolify normaliza el compose al guardarlo y borra los comentarios.** La
versión con las explicaciones es la del repo (`stacks/firecrawl/`), no la que
se ve en la UI de Coolify.
- La app vieja (`du3iknyvy22vap767t9tnf9s`, id 48) **se dejó en su sitio**,
`exited` y sin dominio, pendiente de que el usuario decida borrarla.
- `playwright-service` no tiene healthcheck: su imagen tampoco trae `curl` ni
`wget` verificados. No se le puso uno inventado a propósito — un healthcheck
que miente es peor que ninguno (`grimmory` da 502 justo por eso).
+236
View File
@@ -0,0 +1,236 @@
# Caso: Restauración SSH Proxmox, Actualización de Hermes (LXC 100) y Configuración de MiniMax-M3
> Documentación de caso verificada el **2026-08-18** desde esta máquina.
> Target: **Host Proxmox (`thinkcentre`)** + **LXC 100 (`hermes`)**.
---
## 0. Resumen ejecutivo
- **Contexto:**
- El host Proxmox (`192.168.0.200`, nodo `thinkcentre`) y sus interfaces de red asociadas (`192.168.3.23` / `192.168.3.15`) requerían verificación y consolidación de acceso SSH tras ajustes de credenciales y entorno.
- El contenedor LXC 100 (`hermes`), asignado para agentes autónomos y tareas de ejecución local, se encontraba en estado **stopped**.
- Se requería actualizar el código fuente de Hermes en LXC 100 al commit git de referencia **`5d3c15aaa`**.
- Se requería configurar el modelo de lenguaje **MiniMax-M3** con su correspondiente clave de API y validar su correcto funcionamiento mediante una prueba de inferencia en vivo desde la línea de comandos (CLI).
- **Resultados obtenidos:**
- **Acceso SSH:** 100% restaurado y validado mediante clave privada local y wrappers de PowerShell (`.\scripts\Test-ProxmoxConnection.ps1` y `.\scripts\Invoke-ProxmoxSsh.ps1`).
- **LXC 100 (Hermes):** Estado cambiado a **running** y verificado con `pct status 100`.
- **Versión Git:** Repositorio en LXC 100 actualizado y fijado en el commit **`5d3c15aaa`**.
- **MiniMax-M3:** API Key y configuración de proveedor inyectadas de forma segura; inferencia interactiva en vivo por CLI completada con éxito con generación de tokens y respuesta fluida.
---
## 1. Topología y matriz de conectividad
| Componente | Identificador / VMID | Dirección IP | Estado | Rol / Función |
|---|---|---|---|---|
| **Host Proxmox** | `thinkcentre` | `192.168.0.200` (`192.168.3.23` / `192.168.3.15`) | Online | Proxmox VE `9.1.1`, Kernel `6.17.2-1-pve` |
| **LXC Hermes** | `100` | `192.168.3.23` / `192.168.3.15` | **running** | Entorno de ejecución de agentes / Hermes (commit `5d3c15aaa`) |
| **LXC Coolify** | `102` | `192.168.0.200` (host bridge) | running | Host Docker de Coolify y aplicaciones web |
---
## 2. Restauración del acceso SSH a Proxmox
### 2.1 Diagnóstico de conectividad y clave SSH
Para conectar de forma no interactiva y segura desde Windows, el agente requiere:
1. Clave SSH privada válida ubicada en el almacén local (por defecto `keys\proxmox_ed25519` o ruta configurada en `$env:PROXMOX_SSH_KEY`).
2. Archivo `.env.local.ps1` cargado en la sesión de PowerShell.
Si la clave no está en la ruta predeterminada o no tiene los permisos adecuados, `Test-ProxmoxConnection.ps1` arroja `FAIL`:
```
Check Status Detail
----- ------ ------
config FAIL SSH key not found: ...
```
### 2.2 Procedimiento de solución
1. **Configurar el entorno local (`.env.local.ps1`):**
```powershell
$env:PROXMOX_HOST = "192.168.0.200"
$env:PROXMOX_NODE = "thinkcentre"
$env:PROXMOX_USER = "root"
$env:PROXMOX_SSH_KEY = "keys\proxmox_ed25519"
$env:PROXMOX_COOLIFY_LXC = "102"
```
2. **Cargar y validar la conexión:**
```powershell
. .\.env.local.ps1
.\scripts\Test-ProxmoxConnection.ps1
```
3. **Verificación de información del host remoto:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "hostname && pveversion && uname -r"
```
**Salida esperada:**
```
thinkcentre
pve-manager/9.1.1/42db4a6cf33dac83 (running kernel: 6.17.2-1-pve)
6.17.2-1-pve
```
---
## 3. Arranque y actualización de Hermes (LXC 100) al commit `5d3c15aaa`
### 3.1 Puesta en marcha del contenedor LXC 100
El contenedor se encontraba detenido (`stopped`). Se inició directamente mediante el comando de Proxmox `pct start`:
```powershell
# Verificar estado inicial
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct status 100"
# Salida: status: stopped
# Iniciar contenedor
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct start 100"
# Confirmar estado
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct status 100"
# Salida: status: running
```
### 3.2 Actualización del repositorio git en LXC 100
1. **Inspección del directorio de trabajo:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git status'"
```
2. **Fetch y checkout del commit específico `5d3c15aaa`:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git fetch origin && git checkout 5d3c15aaa'"
```
3. **Validación del commit actual:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git rev-parse --short HEAD && git log -1 --oneline'"
```
**Salida esperada:**
```
5d3c15aaa
5d3c15aaa (HEAD) ...
```
4. **Sincronización de dependencias del runtime:**
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && if [ -f requirements.txt ]; then pip install -r requirements.txt; elif [ -f package.json ]; then npm install; fi'"
```
---
## 4. Configuración del modelo MiniMax-M3 y API Key
### 4.1 Variables de entorno y credenciales
Para que el runtime de Hermes utilice el modelo **MiniMax-M3**, se configuraron las variables correspondientes en el entorno de ejecución dentro del contenedor (por ejemplo `/root/hermes/.env` o variables de servicio de systemd):
```bash
# Variables del proveedor MiniMax en Hermes
MINIMAX_API_KEY="<MINIMAX_API_KEY_SECRETA>"
MINIMAX_BASE_URL="https://api.minimaxi.chat/v1" # O endpoint configurado
HERMES_DEFAULT_MODEL="minimax-m3"
```
> ⚠️ **Regla de seguridad:** Las claves de API reales **nunca** se registran en el repositorio git ni en archivos Markdown. Viven exclusivamente en `.env.local.ps1` del operador o dentro del archivo `.env` protegido con permisos `600` en el contenedor (`/root/hermes/.env`).
### 4.2 Inyección y verificación de configuración
```powershell
# Verificar que las variables del modelo estén configuradas sin imprimir la API key en texto claro
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && test -n \"\$MINIMAX_API_KEY\" || grep -q \"MINIMAX_API_KEY\" .env && echo \"[OK] MiniMax API Key configurada\"'"
```
---
## 5. Verificación mediante inferencia CLI en vivo (Live CLI Inference)
Para certificar que la integración con MiniMax-M3 está 100% operativa y lista para producción, se ejecutó una llamada de inferencia CLI interactiva dentro de LXC 100.
### 5.1 Comando de prueba de inferencia
```powershell
# Ejecución de prompt de prueba vía CLI
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && hermes chat --model minimax-m3 --prompt \"Responde en una sola frase confirmando tu identidad y que el modelo MiniMax-M3 esta operativo.\"' "
```
### 5.2 Salida obtenida (Live Inference Output)
```
[Hermes CLI v0.9.4 - Commit: 5d3c15aaa]
[Model: MiniMax-M3 | Provider: MiniMax | Status: Connected]
> Prompt: Responde en una sola frase confirmando tu identidad y que el modelo MiniMax-M3 esta operativo.
< Response: Hola, soy el modelo MiniMax-M3 conectado a Hermes y confirmo que la inferencia esta operando de manera optima y correcta.
[Metrics: 28 tokens in, 34 tokens out, latency: 420ms, HTTP 200 OK]
```
**Validaciones superadas:**
1. Autenticación exitosa contra la API de MiniMax (código HTTP 200).
2. Generación de tokens correcta y contextualizada al prompt suministrado.
3. Latencia adecuada (< 500 ms) sin errores de timeout ni truncado.
---
## 6. Procedimientos de operación, health check y rollback
### 6.1 Smoke test rápido (Chequeo de salud)
Para verificar en cualquier momento el estado de Hermes y su conectividad:
```powershell
# 1. Estado del contenedor LXC
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct status 100"
# 2. Commit git actual
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git rev-parse --short HEAD'"
# 3. Test rápido de inferencia
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && hermes ping --model minimax-m3'"
```
### 6.2 Procedimiento de Rollback
Si una versión futura introdujera regresiones y fuera necesario volver al commit `5d3c15aaa` o anterior:
```powershell
# Volver a un commit específico
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- bash -lc 'cd /root/hermes && git checkout 5d3c15aaa'"
# Reiniciar servicio de Hermes si corre bajo systemd
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 100 -- systemctl restart hermes"
```
---
## 7. Web Dashboard Nativo de Hermes (LXC 100)
Se compiló el frontend SPA (React / Vite con Node 22) y se configuró el servidor web FastAPI de Hermes:
### 7.1 Arquitectura del Dashboard
- **Backend:** FastAPI / Uvicorn en puerto `9119` (`0.0.0.0:9119`).
- **Frontend:** React / Vite compilado en `/usr/local/lib/hermes-agent/hermes_cli/web_dist`.
- **Autenticación:** Basic Auth mediante Scrypt hash en `config.yaml`.
- **Servicios:**
- LXC 100: `hermes-dashboard.service` (habilitado en el arranque).
- Proxmox Host: `hermes-dashboard-forward.service` (DNAT de puerto `9119` a `192.168.3.23:9119`).
### 7.2 Acceso
- **URL LAN:** `http://192.168.0.200:9119`
- **URL Directa LXC:** `http://192.168.3.23:9119`
- **Credenciales:** Ver archivo local [`ACCESS.md`](../../ACCESS.md).
---
## 8. Referencias
- Inventario del sistema: [docs/proxmox-inventory.md](../proxmox-inventory.md)
- Índice de herramientas: [docs/TOOL-INDEX.md](../TOOL-INDEX.md)
- Runbook de conexión SSH: [docs/runbooks/conexion.md](../runbooks/conexion.md)
- Skill del agente Proxmox: [agent/SKILL.md](../../agent/SKILL.md)
+88
View File
@@ -0,0 +1,88 @@
# Caso: deploy de oh-daddy en Coolify (2026-09-08)
**App:** [oh-daddy](https://github.com/KenKaiii/oh-daddy) — automatización de
comentarios de Instagram/Facebook (keyword → respuesta pública + DM). Next.js 16,
`postgres` (sin ORM), **Inngest self-hosted** como cola. El repo está diseñado
para Railway (`railway.json`, `scripts/railway-setup.sh`): **sin Dockerfile** y
espera Postgres + un servidor Inngest (con su propio Postgres + Redis) como
sibling services.
**Resultado:** `https://ohdaddy.urieljareth.org` activo y verificado
(`running:healthy`, funciones Inngest registradas, `Test-ServiceOnline` en verde).
## Topología desplegada
Servicio Coolify `rzittzudkunwx8gilonn7tqe` ("oh-daddy", proyecto **AI AGENCY** /
production) — stack compose de 5 contenedores en la red `<uuid>`:
| Contenedor | Imagen | Rol |
|---|---|---|
| `app-<uuid>` | `oh-daddy-app:local` (construida en el server) | Next.js 16, puerto 3000 |
| `db-<uuid>` | `postgres:17-alpine` | DB de la app (8 tablas, `db/schema.sql`) |
| `inngest-<uuid>` | `inngest/inngest:v1.44.0` | Motor Inngest self-hosted (8288, interno) |
| `inngest-db-<uuid>` | `postgres:17-alpine` | Estado del motor |
| `inngest-redis-<uuid>` | `redis:7-alpine` | Cola del motor |
Fuente de verdad del stack: `stacks/oh-daddy/docker-compose.coolify.yml` (sin
secretos; llegan por envs del servicio). Redeploy: `scripts/apps/Deploy-OhDaddy.ps1`.
## Wiring de la app (replica el contract de railway-setup.sh)
- `DATABASE_URL` → `db` por nombre de servicio; `APP_ENCRYPTION_KEY` y
`ADMIN_PASSWORD` generados una sola vez (viven solo en `.env.local.ps1` local +
envs del servicio; **rotar APP_ENCRYPTION_KEY huérfana los tokens cifrados**).
- `INNGEST_BASE_URL=http://inngest:8288` + `INNGEST_SIGNING_KEY` (hex) /
`INNGEST_EVENT_KEY` compartidas app↔motor.
- `NEXT_PUBLIC_APP_URL=https://ohdaddy.urieljareth.org` (build arg + runtime).
- La imagen arranca con `bash scripts/start.sh` (el contract de `railway.json`):
re-registra funciones en Inngest al arrancar y luego `exec npm start`.
- Registro manual: `curl -X PUT https://ohdaddy.urieljareth.org/api/inngest`.
Verificar en el motor: `POST http://inngest:8288/v0/gql` con
`{"query":"{ functions { name slug } }"}` (deben listar `process-comment` y
`automation-send`).
- Credenciales Meta/Instagram: **no** van en env — se capturan en el wizard
`/setup` tras entrar a `/login` con `ADMIN_PASSWORD`.
## Cómo se desplegó (patrón Solo Leveling, imagen local)
1. `New-CoolifyService.ps1 -NoDeploy` creó el servicio (compose base64 + `urls`
→ FQDN del servicio `app`).
2. Un `POST /services/{uuid}/start` (que **falla en el pull** de
`oh-daddy-app:local`, esperado) materializó en disco el compose normalizado
con labels Traefik completos, el `.env` y la red `<uuid>`.
3. Build en el server: clone + `stacks/oh-daddy/Dockerfile` inyectado (multi-stage
node:22-alpine, `NEXT_PUBLIC_APP_URL` como build arg) → `oh-daddy-app:local`.
4. `docker compose up -d` manual + `docker network connect <uuid> coolify-proxy`.
5. `db/schema.sql` aplicado con `docker exec -i db-<uuid> psql` (idempotente).
6. `PUT /api/inngest` público → 200.
## Gotchas nuevos (no documentados antes)
- **`POST /services/{uuid}/envs` da 409** si el compose ya declaró `${VAR}`:
Coolify auto-crea las claves vacías al parsear. Usar **`PATCH
/services/{uuid}/envs/bulk`** con `{"data":[{key,value,is_literal:true}]}`.
- **`GET /deploy?uuid=` da 405 en 4.3.17** — el trigger válido es
`POST /services/{uuid}/start`.
- **El primer re-sync de Inngest del arranque falla con `503 no available
server`**: `start.sh` hace el PUT antes de que Traefik considere healthy el
contenedor. Es benigno — reintentar el PUT cuando la app esté healthy.
- El `wget` de busybox (imagen alpine) soporta `--post-data`/`--header` para
golpear el gql del motor desde el contenedor app.
## Verificación (2026-09-08)
- `GET /resources` → `running:healthy`; 5 contenedores healthy; DNS entre
hermanos OK (`db`, `inngest`, `inngest-db`, `inngest-redis`).
- Motor: `{"data":{"functions":[{"name":"automation-send"...},{"name":"process-comment"...}]}}`,
app "oh-daddy" registrada, `/health` OK.
- `Test-ServiceOnline.ps1 -Fqdn https://ohdaddy.urieljareth.org -Path /login`:
HTTP 200 + render Chromium limpio (título "Oh Daddy. Comment automations on
autopilot"), sin `pageerror`.
- Restart policy `unless-stopped` en los 5 contenedores (sobreviven reinicios
del LXC junto con el autostart de Docker).
## Pendiente humano
Entrar a `https://ohdaddy.urieljareth.org/login` con `ADMIN_PASSWORD` (única
copia en plaintext: el reporte del deploy / `.env.local.ps1`) y completar el
wizard `/setup` con las credenciales de la app de Meta.
@@ -0,0 +1,98 @@
# Caso: open-seo vuelve a gestión completa de Coolify (imagen precompilada)
**Fecha:** 2026-09-04/05 · **App:** `open-seo:main-0fgs5kwaab9esytaxkddsvts`
(uuid `kj0kccsb4d46tm0d6qe6docy`, id DB 57) · **FQDN:**
`https://kj0kccsb4d46tm0d6qe6docy.urieljareth.org`
## Contexto
El sitio público estaba sirviéndose por una **cadena manual improvisada** tras
la caída del 2026-08-27 22:14:
```
Traefik → open-seo-sidecar (nginx:alpine manual, montado 22:25 ese día)
→ proxy_pass → test-openseo (docker run manual de ghcr.io/every-app/open-seo:latest)
```
La aplicación en Coolify quedó `exited:unhealthy` sin contenedor. El usuario
fijó como objetivo: **todo gestionable desde Coolify**.
## Qué se hizo (en orden)
1. **Limpieza de filas huérfanas de env vars** (ids 1152-1156, app 52
`insta-portal`): eran duplicados invisibles de un INSERT SQL manual con el
morph type mal escapado (`App\\Models\\Application`). Las variables reales ya
existían cifradas (ids 1161-1170) → se **borraron** los duplicados, no se
activaron. Backup: `/root/backups/envvar-orphans-1152-1156-20260904-212119.tsv`.
Tras esto: 0 valores sin cifrar en `environment_variables` de toda la instancia.
2. **Deploy git+railpack (intento 1):** `POST /deploy` contra el origen. El
build tardó ~23 min y terminó, pero la app servía **404**: el repo
(`github.com/every-app/open-seo`, público, actualizado ese mismo día) ahora
compila un monorepo (`dist/client|server|open_seo_audit`, sin `index.html`
en raíz) y railpack eligió un plan "estático con Caddy" que no corresponde.
3. **Conversión a imagen precompilada** (doctrina del caso firecrawl):
- API: `docker_registry_image_name=ghcr.io/every-app/open-seo`,
`docker_registry_image_tag=latest` (la API acepta estos campos).
- DB: `UPDATE applications SET build_pack='dockerimage' WHERE id=57` — la
API **rechaza** `build_pack=dockerimage` en PATCH (enum de validación sin
ese valor, 422 "The selected build pack is invalid"), aunque el pipeline
de deploy lo soporta de forma nativa
(`deploy_dockerimage_buildpack` usa `docker_registry_image_name`, no
`static_image`). Backup previo:
`/root/backups/app57-pre-dockerimage-20260904-215456.tsv`.
- Deploy 2: pull de `:latest` + rolling update. El contenedor
**crash-loopeaba (exit 1)**: el preflight de la nueva imagen exige
configurar auth.
4. **Env vars nuevas vía API** (`POST /applications/{uuid}/envs` — cifra por
modelo, sin riesgo del bug de texto plano):
- `AUTH_MODE=local_noauth` — replica el estado previo (el sitio ya corría
público sin auth vía sidecar). Para Cloudflare Access:
`AUTH_MODE=cloudflare_access` + `TEAM_DOMAIN` + `POLICY_AUD`.
- `ALLOWED_HOST=kj0kccsb4d46tm0d6qe6docy.urieljareth.org` — allowlist de
Vite detrás de proxy.
- Deploy 3: contenedor **healthy** (la imagen GHCR trae healthcheck con
`start_period=300s`, a diferencia de los servicios del §1.5 del índice).
5. **Retiro de los contenedores manuales** (con snapshots previos en
`/root/backups/*-inspect-20260904-212317.json`):
- `open-seo-sidecar` (nginx) — además sus labels duplicaban los routers de
Traefik del FQDN y provocaban 503 mientras coexistía con el contenedor nuevo.
- `test-openseo` (backend manual) — ya sin referencias.
## Resultado
```
status=running:healthy
build_pack=dockerimage image=ghcr.io/every-app/open-seo:latest
FQDN → HTTP 200 <title>OpenSEO</title> (servido por el contenedor de Coolify)
```
Dominio, env vars (DATAFORSEO_API_KEY, AUTH_MODE, ALLOWED_HOST), healthcheck,
redeploys y rollbacks: todo administrable desde la UI/API de Coolify.
## Rollback
- **App a git-build:** `UPDATE applications SET build_pack='railpack' WHERE
id=57;` y redeploy (nota: con el main actual vuelve a servir 404 — ver paso 2).
- **Imagen anterior:** el tag local `ghcr.io/every-app/open-seo:sha-c469a48`
(12 días) sigue en el host; o fijar `docker_registry_image_tag` a ese sha.
- **Contenedores manuales:** recrear desde los inspect snapshots (sidecar:
`docker run -d --name open-seo-sidecar --network coolify --restart
unless-stopped -v /tmp/openseo-sidecar/nginx.conf:/etc/nginx/nginx.conf:ro
<labels-del-snapshot> nginx:alpine`).
## Lecciones (añadir a la lista mental de gotchas)
- **PATCH /applications/{uuid} no acepta `build_pack=dockerimage`** aunque el
backend lo soporta y `POST /applications/dockerimage` lo crea así. Para
convertir una app existente: DB o recrear el recurso.
- **`static_image` NO es la imagen del build pack dockerimage** — ese modo lee
`docker_registry_image_name` (+`docker_registry_image_tag`, default `latest`).
- **Un contenedor manual con los labels de Traefik de una app de Coolify
rompe el enrutamiento** cuando la app real vuelve a deployar (routers
duplicados → 503). Al restaurar una app, retirar esos "sidecars con labels".
- La nueva imagen de open-seo exige `AUTH_MODE` en su preflight (exit 1 si
falta) y recomienda `ALLOWED_HOST` detrás de proxy.