Agrega scripts y runbooks: Cloudflare API, autostart Coolify, parche Chatwoot, deploy Solo Leveling + docs de casos

- scripts/Invoke-CloudflareApi.ps1: control de tunnel/DNS via API
- scripts/Install-CoolifyAutostart.ps1 + scripts/host/: autostart de LXC 102 + tunnel tras corte
- scripts/Apply-ChatwootEnterprisePatch.ps1: parche enterprise (autodetecta creds)
- Deploy-SoloLeveling.ps1: deploy build-on-server verificado
- docs/runbooks/cloudflare-tunnel.md y autostart-coolify.md
- docs/casos/chatwoot-enterprise-patch.md (credenciales redactadas)
- docs/AGENTS-coolify-apps.md + issues y reportes
- deploy_skill/references/coolify-4.1.2-notes.md: hallazgos verificados (seccion 7)
- package.json para verify-online.mjs del coolify-deploy skill
- .gitignore: excluye .claude/ y .opencode/ (config local de herramientas)
This commit is contained in:
urieljareth
2026-07-18 11:31:31 -06:00
parent e7d1c33cd5
commit f587a8baa2
23 changed files with 2312 additions and 2 deletions
+7
View File
@@ -9,3 +9,10 @@ PROXMOX_COOLIFY_LXC=102
COOLIFY_API_URL=https://coolify.urieljareth.org/api/v1
COOLIFY_TOKEN=REPLACE_WITH_COOLIFY_TOKEN
# Used by deploy_skill (New-GitHubRepo.ps1, Invoke-GitHubApi.ps1) to create
# repos via the GitHub REST API. git push/pull uses Windows Credential Manager
# (wincred) and does NOT need this token in the URL.
# Generate at https://github.com/settings/tokens (classic 'repo' scope, or
# fine-grained with Contents:Read+Write and Metadata:Read).
GITHUB_TOKEN=REPLACE_WITH_GITHUB_PAT
+8
View File
@@ -12,3 +12,11 @@ ACCESS.md
__pycache__/
.DS_Store
Thumbs.db
# Node tooling for coolify-deploy skill (Test-ServiceOnline.ps1 + verify-online.mjs)
node_modules/
# Local agent/tool config (not part of the ops toolkit)
.claude/
.opencode/
+35 -1
View File
@@ -28,6 +28,7 @@ PowerShell script → Invoke-ProxmoxSshCommand (scripts/ProxmoxAgent.ps1)
- **Config resolution:** `Get-ProxmoxConfig` reads env vars (`PROXMOX_HOST`, `PROXMOX_NODE`, `PROXMOX_SSH_KEY`, `PROXMOX_COOLIFY_LXC`, etc.) and falls back to hardcoded local defaults. SSH works with defaults alone; **API calls require `PROXMOX_API_TOKEN_ID` + `PROXMOX_API_TOKEN_SECRET`** (otherwise `Invoke-ProxmoxApi` throws). The Coolify LXC ID (`102`) comes from config — don't hardcode it in new scripts; use `$config.CoolifyLxc`.
- Two independent APIs: the **Proxmox REST API** (via `curl.exe -k`, PVE token header) and the **Coolify API** (via `Invoke-RestMethod`, Bearer token, base `https://coolify.urieljareth.org/api/v1`). They use different scripts and different env vars.
- Docker is **not** managed on the Proxmox host directly — it lives inside LXC `102`. Any container command must be wrapped as `pct exec 102 -- docker ...`.
- **The deploy pipeline** ([deploy_skill/](deploy_skill/)) is the end-to-end path from "local project" to "live on `*.urieljareth.org`": scaffold compliant Dockerfile/compose → pre-deploy checklist → create GitHub repo → push → register + deploy via Coolify API → verify. It also covers rollback. Requires `GITHUB_TOKEN` + `COOLIFY_TOKEN` in `.env.local.ps1`; `git push` uses Windows Credential Manager (`wincred`), verified for the `urieljarethbusiness-cpu` GitHub account. See [deploy_skill/SKILL.md](deploy_skill/SKILL.md) and [docs/AGENTS-coolify-apps.md](docs/AGENTS-coolify-apps.md).
## Common commands
@@ -57,12 +58,42 @@ Invoke-ProxmoxApi -Path "/version"
# Coolify API (requires COOLIFY_TOKEN)
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/projects"
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Method POST -Path "/projects" -BodyJson $body
# Cloudflare API (requires CLOUDFLARE_API_TOKEN) — full tunnel/DNS control
.\scripts\Invoke-CloudflareApi.ps1 -Path "/user/tokens/verify"
```
For the Coolify API reference: don't read the whole tree. Search
`coolify_skill/references/` with `rg`, then open the single matching
`references/ops/*.md` operation file.
## Deploying a new project to Coolify
End-to-end pipeline (scaffold → checklist → GitHub repo → push → Coolify app +
deploy → verify). Requires `GITHUB_TOKEN` and `COOLIFY_TOKEN` in
`.env.local.ps1`. `git push` uses Windows Credential Manager, not the PAT.
```powershell
. .\.env.local.ps1
# Full pipeline on a local project
.\deploy_skill\scripts\Publish-ProjectToCoolify.ps1 `
-AppPath .\my-app -AppName my-app `
-Fqdn https://my-app.urieljareth.org `
-Stack node -AppPort 8080 -Init
# Or step-by-step
.\deploy_skill\scripts\Initialize-CoolifyProject.ps1 -Path .\my-app -Stack node
.\deploy_skill\scripts\Test-PreDeployChecklist.ps1 -Path .\my-app -Strict
.\deploy_skill\scripts\New-GitHubRepo.ps1 -Name my-app
.\deploy_skill\scripts\New-CoolifyApplication.ps1 -RepoUrl https://github.com/urieljarethbusiness-cpu/my-app.git `
-Fqdn https://my-app.urieljareth.org -PortsExposes 8080
.\deploy_skill\scripts\Test-PostDeploy.ps1 -Fqdn https://my-app.urieljareth.org -ContainerName my-app-main
# Rollback to a previous commit
.\deploy_skill\scripts\Invoke-CoolifyRollback.ps1 -AppPath .\my-app -ApplicationUuid <uuid> -CommitSha <sha>
```
## Operating rules (enforced by the skills — follow them)
- **Read-only first.** Default to diagnostics: list, status, logs, inspect, health checks.
@@ -81,7 +112,10 @@ root cause of "database connection" failures here — check with
## Where context lives
- [docs/proxmox-inventory.md](docs/proxmox-inventory.md) — verified topology, LXC list, observed containers, access model. Treat as the source of truth for current state.
- [docs/runbooks/](docs/runbooks/) — concrete procedures: `conexion.md`, `diagnostico.md`, `coolify-docker.md`, `seguridad.md`, plus app-specific `nextcloud.md` and `baserow.md`.
- [docs/runbooks/](docs/runbooks/) — concrete procedures: `conexion.md`, `diagnostico.md`, `coolify-docker.md`, `seguridad.md`, `cloudflare-tunnel.md`, `autostart-coolify.md` (power-outage auto-start of LXC 102 + tunnel), plus app-specific `nextcloud.md` and `baserow.md`.
- Cloudflare tunnel: the tunnel is **dashboard-managed** (ingress comes from the edge, see `INF Updated to new configuration version=N` in `cloudflared` logs) — fix routes in the dashboard or via [scripts/Invoke-CloudflareApi.ps1](scripts/Invoke-CloudflareApi.ps1), not by editing files on the host. Ports 6001/6002 must use `http://`. See [docs/runbooks/cloudflare-tunnel.md](docs/runbooks/cloudflare-tunnel.md).
- [agent/SKILL.md](agent/SKILL.md) + [agent/TOOLS.md](agent/TOOLS.md) — Proxmox agent operating skill.
- [coolify_skill/SKILL.md](coolify_skill/SKILL.md) + [coolify_skill/TOOLS.md](coolify_skill/TOOLS.md) — Coolify agent operating skill and API reference index.
- [docs/AGENTS-coolify-apps.md](docs/AGENTS-coolify-apps.md) — compatibility guide for agents **developing** an app to deploy on this Coolify instance (networking, ports, domains/TLS, env, volumes, healthchecks) + pre-deploy checklist. Source this when building a new app, not when operating an existing one.
- [deploy_skill/SKILL.md](deploy_skill/SKILL.md) + [deploy_skill/TOOLS.md](deploy_skill/TOOLS.md) — deploy pipeline skill (scaffold → push → Coolify API → verify → rollback).
- `PROXMOX/`, `proxmox-agent/`, `proxmox-skill/` are **legacy pointer folders** — they only redirect to the live docs above. Don't add content there.
+87
View File
@@ -0,0 +1,87 @@
# ===========================================================================
# Deploy-SoloLeveling.ps1
# Re-deploy de "El Sistema (Solo Leveling)" en Coolify (LXC 102) SIN depender
# del pull de GHCR (que es privado y sin token read:packages).
#
# Estrategia: construye la imagen DIRECTO en el server (clone del repo privado
# con el PAT scope=repo + docker build), la taguea como ghcr.io/.../solo-leveling:latest
# (el nombre que espera el compose generado por Coolify) y levanta el servicio.
# Como el compose NO declara pull_policy, docker compose up usa la imagen local.
#
# Pre-requisitos:
# - .env.local.ps1 con COOLIFY_*, PROXMOX_* y GITHUB_TOKEN (scope repo).
# - El service ya registrado en Coolify (compose + .env + dominio en su dir).
#
# Uso:
# . .\.env.local.ps1
# .\Deploy-SoloLeveling.ps1 # build + up + verificar
# .\Deploy-SoloLeveling.ps1 -NoBuild # solo up + verificar (imagen ya existe)
# ===========================================================================
[CmdletBinding()]
param(
[string]$ServiceUuid = 'urm8m4u0jvjggmgpfxblnqwc',
[string]$Domain = 'sl.urieljareth.org',
[string]$Repo = 'urieljarethbusiness-cpu/solo-leveling',
[string]$Image = 'ghcr.io/urieljarethbusiness-cpu/solo-leveling:latest',
[switch]$NoBuild
)
Set-Location 'H:\MegaSync\Proyectos\Proxmox & Coolify Manager'
. .\.env.local.ps1
$ErrorActionPreference = 'Stop'
if (-not $env:GITHUB_TOKEN) { throw "GITHUB_TOKEN falta en .env.local.ps1" }
# --- helper: ejecuta un .sh (string) dentro del LXC 102 via base64 (sin quote hell) ---
function Invoke-OnServer([string]$scriptBody, [int]$TimeoutSec = 600) {
$body = ($scriptBody -replace "`r`n","`n") -replace "`r","`n"
$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($body))
$cmd = "pct exec 102 -- sh -c 'echo $b64 | base64 -d | sh'"
& .\scripts\Invoke-ProxmoxSsh.ps1 -Command $cmd
}
# ---------------------------------------------------------------- 1. BUILD ---
if (-not $NoBuild) {
Write-Host "==> Construyendo imagen en el server (clone + docker build)..." -ForegroundColor Cyan
$build = @"
set -e
BUILD_DIR=/tmp/solo-build
rm -rf `"`$BUILD_DIR`" `"/tmp/solo-build.log`"
mkdir -p `"`$BUILD_DIR`"; cd `"`$BUILD_DIR`"
git clone --depth 1 https://x-access-token:$($env:GITHUB_TOKEN)@github.com/$Repo.git repo
cd repo
echo "HEAD: `$(git log -1 --oneline)"
docker build -t $Image .
echo "=== BUILT ==="
docker images $Image --format '{{.Repository}}:{{.Tag}} {{.ID}} {{.Size}}'
"@
Invoke-OnServer $build
}
# --------------------------------------------- 2. UP + CONECTAR PROXY ---
Write-Host "==> Levantando servicio y conectando proxy Traefik..." -ForegroundColor Cyan
$up = @"
set +e
SVC=/data/coolify/services/$ServiceUuid
cd "`$SVC" || { echo "NO_SVC_DIR"; exit 1; }
docker compose up -d
docker network connect $ServiceUuid coolify-proxy 2>&1 && echo "PROXY_CONNECTED" || echo "(proxy ya conectado)"
"@
Invoke-OnServer $up
# -------------------------------------------------- 3. ESPERAR + VERIFICAR ---
Write-Host "==> Esperando salud (migrate/seed + arranque Next)..." -ForegroundColor Cyan
Start-Sleep -Seconds 25
$verify = @"
set +e
echo "=== STATUS ==="
docker compose -f /data/coolify/services/$ServiceUuid/docker-compose.yml ps --format '{{.Name}} | {{.Status}}'
echo "=== HEALTH (local) ==="
docker exec app-$ServiceUuid wget -qO- http://127.0.0.1:3000/api/health 2>&1
echo ""
"@
Invoke-OnServer $verify
Write-Host "=== HEALTH (publico: https://$Domain) ===" -ForegroundColor Cyan
$code = & curl.exe -s -o NUL -w "%{http_code}" --max-time 30 "https://$Domain/api/health"
Write-Host ("PUBLIC_HEALTH=" + $code)
if ($code -eq '200') { Write-Host "OK: sitio activo en https://$Domain" -ForegroundColor Green }
else { Write-Host "AVISO: health publico no es 200 ($code). Revisar logs: docker logs app-$ServiceUuid" -ForegroundColor Yellow }
+6
View File
@@ -25,10 +25,16 @@ Verificado el 2026-05-30 desde esta maquina:
- `agent/TOOLS.md`: comandos seguros y patrones de uso.
- `coolify_skill/`: skill local para operar Coolify, Docker en LXC `102` y API
de Coolify sin guardar secretos.
- `deploy_skill/`: skill para deployar proyectos nuevos a Coolify de punta a
punta (scaffold Dockerfile/compose → checklist → repo GitHub → push → alta
via API → deploy → verificacion → rollback). Necesita `GITHUB_TOKEN` y
`COOLIFY_TOKEN` en `.env.local.ps1`.
- `docs/proxmox-inventory.md`: inventario verificado y notas de arquitectura.
- `docs/runbooks/`: procedimientos concretos para conexion, diagnostico y Coolify.
- `docs/runbooks/nextcloud.md`: recuperacion y fix HTTPS para Nextcloud.
- `docs/runbooks/baserow.md`: puesta en vivo de Baserow y fix red/Traefik.
- `docs/casos/`: casos verificados paso a paso (ej. `chatwoot-enterprise-patch.md`).
- `docs/AGENTS-coolify-apps.md`: guia para agentes/LLMs que desarrollan una app destinada a esta instancia de Coolify (reglas de red, puertos, dominios/TLS, env, volumenes, healthchecks) + checklist pre-deploy.
- `scripts/`: wrappers PowerShell para SSH, API e inventario.
- `PROXMOX/`, `proxmox-agent/`, `proxmox-skill/`: carpetas legacy que ahora apuntan a la documentacion viva.
+1 -1
View File
@@ -36,7 +36,7 @@ $request = @{
Method = $Method
Uri = $uri
Headers = $headers
TimeoutSec = 30
TimeoutSec = 240
}
if ($PSBoundParameters.ContainsKey("BodyJson")) {
@@ -101,3 +101,64 @@ FIX: read with `[System.IO.File]::ReadAllText($path)`.
4. `POST /applications/private-deploy-key` with `build_pack=dockercompose`,
`docker_compose_location`, `docker_compose_domains`, `ports_exposes`, `private_key_uuid`,
`[email protected]:owner/repo.git`. ← **404 on v4.1.2**; use the UI instead.
## 7. Verified insights — full-stack app + self-hosted Supabase (E3 Manager, 2026-07)
Deployed a Next.js 16 app + Node worker + a full self-hosted Supabase (Auth, PostgREST,
Realtime, Storage, Kong, Studio) end-to-end. New, reusable facts:
### 7.1 `POST /services` request contract (corrects `New-CoolifyService.ps1`)
- `docker_compose_raw` **must be base64-encoded** → else `422 {"docker_compose_raw":"should be base64 encoded"}`.
- **Do NOT send `type` together with `docker_compose_raw`** → else `422 "You cannot provide both service type and docker_compose_raw"`. For a custom compose service, OMIT `type` entirely (the script's `type="one-click-service"` is wrong for this path).
- Minimal working body: `{ name, project_uuid, environment_name, server_uuid, docker_compose_raw:<base64>, instant_deploy:false }`. Returns `{ uuid, domains }`. (`urls` optional; pin domains with your own Traefik labels or `SERVICE_FQDN_*`.)
### 7.2 Locally-built images → Coolify's deploy `pull`s and fails
- Coolify's service deploy runs `docker compose pull``pull access denied ... repository does not exist` for a local-only image tag. **Don't use Coolify's Deploy button/`/start` for local images.**
- Instead build the image on the server (clone repo + `docker build -t <tag>`), then `docker compose up -d` **manually** in `/data/coolify/services/<uuid>/` (default pull policy skips pull when the image exists locally). Same pattern as `Deploy-SoloLeveling.ps1`.
- Coolify's normalized on-disk compose (from your base64 raw) **preserves** your `image`, inlined `environment`, custom `labels` (incl. Traefik) and networks — but **renames containers to `<service>-<uuid>`**. Cross-container refs must use the *other* service's real name (unchanged), not the renamed one.
- The service dir + its per-service external network `<uuid>` are created only on Coolify's own deploy. For a first manual `up`, run `docker network create --attachable <uuid>` first (else `network <uuid> declared as external, but could not be found`). Put app containers on the external `coolify` network to reach other stacks (Traefik `coolify-proxy` is already on it).
### 7.3 `Invoke-GitHubApi.ps1` BOM bug
- It writes the JSON body with `Set-Content -Encoding utf8` → PS 5.1 prepends a **BOM** → GitHub `400 "Problems parsing JSON"`. Workaround: POST bodies via `[IO.File]::WriteAllText($tmp,$json)` (no BOM) + `curl.exe --data @tmp`, or fix the helper to use `WriteAllText`.
### 7.4 Self-hosted Supabase for apps that need more than Postgres
- Clone official `supabase/supabase` `docker/volumes` (sparse) for kong.yml + kong-entrypoint.sh + db init SQL; base the compose on the **master** `docker-compose.yml` (kong 3.9.1 uses a Lua entrypoint — keep the matching kong-entrypoint.sh). Trim `functions`/`supavisor`/`analytics`/`vector` if unneeded. Legacy symmetric keys (JWT_SECRET + anon/service_role JWTs) still work; leave publishable/secret empty.
- Route Kong (and Mailpit/Studio) via Traefik **labels + `coolify` network**; the wildcard `*.urieljareth.org` (Cloudflare→Traefik) means new subdomains 503 until a router matches, then work — no DNS change needed.
- **`supabase/postgres` init exceeds the compose healthcheck window** (many internal migrations, no `start_period`) → `db` flaps `unhealthy` and dependents abort. Just re-run `docker compose up -d` once `db` reports healthy.
- Apply the app's own SQL migrations **after** Auth/Storage have initialized their schemas (they need `auth.users`/`storage.buckets`); run as `postgres` via `docker exec -i supabase-db psql` (bypasses `request.jwt.claims`-gated triggers).
### 7.5 `@supabase/ssr` cookie-name gotcha (SSR apps)
- The session cookie name is `sb-<ref>-auth-token` where `ref = new URL(supabaseUrl).hostname.split('.')[0]`. If the **browser** uses the public URL (`demosupabase…``sb-demosupabase-…`) and the **server** uses a different internal URL (`http://kong:8000``sb-kong-…`), the middleware can't find the session and every login bounces back to `/login` (auth returns 200, but no redirect). Fix: server must use a URL whose first hostname label matches the browser's (simplest: use the same public URL server-side; or give the internal host a network alias equal to that first label).
### 7.6 Remote shell = dash, not bash
- `pct exec 102 -- sh -c '…'` runs under **dash**. No `${PIPESTATUS[0]}`, `[[ ]]`, arrays → "Bad substitution". A build can succeed while the wrapper script exits non-zero purely on a trailing bashism — check the actual artifact (image), not just the exit code.
### 7.7 Invoking these scripts HEADLESSLY (agent/CI) — never wrap them in `2>&1` (verified 2026-07, Solo Leveling re-deploy)
Every deploy script here sets `$ErrorActionPreference = "Stop"` and shells out to native
`git`/`ssh`/`docker` (via `Invoke-ProxmoxSsh.ps1`). Under an **outer** stderr redirect —
`& .\Deploy-*.ps1 2>&1 | …` or `*>&1` — PowerShell **5.1** turns each native-stderr line into a
`NativeCommandError` ErrorRecord; with `Stop` in effect the **first** one becomes terminating and
kills the script on its first line of benign progress (e.g. git `Cloning into 'repo'...`).
Symptom: exit 1 with the error anchored at `& ssh @sshArgs` in `ProxmoxAgent.ps1`, right after the
first `git`/`ssh` progress line — **before any real work fails**.
- **Fix:** invoke the script **plain**`& .\Deploy-SoloLeveling.ps1` (no `2>&1`/`*>&1`, no
merging pipe). The harness/terminal already captures the process's stderr at the OS level, which
does **not** create ErrorRecords. Same rule for `Publish-ProjectToCoolify.ps1`,
`New-CoolifyService.ps1`, `Invoke-CoolifyRollback.ps1`, `Test-ServiceOnline.ps1`.
- Need the tail in a file? Redirect **stdout only** (`1>`) or wrap with `Start-Transcript`/
`Tee-Object` on the success stream — never `2>&1` around a native-calling, `Stop`-mode script.
- Note the scripts' *internal* `git push 2>&1 | ForEach-Object {…}` is fine (scoped to one
statement); the trap is the **outer** redirect the caller adds.
### 7.8 build-on-server re-deploy: verification + rollback caveats (Solo Leveling, `Deploy-SoloLeveling.ps1`)
- **Confirm the shipped commit, not just health.** The build step echoes `HEAD: <sha> <subject>`
from the fresh clone — gate on it matching your intended commit, and confirm the app container
shows **`Recreated`** (not reused) in `docker compose up -d` output. Health 200 alone only proves
*something* is up (and Coolify's `/resources` status lags reality — see §4). Close with
`Test-ServiceOnline.ps1` (HTTP + real Chromium render) as the definition of done.
- **No instant image rollback on this path.** The build tags only `…/solo-leveling:latest` and
overwrites it every deploy, so there is no previous image to `docker` back to. Rollback =
re-run the build-on-server deploy with the repo checked out at the older SHA (slow: full rebuild).
If instant rollback matters, tag per-commit too (`:<sha>` alongside `:latest`) so you can retag
`:latest` to a prior digest and `docker compose up -d`. (The skill's `Invoke-CoolifyRollback.ps1`
assumes the git-build path, which is `/applications/*` = 404 here — it doesn't apply to build-on-server.)
@@ -0,0 +1,143 @@
# ISSUE: cloudflare-tunnel · routing · websocket-tls-handshake
**ID:** `cloudflare-tunnel_routing_websocket-tls-handshake`
**Tags:** `cloudflare-tunnel` `routing` `websocket` `tls` `coolify` `terminal` `realtime` `6001` `6002`
**Severidad:** Alta — terminal de Coolify inoperativa, websockets de la UI rotos
**Fecha:** 2026-04-11
---
## Síntoma
- La terminal dentro de Coolify muestra `Terminal websocket connection lost`
- La UI de Coolify se comporta de forma inestable (reconexiones constantes)
- Los logs de `cloudflared` muestran:
```
tls: first record does not look like a TLS handshake
originService=https://192.168.0.117:6001
tls: first record does not look like a TLS handshake
originService=https://192.168.0.117:6002
```
---
## Causa Raíz
Las rutas del Cloudflare Tunnel para los puertos 6001 (realtime/websocket) y 6002 (terminal/websocket) están configuradas con `https://` como prefijo del servicio de origen.
Estos puertos **no hablan HTTPS** — solo HTTP plano. Cloudflared intenta establecer un handshake TLS con un servicio que responde en texto plano, causando el error.
---
## Dónde vive la configuración (importante)
El túnel está **gestionado desde el dashboard de Cloudflare** (remotely-managed),
no por el `config.yml` local. El `/etc/cloudflared/config.yml` dentro del contenedor
solo tiene el UUID del túnel, las credenciales y el comodín `*.urieljareth.org`:
```yaml
tunnel: 5f0e5c5b-a180-46f2-a090-44d151006a62
credentials-file: /etc/cloudflared/credentials.json
ingress:
- hostname: "*.urieljareth.org"
service: https://192.168.0.117:443
- service: http_status:404
```
**Las rutas de `coolify.urieljareth.org` (incluidas 6001/6002) NO están en ese
archivo — vienen del dashboard/API.** La firma de esto en los logs es la línea
`INF Updated to new configuration version=N`. Consecuencia práctica: **este problema
no se arregla editando archivos en el servidor**, sino en el dashboard (rápido) o
por la API de Cloudflare (control total — ver runbook).
---
## Configuración Correcta del Tunnel (coolify-tunnel)
El orden importa: Cloudflare evalúa las rutas de **arriba hacia abajo**. Las rutas específicas deben ir ANTES del comodín.
| # | Dominio | Ruta | Servicio | Protocolo |
|---|---------|------|---------|-----------|
| 1 | `coolify.dominio.com` | `/build/*` | `http://192.168.0.117:8000` | HTTP |
| 2 | `coolify.dominio.com` | `/app/*` | `http://192.168.0.117:6001` | HTTP ⚠️ |
| 3 | `coolify.dominio.com` | `/terminal/ws*` | `http://192.168.0.117:6002` | HTTP ⚠️ |
| 4 | `coolify.dominio.com` | `*` | `http://192.168.0.117:8000` | HTTP |
| 5 | `*.dominio.com` | `*` | `https://192.168.0.117:443` | HTTPS |
### Reglas Críticas
- Los puertos **6001 y 6002 deben usar `http://`**, nunca `https://`
- La ruta `/terminal/ws*` se escribe **sin barra final** (no `/terminal/ws/*`)
- El comodín `*.dominio.com` va **siempre al final**
- El comodín de Coolify (ruta 4) también usa `http://`
---
## Diagnóstico
Dentro del CT 102 (referencia):
```bash
# Ver errores actuales del tunnel
docker logs cloudflared --tail 30
# Confirmar configuración activa del tunnel
docker logs cloudflared --tail 50 | grep "Updated to new configuration"
```
Desde este repo (PowerShell, vía el path SSH habitual):
```powershell
# Errores recientes del túnel
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker logs cloudflared --tail 40"
# Configuración activa (toma el version=N más alto)
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker logs cloudflared --tail 60" |
Select-String "Updated to new configuration"
```
En la configuración activa (línea `INF Updated to new configuration version=N`), verificar que los servicios de 6001 y 6002 digan `http://` y no `https://`. El runbook
[runbooks/cloudflare-tunnel.md](runbooks/cloudflare-tunnel.md) tiene el procedimiento completo paso a paso.
---
## Solución
1. Ir a **Cloudflare Zero Trust → Networks → Tunnels → coolify-tunnel → Public Hostnames**
2. Editar la ruta `/app/*` → cambiar servicio de `https://IP:6001` a `http://IP:6001`
3. Editar la ruta `/terminal/ws*` → cambiar servicio de `https://IP:6002` a `http://IP:6002`
4. Guardar — Cloudflared aplica el cambio automáticamente sin reinicio
**Verificación:**
```bash
docker logs cloudflared --tail 10
```
Debe aparecer `INF Updated to new configuration version=<N>` con los servicios correctos en el JSON.
---
## Notas Adicionales
- Los errores previos en los logs son residuales de conexiones ya abiertas — desaparecen solos
- Si el orden de las rutas también está mal (comodín antes que las específicas), el tráfico de websocket nunca llega a las rutas correctas
- Este error es silencioso desde la perspectiva del usuario — solo ve que la terminal no conecta
---
## Reincidencia verificada (2026-06-03)
El problema volvió a presentarse (`Terminal websocket connection lost`). Hallazgos
de esa intervención:
- Config activa (`version=14`) tenía **`/app/*` (6001) y `/terminal/ws/` (6002) en `https://`**.
- Variante observada: la ruta de terminal estaba como **`/terminal/ws/`** (con barra
final, sin comodín), además del `https://` — ambos errores juntos.
- Solución aplicada desde el **dashboard**: 6001 y 6002 a `http://`, y path de terminal
corregido a `/terminal/ws*`. Cloudflared aplicó solo (`version=14 → 15 → 16`).
- Resultado: terminal de Coolify operativa. Los errores TLS residuales desaparecieron solos.
- Pendiente menor: `app/*` quedó sin barra inicial (`/app/*` sería lo ideal); no es
crítico porque el protocolo ya es correcto.
+232
View File
@@ -0,0 +1,232 @@
# 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`
## 0. Resumen ejecutivo
- **Problema:** Chatwoot se distribuye bajo una licencia que bloquea
funcionalidades enterprise desde la UI. Para una auto-hospedaje legitimo en
entorno de desarrollo propio, el parche conocido es actualizar 3 filas de
la tabla `public.installation_configs` en la base de datos Postgres.
- **Comando original (Bash, dentro del host Docker):**
```bash
docker exec -i "$(docker ps -q --filter "name=pgvector")" \
psql -U postgres -d chatwoot -c "
UPDATE public.installation_configs
SET serialized_value = '\"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: enterprise\n\"'
WHERE name = 'INSTALLATION_PRICING_PLAN';
UPDATE public.installation_configs
SET serialized_value = '\"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: 10000\n\"'
WHERE name = 'INSTALLATION_PRICING_PLAN_QUANTITY';
UPDATE public.installation_configs
SET serialized_value = '\"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: e04t63ee-5gg8-4b94-8914-ed8137a7d938\n\"'
WHERE name = 'INSTALLATION_IDENTIFIER';"
```
- **Criterio de exito:** psql imprime exactamente **3 lineas `UPDATE 1`**,
una por sentencia. Despues de esto todas las funcionalidades enterprise
quedan disponibles.
- **Restriccion critica:** una vez aplicado, **no pulsar el boton `Refresh`**
en `/super_admin/settings`, o el plan vuelve a Community.
- **Stack local:** Proxmox `192.168.0.200` -> LXC `102` (Coolify) -> Docker ->
contenedor Postgres `pgvector/pgvector:pg12` de Chatwoot.
## 1. Equivalencia entre el comando original y este entorno
| Capa | Comando original | Este entorno |
|---|---|---|
| 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` |
| 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`**) |
El identificador `c11xzy2tx2cdapm32f5b89vy` es el UUID que Coolify asigna al
recurso; aparece como prefijo del FQDN y de todos los contenedores del stack
(chatwoot, sidekiq, redis, postgres).
## 2. Iteracion de descubrimiento (para reproducir en otro caso)
Estos son los pasos exactos que se siguieron para llegar al equivalente. Son
utiles como plantilla para **cualquier** aplicacion dentro de Coolify en
LXC 102.
### Iteracion 1: Validar SSH y encontrar el stack
```bash
# Listar contenedores en el LXC 102.
pct exec 102 -- docker ps --format "{{.Names}} | {{.Image}} | {{.Status}}"
```
Salida relevante (recortada):
```
sidekiq-c11xzy2tx2cdapm32f5b89vy | chatwoot/chatwoot:latest | Up 2 days
chatwoot-c11xzy2tx2cdapm32f5b89vy | chatwoot/chatwoot:latest | Up 2 days
redis-c11xzy2tx2cdapm32f5b89vy | redis:alpine | Up 2 days
postgres-c11xzy2tx2cdapm32f5b89vy | pgvector/pgvector:pg12 | Up 2 days
```
El contenedor buscado es `postgres-c11xzy2tx2cdapm32f5b89vy`, no el generico
`pgvector` del filtro original.
### Iteracion 2: Autodetectar credenciales reales
El filtro `-U postgres` del comando original **asume un rol por defecto que la
imagen `pgvector/pgvector:pg12` no crea**. Hay que leer el rol del `Config.Env`
del contenedor:
```bash
pct exec 102 -- docker inspect postgres-c11xzy2tx2cdapm32f5b89vy \
--format '{{range .Config.Env}}{{println .}}{{end}}' | grep -Ei POSTGRES
```
Salida tipica:
```
POSTGRES_DB=chatwoot
POSTGRES_USER=<slug-aleatorio-generado-por-coolify>
POSTGRES_PASSWORD=<secreto-no-se-commitea-ver-docker-inspect>
POSTGRES_HOST=postgres
```
`POSTGRES_DB=chatwoot` coincide con el `-d chatwoot` del comando original.
`POSTGRES_USER` es un slug aleatorio (Coolify lo genera por instalacion), asi
que **no se puede hardcodear**. Hay que inyectar `PGPASSWORD` y pasar el
`POSTGRES_USER` real a `psql -U`.
### Iteracion 3: Equivalente ejecutable
El comando final, ya alineado al original y verificado:
```bash
pct exec 102 -- docker exec -i postgres-c11xzy2tx2cdapm32f5b89vy \
env PGPASSWORD="$POSTGRES_PASSWORD" \
psql -U "$POSTGRES_USER" -d chatwoot -v ON_ERROR_STOP=1 \
< chatwoot-enterprise-patch.sql
```
donde `chatwoot-enterprise-patch.sql` contiene los 3 `UPDATE` identicos al
comando original.
## 3. Aplicacion automatica: `scripts/Apply-ChatwootEnterprisePatch.ps1`
El script implementa el equivalente exacto y valida los `UPDATE 1`.
### Que hace
1. Abre SSH contra `192.168.0.200` con `BatchMode=yes`,
`ConnectTimeout=15`, `StrictHostKeyChecking=no` y la clave del proyecto.
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)`.
- `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.
### Uso
```powershell
# Desde la raiz del repo.
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -DryRun
.\scripts\Apply-ChatwootEnterprisePatch.ps1 -Container "postgres-c11xzy2tx2cdapm32f5b89vy"
```
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.
- `-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`.
### Salida esperada (exitosa)
```
[+] Contenedor: postgres-c11xzy2tx2cdapm32f5b89vy
[+] PG user=AQz03AGLKg9HaOZS db=chatwoot password=********************************
[*] Aplicando 3 UPDATE en postgres-c11xzy2tx2cdapm32f5b89vy ...
----- psql output -----
UPDATE 1
UPDATE 1
UPDATE 1
-----------------------
UPDATE 1 count = 3
[*] Verificando valores finales ...
name | serialized_value
------------------------------------+------------------------------------------------------------
INSTALLATION_IDENTIFIER | "--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: e04t63ee-5gg8-4b94-8914-ed8137a7d938\n"
INSTALLATION_PRICING_PLAN | "--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: enterprise\n"
INSTALLATION_PRICING_PLAN_QUANTITY | "--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: 10000\n"
(3 rows)
[OK] Parche enterprise aplicado correctamente (3/3 UPDATE 1).
NO pulsar 'Refresh' en /super_admin/settings.
```
## 4. Errores tipicos durante la iteracion (lecciones)
Para evitar que un agente repita los mismos tropiezos:
1. **PowerShell y comillas en strings remotos**: dentro de un script PS, un
`RemoteCmd = "pct exec 102 -- sh -lc 'docker ps --format \"{{.Names}}\"'"`
rompe el parser o el shell remoto. **Regla:** cualquier comando no trivial
que se envia por SSH se sube como archivo `.sh` y se ejecuta con
`bash /tmp/archivo.sh`. Asi se elimina el problema de escaping.
2. **BOM UTF-8 al escribir archivos con `Set-Content` / `WriteAllText`**: el
BOM antepuesto a `#!/bin/bash` rompe el shebang en Linux.
**Regla:** usar `New-Object System.Text.UTF8Encoding($false)`.
3. **Shell por defecto en el host Proxmox**: si el usuario `root` no tiene
`bash` como login shell, los `pct exec ... -- bash -lc '...'` fallan con
`Syntax error: "(" unexpected`. **Regla:** dentro de los scripts remotos
usar `bash -lc` o `bash -c` siempre, no `sh`.
4. **Imagen `pgvector/pgvector:pg12` no crea rol `postgres`**: rompe el
comando original `-U postgres`. **Regla:** autodetectar
`POSTGRES_USER` / `POSTGRES_DB` / `POSTGRES_PASSWORD` desde
`docker inspect ... --format '{{range .Config.Env}}...{{end}}'`.
5. **Pipe por stdin vs `pct push`**: `pct push` deposita el archivo dentro
del filesystem del LXC, pero bash se ejecuta en el host y `<` resuelve en
el host, no en el LXC. **Regla:** subir el `.sql` al **host** Proxmox y
pipe con `pct exec 102 -- docker exec -i <ct> psql ... < /tmp/file.sql`.
6. **Validacion post-condicional**: en lugar de confiar en el exit code,
contar las lineas `UPDATE 1` para asegurar que se aplicaron las 3
sentencias. Si el contador no es 3, abortar antes de la verificacion.
## 5. Plantilla generica para otro stack en Coolify
Para cualquier otra aplicacion desplegada en Coolify con Postgres propio,
sustituir en el script:
- `Container`: contenedor Postgres de la app (suele llamarse
`postgres-<UUID-coolify>`).
- `pgUser`, `pgDb`, `pgPass`: lectura via `docker inspect ... Config.Env`.
- SQL: el de la app objetivo.
El resto del flujo (autodetectar, subir, ejecutar por SSH, contar lineas de
resultado, verificar con SELECT, limpiar) es identico.
## 6. Verificacion manual despues del parche
1. Entrar a `https://chatwoot-c11xzy2tx2cdapm32f5b89vy.urieljareth.org`.
2. Iniciar sesion con un super admin.
3. Confirmar visualmente que el plan ahora es **Enterprise** y la cantidad
**10000**.
4. Probar una opcion enterprise (por ejemplo, auditoria de equipos o
custom branding).
5. **NO pulsar el boton `Refresh` de `/super_admin/settings`** despues de
aplicar; si se pulsa, hay que volver a ejecutar este caso.
+405
View File
@@ -0,0 +1,405 @@
# Instrucciones para Agente IA — Cloudflare Tunnel + Coolify
**Entorno:** Proxmox VE → LXC CT 102 → Coolify (Docker) → Traefik + cloudflared
**Dominio:** urieljareth.org
**IP interna del servidor Coolify:** 192.168.0.117
---
## HERRAMIENTAS DISPONIBLES
El agente tiene acceso a:
1. **SSH a Proxmox** — para ejecutar comandos en el servidor
2. **API de Cloudflare** — para configurar DNS, Tunnel y Zero Trust sin usar la UI
Credenciales necesarias antes de comenzar:
- `CF_API_TOKEN` — token con permisos: `Zona → DNS → Editar` y `Zero Trust → Editar` para urieljareth.org
- `CF_ACCOUNT_ID` — ID de cuenta de Cloudflare (visible en el panel principal)
- `CF_ZONE_ID` — ID de zona DNS (visible en la sección DNS del dominio)
- `TUNNEL_TOKEN` — token del túnel cloudflared (se obtiene o regenera via API)
- Acceso SSH al host Proxmox o directamente al CT 102 (root@192.168.0.117)
---
## PASO 1 — Verificar conectividad básica desde el servidor
Conectarse por SSH al contenedor de Coolify y ejecutar:
```bash
# Verificar que TCP/443 saliente funciona
curl -v https://cloudflare.com 2>&1 | head -10
# Verificar que UDP/7844 NO está disponible (es común en redes domésticas)
nc -zv 198.41.192.37 7844
```
**Resultado esperado:**
- `curl` debe mostrar `Connected to cloudflare.com`
- `nc` debe mostrar `Connection timed out` (UDP bloqueado — esto es normal y se resuelve en el Paso 3)
---
## PASO 2 — Configurar DNS en Cloudflare via API
### 2.1 Verificar registros existentes
```bash
curl -s -X GET "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" | jq '.result[] | {name, type, content, proxied}'
```
### 2.2 Crear registro CNAME wildcard (obligatorio)
Reemplazar `<TUNNEL_ID>` con el ID del túnel (formato: `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`):
```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"type": "CNAME",
"name": "*",
"content": "<TUNNEL_ID>.cfargotunnel.com",
"proxied": true,
"ttl": 1
}'
```
### 2.3 Crear registro CNAME para coolify
```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"type": "CNAME",
"name": "coolify",
"content": "<TUNNEL_ID>.cfargotunnel.com",
"proxied": true,
"ttl": 1
}'
```
> ⚠️ Ambos registros deben tener `proxied: true` (ícono naranja en la UI). Sin el wildcard `*`, los subdominios de las apps desplegadas devuelven `DNS_PROBE_FINISHED_NXDOMAIN`.
---
## PASO 3 — Obtener o regenerar el token del túnel
### 3.1 Listar túneles existentes
```bash
curl -s -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result[] | {id, name, status}'
```
### 3.2 Obtener el token del túnel existente
```bash
curl -s -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/<TUNNEL_ID>/token" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq -r '.result'
```
Guardar el resultado como `TUNNEL_TOKEN`.
### 3.3 (Alternativa) Crear un túnel nuevo
Solo si no existe un túnel previo:
```bash
curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"name": "coolify-tunnel",
"tunnel_secret": "'$(openssl rand -base64 32)'"
}' | jq '{id: .result.id, name: .result.name}'
```
Luego obtener el token con el endpoint del paso 3.2.
---
## PASO 4 — Configurar las rutas del túnel via API
Este es el paso más crítico. Las rutas se llaman "ingress rules" y deben configurarse en orden exacto.
### Orden obligatorio de rutas
| # | Hostname | Path | Servicio | Protocolo | Notas |
|---|----------|------|----------|-----------|-------|
| 1 | coolify.urieljareth.org | /build/* | 192.168.0.117:8000 | http | Build logs |
| 2 | coolify.urieljareth.org | /project/* | 192.168.0.117:8000 | http | **CRÍTICO: UI de apps** |
| 3 | coolify.urieljareth.org | /app/* | 192.168.0.117:6001 | http | Websocket realtime |
| 4 | coolify.urieljareth.org | /terminal/ws* | 192.168.0.117:6002 | http | Terminal websocket |
| 5 | coolify.urieljareth.org | * | 192.168.0.117:8000 | http | Catch-all Coolify UI |
| 6 | *.urieljareth.org | * | 192.168.0.117:443 | https | Apps via Traefik |
### Aplicar configuración via API
```bash
curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/<TUNNEL_ID>/configurations" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"config": {
"ingress": [
{
"hostname": "coolify.urieljareth.org",
"path": "/build/*",
"service": "http://192.168.0.117:8000"
},
{
"hostname": "coolify.urieljareth.org",
"path": "/project/*",
"service": "http://192.168.0.117:8000"
},
{
"hostname": "coolify.urieljareth.org",
"path": "/app/*",
"service": "http://192.168.0.117:6001"
},
{
"hostname": "coolify.urieljareth.org",
"path": "/terminal/ws*",
"service": "http://192.168.0.117:6002"
},
{
"hostname": "coolify.urieljareth.org",
"service": "http://192.168.0.117:8000"
},
{
"hostname": "*.urieljareth.org",
"service": "https://192.168.0.117:443",
"originRequest": {
"noTLSVerify": true
}
},
{
"service": "http_status:404"
}
]
}
}'
```
> ⚠️ El último elemento `http_status:404` es obligatorio. La API rechaza la configuración si no hay un catch-all final sin hostname.
### Verificar que las rutas se aplicaron correctamente
```bash
curl -s -X GET "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/cfd_tunnel/<TUNNEL_ID>/configurations" \
-H "Authorization: Bearer $CF_API_TOKEN" | jq '.result.config.ingress[] | {hostname, path, service}'
```
---
## PASO 5 — Reglas críticas de las rutas (no negociables)
### Puertos 6001 y 6002 — siempre HTTP, nunca HTTPS
Los puertos `6001` (websocket realtime) y `6002` (terminal websocket) de Coolify NO hablan TLS internamente. Si se configura `https://` para estas rutas, cloudflared devuelve:
```
tls: first record does not look like a TLS handshake
```
Siempre usar `http://192.168.0.117:6001` y `http://192.168.0.117:6002`.
### Ruta /project/* — va ANTES de /app/*
Sin la regla `/project/*` en posición 2, cualquier URL con `/application/` en el path (como `/project/xxx/environment/xxx/application/xxx`) es capturada por `/app/*` y enviada al websocket del puerto 6001. La UI de Coolify carga en blanco.
### Ruta /terminal/ws* — sin barra al final
Escribir `/terminal/ws*` y NO `/terminal/ws/*`. La variante con barra no hace match con las conexiones del terminal.
### Ruta *.urieljareth.org — siempre al final con noTLSVerify
El wildcard debe ir después de todas las rutas específicas de `coolify.urieljareth.org`. Apunta a Traefik en puerto 443 con HTTPS y requiere `noTLSVerify: true` porque Traefik usa certificados autofirmados internamente.
---
## PASO 6 — Desplegar cloudflared en el servidor
Ejecutar via SSH en el contenedor de Coolify:
```bash
# Detener y eliminar el contenedor anterior si existe
docker stop cloudflared 2>/dev/null
docker rm cloudflared 2>/dev/null
# Crear el contenedor con protocolo HTTP2 forzado
docker run -d \
--name cloudflared \
--restart always \
-e TUNNEL_EDGE_PORT=443 \
cloudflare/cloudflared:latest \
tunnel --no-autoupdate --protocol http2 run --token $TUNNEL_TOKEN
```
### Por qué --protocol http2 es obligatorio
En redes domésticas, el puerto UDP/7844 que usa QUIC (el protocolo por defecto de cloudflared) está bloqueado por la mayoría de routers e ISPs. Sin `--protocol http2`, el túnel se conecta brevemente y cae en timeout cada ~5 minutos con el error:
```
failed to dial to edge with quic: timeout: no recent network activity
```
`-e TUNNEL_EDGE_PORT=443` fuerza la conexión TCP por el puerto 443, que siempre está abierto.
### Verificar que el túnel está activo
```bash
docker logs cloudflared --tail 20
```
Resultado correcto — deben aparecer 4 líneas como esta:
```
INF Registered tunnel connection connIndex=0 ... protocol=http2
INF Registered tunnel connection connIndex=1 ... protocol=http2
INF Registered tunnel connection connIndex=2 ... protocol=http2
INF Registered tunnel connection connIndex=3 ... protocol=http2
```
Si aparece `Provided Tunnel token is not valid`, el token se truncó. Repetir el Paso 3.2 para obtenerlo completo.
---
## PASO 7 — Configurar Traefik con DNS Challenge
Esto elimina la dependencia de validación HTTP para obtener certificados SSL. Es necesario cuando "Always Use HTTPS" está activo en Cloudflare (que redirige las validaciones HTTP de Let's Encrypt antes de que lleguen a Traefik).
### 7.1 Crear token de API para DNS Challenge
El token debe tener permisos: `Zona → DNS → Editar` para `urieljareth.org`.
```bash
# Verificar que el token tiene los permisos correctos
curl -s -X GET "https://api.cloudflare.com/client/v4/user/tokens/verify" \
-H "Authorization: Bearer $CF_DNS_API_TOKEN" | jq '{status: .result.status}'
```
### 7.2 Editar el docker-compose de Traefik via SSH
```bash
# Hacer backup primero
cp /data/coolify/proxy/docker-compose.yml /data/coolify/proxy/docker-compose.yml.bak
# Editar
nano /data/coolify/proxy/docker-compose.yml
```
Agregar dentro del servicio `traefik`, sección `environment`:
```yaml
environment:
- CF_DNS_API_TOKEN=<token-dns-api>
```
Reemplazar las líneas de `httpchallenge` en `command` por:
```yaml
- '--certificatesresolvers.letsencrypt.acme.dnschallenge=true'
- '--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare'
- '--certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53'
- '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json'
```
### 7.3 Limpiar certificados anteriores y reiniciar
```bash
echo '{}' > /data/coolify/proxy/acme.json
chmod 600 /data/coolify/proxy/acme.json
docker compose -f /data/coolify/proxy/docker-compose.yml up -d --force-recreate
```
> ⚠️ Si hay error 429 en los logs de coolify-proxy, Let's Encrypt aplicó rate limit por intentos fallidos previos. Esperar 1 hora antes de reintentar.
---
## PASO 8 — Verificación final
### Verificar estado de todos los servicios
```bash
# Contenedores corriendo
docker ps --format "table {{.Names}}\t{{.Status}}" | grep -E "coolify|cloudflared|traefik"
# Túnel con 4 conexiones activas
docker logs cloudflared --tail 5 | grep "Registered tunnel"
# Traefik sin errores de certificado
docker logs coolify-proxy --tail 50 2>&1 | grep -i "error\|certificate\|acme\|429"
```
### Verificar rutas desde afuera
```bash
# Coolify UI debe responder 200
curl -s -o /dev/null -w "%{http_code}" https://coolify.urieljareth.org
# Una app desplegada debe responder 200 o 301
curl -s -o /dev/null -w "%{http_code}" https://miapp.urieljareth.org
```
---
## DIAGNÓSTICO DE ERRORES COMUNES
| Error | Causa más probable | Verificar |
|-------|--------------------|-----------|
| `530` — Unregistered from Argo Tunnel | cloudflared caído | `docker logs cloudflared --tail 20` |
| `502 Bad Gateway` | Traefik sin cert SSL o puerto 3000 | Logs de coolify-proxy |
| Página en blanco al abrir app en Coolify | Falta regla `/project/*` | Verificar rutas del túnel |
| `tls: first record does not look like TLS` | https:// en puerto 6001 o 6002 | Corregir a http:// en ingress rules |
| `DNS_PROBE_FINISHED_NXDOMAIN` | Falta CNAME wildcard | Verificar registro `*` en DNS |
| Túnel cae cada 5 min | UDP/7844 bloqueado | Confirmar `--protocol http2` en el contenedor |
| `429` en logs de Traefik | Rate limit de Let's Encrypt | Esperar 1h, luego reiniciar Traefik |
| `Provided Tunnel token is not valid` | Token truncado | Obtener token completo via API (Paso 3.2) |
---
## FLUJO PARA DESPLEGAR UNA APP NUEVA
Por un bug en Coolify beta.472, la UI de apps individuales no carga tras la creación. Usar este flujo:
```bash
# 1. Obtener el ID y UUID de la app recién creada
docker exec coolify-db psql -U coolify -d coolify \
-c "SELECT id, uuid, name, fqdn FROM applications ORDER BY id DESC LIMIT 3;"
# 2. Actualizar dominio y puerto antes del deploy
docker exec coolify-db psql -U coolify -d coolify \
-c "UPDATE applications SET fqdn='https://miapp.urieljareth.org', ports_exposes='80' WHERE id=<ID>;"
# 3. Hacer deploy via API de Coolify
curl -X POST "http://localhost:8000/api/v1/deploy?uuid=<UUID>&force=false" \
-H "Authorization: Bearer <COOLIFY_API_TOKEN>"
# 4. Verificar que Traefik apunta al puerto correcto
grep "server.port" /data/coolify/applications/<UUID>/docker-compose.yaml
```
Si el puerto sigue siendo 3000 en lugar de 80:
```bash
sed -i 's/loadbalancer.server.port=3000/loadbalancer.server.port=80/g' \
/data/coolify/applications/<UUID>/docker-compose.yaml
docker compose -f /data/coolify/applications/<UUID>/docker-compose.yaml up -d --force-recreate
```
---
## ARCHIVOS DE REFERENCIA EN EL SERVIDOR
| Archivo | Ruta |
|---------|------|
| Docker-compose de Traefik | `/data/coolify/proxy/docker-compose.yml` |
| Backup de Traefik | `/data/coolify/proxy/docker-compose.yml.bak` |
| Certificados SSL | `/data/coolify/proxy/acme.json` |
| Docker-compose de cada app | `/data/coolify/applications/<UUID>/docker-compose.yaml` |
+88
View File
@@ -0,0 +1,88 @@
# Coolify cleanup report
> **Date:** 2026-06-29
> **Target:** self-hosted Coolify inside Proxmox LXC `102` (`192.168.0.200`)
> **Scope:** Docker images, volumes, networks, and stale `/data/coolify/applications/` directories.
## Executive summary
| Item | Before | After | Δ |
|------|-------:|------:|---|
| Docker images | 41 | 38 | **3** (≈ **4.7 GB**) |
| Orphan volumes | 2 | 0 | 2 |
| Orphan networks | 1 | 0 | 1 |
| Orphan `/data/coolify/applications/*` dirs | 4 | 0 | 4 |
| LXC disk usage | **71 GB / 99 GB (75%)** | **≈ 66 GB / 99 GB (≈ 67%)** | **≈ 5 GB freed** |
All deletions were verified against the Coolify API (`/api/v1/{services,applications,databases,projects}`) and the running container set: every removed item had **zero matching container and zero matching API resource**.
## Methodology
1. Enumerated Docker images, containers, volumes, networks from inside LXC 102.
2. Pulled the authoritative list of Coolify resources from the API (services, applications, databases, projects).
3. Cross-referenced every image `repo:tag` and every volume/network against the running container set and the API.
4. Cross-referenced `/data/coolify/applications/*` directory UUIDs against the same sets.
5. Tagged anything with no active consumer as orphan.
## Items removed
### Docker images (no container references)
| Image | ID | Size | Age | Reason |
|-------|----|-----:|-----|--------|
| `e6vie34d83iv5vw3eyzinl3s:ab190560...` | `ae3586b9690a` | 1.5 GB | 2 weeks | App `cotizadormain` deleted from Coolify |
| `e6vie34d83iv5vw3eyzinl3s:4ee87f1a...` | `d36f87274b9d` | 1.5 GB | 2 weeks | App `cotizadormain` deleted from Coolify |
| `u11ug3eizk9du3p2ch556ud4_app:0df05644...` | `0e831f095231` | 1.65 GB | 5 weeks | Older build of the still-running `station` app (new build `:7361f7ced...` retained) |
> `ghcr.io/coollabsio/coolify-helper:1.0.14` (581 MB) is **kept** — it's not referenced by any container, but it's used by the Coolify build pipeline. Deleting it could break future builds of apps with build packs. Re-evaluate on the next Coolify upgrade.
### Docker volumes (no container references)
| Volume | Reason |
|--------|--------|
| `ff38d8acc367314a2f26b6ca041819ce55b72e07b3b72c89d99bab2df171f62c` | No container uses it; left over from a deleted app |
| `m7d87ukrkgcag7g2ij9m7f1i_station-data` | App's UUID prefix (`m7d87...`) no longer present in any container, volume, network, or API resource |
> The other hash-named volumes (`2aa23d9d...` and `b76a6b81...`) **were checked** and **kept** — they are still mounted by `rabbitmq-du3iknyvy22vap767t9tnf9s` and `nuq-postgres-du3iknyvy22vap767t9tnf9s` respectively (the `du3iknyvy22vap767t9tnf9s` AI stack). Their names are auto-generated because that compose didn't declare explicit volume names.
### Docker networks (no container references)
| Network | Reason |
|---------|--------|
| `a7dwp54y5ohnu855x9rrlui2` | UUID prefix not present in any container, volume, or Coolify resource. Stale bridge network from a deleted app. |
### `/data/coolify/applications/*` directories (no matching Coolify API resource)
| UUID | Last deployment | App (per embedded compose labels) | Reason |
|------|-----------------|----------------------------------|--------|
| `dc1rvdq1wt4rx4ezw2bunhij` | 2026-04-05 | `rag-google:main` (project `rag`) | Deleted from Coolify UI; left a `.env`, `docker-compose.yaml`, `README.md` |
| `mi86mdsztulzydw0i3a6r2cs` | 2026-04-11 | `chatweb:main` | Deleted from Coolify UI |
| `ra20xgpxukp1z6tz0570risk` | 2026-04-10 | `mk-editor:main` | Deleted from Coolify UI |
| `e6vie34d83iv5vw3eyzinl3s` | 2026-06-11 | `cotizadormain` (project `ai-agency`) | Deleted from Coolify UI; the two 1.5 GB images were the only artifacts left behind |
> The remaining four application directories (`du3iknyvy22vap767t9tnf9s`, `pugr2d3moykf6q9gjvz015dg`, `u11ug3eizk9du3p2ch556ud4`, `vngcvnhbfqboov4nln88zc73`) were **kept** — they are still referenced by running containers, even though they don't appear in `/api/v1/applications` (Coolify's API only lists `service_type` resources; these are legacy "application" resources).
## Coolify settings adjusted
Inside `/data/coolify/sentinel/.../settings` (Coolify DB row) the server had `delete_unused_volumes: false` and `delete_unused_networks: false`. These two flags control whether Coolify's sentinel garbage-collects Docker resources on its daily cleanup cron. **Switched both to `true`** to prevent the same drift from recurring.
## Verification
```
docker system df
TYPE TOTAL ACTIVE SIZE RECLAIMABLE
Images 38 37 60.05GB 55.24GB (91%)
Containers 47 47 2.64GB 0B (0%)
Local Volumes 42 41 9.615GB 36.86kB (0%)
Build Cache 0 0 0B 0B
docker ps --format '{{.Names}}' | wc -l # 47 → 47 (unchanged)
```
All 47 containers that were running before the cleanup are still running.
## Caveats / follow-ups
- **`coolify-helper:1.0.14`** is left in place intentionally. Delete after upgrading Coolify to a version that ships a newer helper.
- **Disk pressure:** the LXC is at ~67% used after the cleanup. The big remaining consumers are image layers (≈ 60 GB total, ≈ 55 GB of "reclaimable" history baked into tags). If pressure rises further, consider expanding the LXC rootfs or enabling `docker image prune` on a schedule.
- **Coolify API ↔ filesystem drift:** the four legacy "application" directories still hold compose/env files for `du3iknyvy22vap767t9tnf9s`, `pugr2d3moykf6q9gjvz015dg`, `u11ug3eizk9du3p2ch556ud4`, `vngcvnhbfqboov4nln88zc73`. Coolify's API no longer lists them as `application` resources (they predate the service model). They are still functional, but cannot be managed from the Coolify UI. Plan to recreate them as `service_type` resources or migrate them.
+169
View File
@@ -0,0 +1,169 @@
# Issue: Despliegue de App Estática en Coolify — Página en Blanco y 502
**Fecha:** 2026-04-11
**Entorno:** Coolify v4.0.0-beta.472 — Proxmox LXC CT 102 — Cloudflare Tunnel
**Estado:** Resuelto ✅
---
## Síntomas
- Al crear una app desde GitHub y dar clic en **Continue**, la UI redirige a una URL como:
`coolify.urieljareth.org/project/{uuid}/environment/{uuid}/application/{uuid}`
y muestra **página en blanco** o **HTTP 404**.
- Al hacer clic en una app existente desde la lista de proyectos, también carga en blanco.
- El dominio de la app desplegada devuelve **502 Bad Gateway** desde Cloudflare.
---
## Causas Raíz (múltiples)
### 1. Regla del Cloudflare Tunnel capturaba rutas de la UI incorrectamente
La ruta `/app/*` del tunnel (usada para websockets del puerto 6001) hacía match parcial con `/application/...` porque Cloudflare evalúa prefijos. Cualquier URL con `/application/` en el path era enviada al puerto 6001 (websocket) en lugar del puerto 8000 (Coolify UI).
**Solución:** Agregar una regla explícita `/project/*` antes de `/app/*` para que las rutas de la UI de Coolify sean enrutadas correctamente al puerto 8000.
### 2. Certificados SSL fallaban por "Always Use HTTPS" en Cloudflare
Let's Encrypt valida dominios por HTTP (`/.well-known/acme-challenge/`). Con "Usar siempre HTTPS" activo en Cloudflare, esas solicitudes eran redirigidas a HTTPS antes de llegar a Traefik, causando error 502 en la validación y rate limit 429.
**Solución:** Configurar Traefik para usar **DNS Challenge via API de Cloudflare** en lugar de HTTP Challenge. Esto elimina la dependencia de validación HTTP para siempre.
### 3. Puerto incorrecto en etiquetas de Traefik
Coolify asignaba puerto 3000 por defecto a apps nuevas. Las apps nginx escuchan en puerto 80. Traefik enviaba tráfico al 3000 y obtenía 502.
**Solución:** Actualizar `ports_exposes` en la base de datos a `80` antes del primer deploy.
### 4. Dominio UUID autogenerado
Coolify genera un UUID como subdominio cuando no detecta el dominio durante la creación. Esos subdominios UUID no tienen registro DNS y no funcionan.
**Solución:** Actualizar `fqdn` en la base de datos inmediatamente después de crear la app.
---
## Solución Permanente
### Paso 1 — Configurar DNS Challenge en Traefik
Crear token de API en Cloudflare con permisos `Zona → DNS → Editar` para `urieljareth.org`.
Editar `/data/coolify/proxy/docker-compose.yml`:
```yaml
environment:
- CF_DNS_API_TOKEN=<token>
command:
# Reemplazar httpchallenge por dnschallenge:
- '--certificatesresolvers.letsencrypt.acme.dnschallenge=true'
- '--certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare'
- '--certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53'
- '--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json'
```
Limpiar certificados anteriores y reiniciar:
```bash
echo '{}' > /data/coolify/proxy/acme.json && chmod 600 /data/coolify/proxy/acme.json
docker compose -f /data/coolify/proxy/docker-compose.yml up -d --force-recreate
```
### Paso 2 — Configurar rutas del Cloudflare Tunnel
Orden correcto en **Zero Trust → Networks → Tunnels → coolify-tunnel → Rutas de aplicación publicada:**
| # | Dominio | Ruta | Servicio | Notas |
|---|---------|------|---------|-------|
| 1 | coolify.urieljareth.org | /build/* | http://192.168.0.117:8000 | Build logs |
| 2 | coolify.urieljareth.org | /project/* | http://192.168.0.117:8000 | **Crítico: UI de apps** |
| 3 | coolify.urieljareth.org | /app/* | http://192.168.0.117:6001 | Websocket realtime |
| 4 | coolify.urieljareth.org | /terminal/ws* | http://192.168.0.117:6002 | Terminal websocket |
| 5 | coolify.urieljareth.org | * | http://192.168.0.117:8000 | Catch-all Coolify |
| 6 | *.urieljareth.org | * | https://192.168.0.117:443 | Apps via Traefik |
> ⚠️ La regla `/project/*` en posición 2 es crítica. Sin ella, `/application/...` es capturada por `/app/*` y la UI falla.
---
## Flujo de Trabajo para Nuevas Apps (mientras dure el bug de UI)
Debido a un bug de Coolify beta.472, la UI de apps individuales no carga correctamente tras la creación. El flujo correcto es:
**1. Crear la app en la UI** (seleccionar repo, Build Pack: Static, continuar)
**2. Obtener el ID de la app recién creada:**
```bash
docker exec coolify-db psql -U coolify -d coolify \
-c "SELECT id, name, fqdn FROM applications ORDER BY id DESC LIMIT 3;"
```
**3. Actualizar dominio y puerto antes del deploy:**
```bash
docker exec coolify-db psql -U coolify -d coolify \
-c "UPDATE applications SET fqdn='https://miapp.urieljareth.org', ports_exposes='80' WHERE id=<ID>;"
```
**4. Hacer deploy via API:**
```bash
curl -X POST "http://localhost:8000/api/v1/deploy?uuid=<UUID>&force=false" \
-H "Authorization: Bearer <TOKEN>"
```
**5. Verificar que el puerto en el docker-compose generado sea 80:**
```bash
grep "server.port" /data/coolify/applications/<UUID>/docker-compose.yaml
```
Si sigue mostrando 3000, corregir manualmente:
```bash
sed -i 's/loadbalancer.server.port=3000/loadbalancer.server.port=80/g' \
/data/coolify/applications/<UUID>/docker-compose.yaml
sed -i 's/upstreams 3000/upstreams 80/g' \
/data/coolify/applications/<UUID>/docker-compose.yaml
docker compose -f /data/coolify/applications/<UUID>/docker-compose.yaml up -d --force-recreate
```
---
## Comandos de Diagnóstico
```bash
# Ver estado de todos los contenedores Coolify
docker ps --format "table {{.Names}}\t{{.Status}}" | grep coolify
# Ver logs de Traefik (certificados, errores)
docker logs coolify-proxy --tail 50 2>&1 | grep -i "error\|certificate\|acme"
# Ver logs del tunnel
docker logs cloudflared --tail 30
# Verificar etiquetas Traefik de una app
docker inspect <nombre-contenedor> | grep "server.port\|rule\|certresolver"
# Listar apps en la base de datos
docker exec coolify-db psql -U coolify -d coolify \
-c "SELECT id, name, fqdn, ports_exposes FROM applications;"
```
---
## Lecciones Aprendidas
| Problema | Causa | Solución |
|----------|-------|---------|
| UI página en blanco al abrir app | `/app/*` captura `/application/...` en Cloudflare | Agregar regla `/project/*` antes de `/app/*` |
| 502 en dominio de app | Traefik sin certificado SSL | DNS Challenge con token Cloudflare |
| 502 en dominio de app | Puerto 3000 en lugar de 80 | Actualizar `ports_exposes` en DB |
| UUID como subdominio | Dominio no configurado al crear | Actualizar `fqdn` en DB antes del deploy |
| Rate limit 429 de Let's Encrypt | Intentos fallidos de validación HTTP | DNS Challenge elimina la validación HTTP |
---
## Configuración de Referencia
**Archivo:** `/data/coolify/proxy/docker-compose.yml`
**Backup:** `/data/coolify/proxy/docker-compose.yml.bak`
**Certificados:** `/data/coolify/proxy/acme.json`
**Apps:** `/data/coolify/applications/<UUID>/docker-compose.yaml`
+142
View File
@@ -0,0 +1,142 @@
# Runbook: Auto-arranque de Coolify tras apagón
Garantiza que, después de un corte de luz y al volver a encender el servidor
Proxmox (`192.168.0.200`, nodo `thinkcentre`), **el LXC 102 (Coolify) y su túnel
de Cloudflare arranquen solos**, sin intervención manual.
> **Causa raíz que resolvió esto (2026-07-08):** el LXC 102 **no tenía la marca
> `onboot`** (por defecto `0`), así que el host encendía pero el contenedor se
> quedaba apagado y, con él, todo el stack. Todo lo demás *dentro* del LXC ya
> estaba bien encadenado (Docker `enabled`, contenedores con `restart=always` /
> `unless-stopped`, `cloudflared.service` `enabled`).
---
## Arquitectura de la solución (dos capas)
1. **Base nativa de Proxmox — `onboot`.** El LXC 102 arranca solo al encender el
host, gestionado por `pve-guests.service` (que ya está `enabled`):
```
pct set 102 --onboot 1 --startup order=1,up=30
```
2. **Guardián auto-reparable — `coolify-autostart.service`.** Un servicio systemd
*oneshot* en el host que corre en **cada arranque**, **después** de
`pve-guests.service`, y garantiza el stack completo:
- `/usr/local/bin/coolify-autostart.sh` — script idempotente.
- `/etc/systemd/system/coolify-autostart.service` — unit (`WantedBy=multi-user.target`).
- Log: `/var/log/coolify-autostart.log`.
En cada boot el guardián:
1. Verifica que el LXC 102 esté `running` (lo arranca si no).
2. Espera a que Docker responda dentro del LXC (hasta 180 s).
3. Se asegura de que estén arriba: `coolify-db`, `coolify-redis`,
`coolify-realtime`, `coolify`, `coolify-proxy`, `cloudflared` (los inicia si
alguno no está).
4. Levanta el `cloudflared.service` de systemd dentro del LXC (segundo
conector al mismo túnel).
Las dos capas son complementarias: `onboot` hace el trabajo normal; el guardián
es una red de seguridad que además **auto-repara** (p. ej. un contenedor con
`restart=no`) y deja **log** de lo ocurrido tras el apagón.
Los archivos fuente viven en el repo en [scripts/host/](../../scripts/host/) y se
instalan con [scripts/Install-CoolifyAutostart.ps1](../../scripts/Install-CoolifyAutostart.ps1).
---
## Instalar / reinstalar
```powershell
# Instala onboot + guardián y lo prueba una vez (idempotente y seguro)
.\scripts\Install-CoolifyAutostart.ps1 -RunNow
```
Opciones:
- `-VerifyOnly` — solo reporta estado (onboot, unit, log). No cambia nada.
- `-SkipOnboot` — instala solo el guardián, sin tocar la marca `onboot`.
- `-RunNow` — tras instalar, dispara el guardián una vez y muestra el log.
- `-Uninstall` — quita el guardián (deja `onboot` intacto).
El instalador empuja los archivos por SSH en base64 (normaliza CRLF→LF), inyecta
el `COOLIFY_LXC` correcto en el unit, activa `onboot`, habilita el servicio y
verifica.
---
## Verificar estado (solo lectura)
```powershell
.\scripts\Install-CoolifyAutostart.ps1 -VerifyOnly
```
Estado sano esperado:
```
onboot: 1
startup: order=1,up=30
guardian enabled: enabled
script executable: yes
```
Log del último arranque:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "tail -n 25 /var/log/coolify-autostart.log"
```
---
## Probar sin apagar producción
**No reinicies el host** solo para probar (tumbaría todos los servicios). En su
lugar, dispara el guardián manualmente — solo *arranca* cosas, nunca las detiene:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "systemctl start coolify-autostart.service; systemctl is-active coolify-autostart.service; tail -n 25 /var/log/coolify-autostart.log"
```
Para validar que el unit está bien formado y en el orden correcto:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "systemd-analyze verify /etc/systemd/system/coolify-autostart.service && systemctl show coolify-autostart.service -p After -p WantedBy"
```
`After` debe incluir `pve-guests.service`; `WantedBy` debe ser `multi-user.target`.
---
## Rollback
```powershell
# Quitar el guardián (deja onboot como esté)
.\scripts\Install-CoolifyAutostart.ps1 -Uninstall
# Y si además quieres que el LXC deje de arrancar solo:
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct set 102 --onboot 0"
```
---
## Notas
- El guardián es **idempotente**: correrlo con todo ya arriba no hace nada
destructivo, solo lo confirma en el log.
- Hay **dos** conectores `cloudflared` al mismo túnel
(`5f0e5c5b-a180-46f2-a090-44d151006a62`): el contenedor Docker `cloudflared`
(`restart=unless-stopped`) y el `cloudflared.service` de systemd dentro del LXC
(`enabled`). Ambos arrancan solos; es redundante pero inofensivo. Si algún día
se consolida en uno, ajustar el paso 4 del script y esta nota. Ver
[cloudflare-tunnel.md](cloudflare-tunnel.md).
- `coolify-sentinel` tiene `restart=no` (monitor no crítico); Coolify lo recrea,
por eso no está en la lista de contenedores core del guardián.
---
**Verificado:** 2026-07-08 — instalado y probado en vivo. `onboot=1`,
`coolify-autostart.service` `enabled`, guardián ejecutado con éxito (LXC arriba,
Docker listo, 6 contenedores core + túnel `running`). `systemd-analyze verify` sin
warnings.
+197
View File
@@ -0,0 +1,197 @@
# Runbook: Cloudflare Tunnel (coolify-tunnel)
Procedimiento para diagnosticar y corregir el túnel de Cloudflare que expone
Coolify (`coolify.urieljareth.org`) y las apps (`*.urieljareth.org`).
> **Dato clave:** el túnel está **gestionado desde el dashboard de Cloudflare**
> (remotely-managed), no por el `config.yml` local. El archivo
> `/etc/cloudflared/config.yml` dentro del contenedor solo contiene el UUID del
> túnel, las credenciales y el comodín `*.urieljareth.org`. **Todas las rutas de
> `coolify.urieljareth.org` (incluidas 6001/6002) viven en el dashboard / la API
> de Cloudflare.** Por eso un cambio de rutas NO se arregla editando archivos en
> el servidor — se arregla en el dashboard (rápido) o por la API (control total).
> La firma de esto en los logs es la línea `INF Updated to new configuration version=N`.
Síntomas típicos que llevan aquí:
- En la terminal de Coolify: `Terminal websocket connection lost. Reconnecting...`
- UI de Coolify inestable / reconexiones constantes / página en blanco al abrir una app
- En logs de `cloudflared`: `tls: first record does not look like a TLS handshake`
- Dominio de app con `502 Bad Gateway` o `530`
Documentos relacionados:
[ISSUE_cloudflare-tunnel_routing_websocket-tls-handshake.md](../ISSUE_cloudflare-tunnel_routing_websocket-tls-handshake.md) ·
[cloudflare-tunnel-coolify-agent_1.md](../cloudflare-tunnel-coolify-agent_1.md) ·
[issue-coolify-static-app-deploy.md](../issue-coolify-static-app-deploy.md)
---
## 1. Cargar entorno y verificar conexión
```powershell
# Secretos privados (gitignored) — necesario solo para llamadas a API
. .\.env.local.ps1
# Verifica config + SSH + Docker + API (sale 1 si algo falla)
.\scripts\Test-ProxmoxConnection.ps1
```
El SSH funciona con los defaults aunque no cargues `.env.local.ps1`.
---
## 2. Diagnóstico rápido (solo lectura)
### 2.1 Logs recientes del túnel
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker logs cloudflared --tail 40"
```
Buscar:
- `Registered tunnel connection ... protocol=http2` (x4) → túnel arriba y sano
- `tls: first record does not look like a TLS handshake` → ruta 6001/6002 en `https://`
- `Lost connection with the edge` / `failed to dial to edge with quic` → ver nota de `--protocol http2`
### 2.2 Ver la configuración ACTIVA del túnel (lo que realmente aplica)
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker logs cloudflared --tail 60" |
Select-String "Updated to new configuration"
```
Toma la línea con el `version=N` **más alto** (es la config vigente) y revisa el
JSON `ingress`. **Los puertos 6001 y 6002 deben decir `http://`, nunca `https://`.**
Cada vez que se guarda un cambio en el dashboard/API, `version` se incrementa.
### 2.3 Ver el `config.yml` local (opcional)
La imagen `cloudflared` es **distroless** (no tiene `cat`/`sh` propios para leer el
archivo directo), así que se copia con `docker cp` y se lee desde el LXC:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- sh -c 'docker cp cloudflared:/etc/cloudflared/config.yml /tmp/cf-config.yml && cat /tmp/cf-config.yml; rm -f /tmp/cf-config.yml'"
```
Esperado: solo `tunnel:`, `credentials-file:` y el comodín. Si aquí NO aparecen
las rutas de `coolify.urieljareth.org`, confirma que el túnel es dashboard-managed.
> Nunca leas `credentials.json` ni lo imprimas — contiene el secreto del túnel.
---
## 3. Reglas de oro de las rutas (ingress)
Cloudflare evalúa las rutas **de arriba hacia abajo**; las específicas van ANTES
del comodín. Esta es la configuración correcta (orden exacto):
| # | Hostname | Path | Servicio | Protocolo | Notas |
|---|----------|------|----------|-----------|-------|
| 1 | coolify.urieljareth.org | `/build/*` | `192.168.0.117:8000` | **http** | Build logs |
| 2 | coolify.urieljareth.org | `/project/*` | `192.168.0.117:8000` | **http** | **Crítico: UI de apps** |
| 3 | coolify.urieljareth.org | `/app/*` | `192.168.0.117:6001` | **http** ⚠️ | Websocket realtime |
| 4 | coolify.urieljareth.org | `/terminal/ws*` | `192.168.0.117:6002` | **http** ⚠️ | Terminal websocket |
| 5 | coolify.urieljareth.org | `*` | `192.168.0.117:8000` | **http** | Catch-all Coolify UI |
| 6 | `*.urieljareth.org` | `*` | `192.168.0.117:443` | **https** | Apps via Traefik (`noTLSVerify: true`) |
Reglas no negociables:
- **6001 y 6002 → `http://` siempre.** Esos puertos no hablan TLS; con `https://`
aparece `tls: first record does not look like a TLS handshake` y la terminal/realtime no conecta.
- **`/project/*` va ANTES de `/app/*`.** Si no, las URL con `/application/...` las
captura `/app/*` (puerto 6001) y la UI carga en blanco.
- **`/terminal/ws*` sin barra final** (no `/terminal/ws/` ni `/terminal/ws/*`).
- **`*.urieljareth.org` siempre al final**, con `noTLSVerify: true` (Traefik usa cert autofirmado interno).
---
## 4. Solución vía Dashboard (rápida, sin token)
1. Entrar a **Cloudflare Zero Trust → Networks → Tunnels → `coolify-tunnel` → Public Hostnames**.
2. Corregir cada ruta que esté mal según la tabla del paso 3. Lo más común:
- `/app/*` (6001): Service `HTTPS`**`HTTP`**.
- `/terminal/ws*` (6002): Service `HTTPS`**`HTTP`**; path sin barra final.
3. Guardar. **Cloudflared aplica el cambio solo, sin reinicio** (verás un nuevo `version=N`).
---
## 5. Solución vía API de Cloudflare (control total, requiere token)
Para gestionar el túnel sin tocar el dashboard (automatizable). Requiere un token
y los IDs cargados en `.env.local.ps1` (ver [Set-ProxmoxEnv.example.ps1](../../scripts/Set-ProxmoxEnv.example.ps1)):
```powershell
$env:CLOUDFLARE_API_TOKEN = "<token con Account:Cloudflare Tunnel:Edit + Zone:DNS:Edit>"
$env:CLOUDFLARE_ACCOUNT_ID = "<account id>"
$env:CLOUDFLARE_ZONE_ID = "<zone id de urieljareth.org>"
$env:CLOUDFLARE_TUNNEL_ID = "5f0e5c5b-a180-46f2-a090-44d151006a62"
```
Wrapper PowerShell: [scripts/Invoke-CloudflareApi.ps1](../../scripts/Invoke-CloudflareApi.ps1)
(mismo patrón que `Invoke-CoolifyApi.ps1`: Bearer token, `Invoke-RestMethod`).
```powershell
. .\.env.local.ps1
# Verificar que el token es válido y sus permisos
.\scripts\Invoke-CloudflareApi.ps1 -Path "/user/tokens/verify"
# Leer la configuración (ingress) actual del túnel
.\scripts\Invoke-CloudflareApi.ps1 `
-Path "/accounts/$($env:CLOUDFLARE_ACCOUNT_ID)/cfd_tunnel/$($env:CLOUDFLARE_TUNNEL_ID)/configurations"
# Aplicar la configuración corregida (PUT) — CONFIRMAR antes de ejecutar
$body = @{
config = @{
ingress = @(
@{ hostname = "coolify.urieljareth.org"; path = "/build/*"; service = "http://192.168.0.117:8000" }
@{ hostname = "coolify.urieljareth.org"; path = "/project/*"; service = "http://192.168.0.117:8000" }
@{ hostname = "coolify.urieljareth.org"; path = "/app/*"; service = "http://192.168.0.117:6001" }
@{ hostname = "coolify.urieljareth.org"; path = "/terminal/ws*"; service = "http://192.168.0.117:6002" }
@{ hostname = "coolify.urieljareth.org"; service = "http://192.168.0.117:8000" }
@{ hostname = "*.urieljareth.org"; service = "https://192.168.0.117:443"; originRequest = @{ noTLSVerify = $true } }
@{ service = "http_status:404" }
)
}
} | ConvertTo-Json -Depth 10
.\scripts\Invoke-CloudflareApi.ps1 -Method PUT `
-Path "/accounts/$($env:CLOUDFLARE_ACCOUNT_ID)/cfd_tunnel/$($env:CLOUDFLARE_TUNNEL_ID)/configurations" `
-BodyJson $body
```
> ⚠️ El último elemento `http_status:404` (sin hostname) es obligatorio o la API
> rechaza la configuración. La referencia completa de endpoints (DNS, crear túnel,
> obtener token) está en [cloudflare-tunnel-coolify-agent_1.md](../cloudflare-tunnel-coolify-agent_1.md).
**Cualquier `PUT`/`POST` a Cloudflare es un cambio de estado → confirmar con el usuario y
capturar el estado actual (paso 2.2) antes de aplicar, para tener rollback.**
---
## 6. Verificación
```powershell
# Debe aparecer un version=N mayor con 6001/6002 ya en http://
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker logs cloudflared --tail 10"
```
Luego en el navegador: recargar la UI de Coolify con **Ctrl+Shift+R** y abrir una
terminal en cualquier contenedor. Debe conectar sin `Terminal websocket connection lost`.
---
## Notas
- Los errores `tls: first record does not look like a TLS handshake` previos al
cambio son **residuales** de conexiones ya abiertas; desaparecen solos.
- Si el túnel se cae cada ~5 min con `failed to dial to edge with quic: timeout`,
el contenedor debe correr con `--protocol http2` (UDP/7844 suele estar bloqueado
en redes domésticas). Ver PASO 6 de [cloudflare-tunnel-coolify-agent_1.md](../cloudflare-tunnel-coolify-agent_1.md).
- `cloudflared` es distroless: para leer archivos internos usa `docker cp`, no `cat` directo.
---
**Verificado:** 2026-06-03 — corregidas rutas `/app/*` (6001) y `/terminal/ws*` (6002)
de `https://` a `http://` desde el dashboard; terminal de Coolify operativa
(config `version=16`).
+46
View File
@@ -91,6 +91,52 @@ Resultado verificado tras recuperacion:
- container Nextcloud: `running healthy`
- redirects HTTPS probados sin `Location: http://...`
## Si el contenedor esta healthy pero la UI sale vacia
Verificar primero desde fuera:
```powershell
curl.exe -k -sS --max-time 30 https://nextcloudsuite.urieljareth.org/status.php
curl.exe -k -sS -o NUL -w "status=%{http_code} total=%{time_total} starttransfer=%{time_starttransfer}`n" --max-time 30 https://nextcloudsuite.urieljareth.org/login
```
Verificar estado interno:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec nextcloud-hdcdpkm0jko3qqvn5683ercc php /app/www/public/occ status"
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec nextcloud-hdcdpkm0jko3qqvn5683ercc php /app/www/public/occ user:list"
```
Si el usuario existe pero no aparecen archivos, reindexar solo ese usuario:
```powershell
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec nextcloud-hdcdpkm0jko3qqvn5683ercc php /app/www/public/occ files:scan --path='urieljareth/files'"
```
Resultado verificado el 2026-06-10:
- `status.php`: `installed=true`, `maintenance=false`
- `/login`: `200 OK`, ~0.4s
- `urieljareth/files`: 143 archivos, 151 MB
- `files:scan`: 143 archivos, 0 errores
## PHP-FPM saturado
Los logs han mostrado:
```text
server reached pm.max_children setting (5), consider raising it
```
El override persistente esta en:
```text
/config/php/www2.conf
```
Ese cambio requiere recargar/reiniciar solo el contenedor Nextcloud para aplicar.
No requiere reiniciar `coolify-proxy`, Cloudflare ni otros proyectos.
## Pendiente tras reset de volumenes
Si el volumen de configuracion/base de datos fue eliminado, Nextcloud queda como
+59
View File
@@ -0,0 +1,59 @@
{
"name": "proxmox-coolify-manager-tools",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxmox-coolify-manager-tools",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.61.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"name": "proxmox-coolify-manager-tools",
"version": "1.0.0",
"private": true,
"description": "Node tooling for the coolify-deploy skill (post-deploy browser verification).",
"dependencies": {
"playwright": "^1.61.1"
}
}
+268
View File
@@ -0,0 +1,268 @@
# Aplica el parche enterprise de Chatwoot alineado al comando original:
#
# docker exec -i "$(docker ps -q --filter 'name=pgvector')" psql \
# -U postgres -d chatwoot -c "UPDATE ... WHERE name = '...';"
#
# Convencion del repo: Proxmox 192.168.0.200 -> LXC 102 (coolify) -> docker.
#
# Criterio de exito: psql imprime 3 lineas "UPDATE 1" (una por sentencia).
# Tras aplicar, NO pulsar "Refresh" en /super_admin/settings.
#
# Uso:
# .\scripts\Apply-ChatwootEnterprisePatch.ps1
# .\scripts\Apply-ChatwootEnterprisePatch.ps1 -DryRun
# .\scripts\Apply-ChatwootEnterprisePatch.ps1 -Container "postgres-c11xzy2tx2cdapm32f5b89vy"
[CmdletBinding()]
param(
[switch]$DryRun,
[string]$Container = "",
[string]$LxcId = "102",
[string]$ProxmoxHost = $(if ($env:PROXMOX_HOST) { $env:PROXMOX_HOST } else { "192.168.0.200" }),
[string]$SshKey = $(if ($env:PROXMOX_SSH_KEY) { $env:PROXMOX_SSH_KEY } else { "C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win" })
)
# Encoding UTF-8 sin BOM (BOM rompe el shebang #!/bin/bash en Linux).
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$ErrorActionPreference = "Stop"
if (-not (Test-Path -LiteralPath $SshKey)) {
throw "No se encontro la clave SSH: $SshKey"
}
function Invoke-Remote {
param([string]$RemoteCmd)
$args = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=15"
"-o", "StrictHostKeyChecking=no"
"-i", $SshKey
"root@$ProxmoxHost"
$RemoteCmd
)
return & ssh @args
}
# --- 1) Resolver contenedor Postgres de Chatwoot --------------------------
if (-not $Container) {
$detect = @'
#!/bin/bash
pct exec __LXC__ -- bash -lc 'docker ps --format "{{.Names}}" | grep -Ei "c11xzy2tx2cdapm32f5b89vy.*(pgvector|postgres|db)" | head -n1'
'@
$detect = $detect.Replace('__LXC__', $LxcId)
$tmpDetect = [IO.Path]::GetTempFileName() + ".sh"
[IO.File]::WriteAllText($tmpDetect, $detect, $utf8NoBom)
try {
$localName = Split-Path -Leaf $tmpDetect
$scpArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=15"
"-o", "StrictHostKeyChecking=no"
"-i", $SshKey
$tmpDetect
"root@${ProxmoxHost}:/tmp/$localName"
)
& scp @scpArgs | Out-Null
if ($LASTEXITCODE -ne 0) { throw "scp detect fallo (exit $LASTEXITCODE)." }
Write-Host "[*] Buscando contenedor Postgres de Chatwoot en LXC $LxcId ..."
$cand = Invoke-Remote "bash /tmp/$localName"
Invoke-Remote "rm -f /tmp/$localName" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Listado remoto fallo (exit $LASTEXITCODE)." }
$Container = @($cand | Where-Object { $_ -match '\S' })[0]
if (-not $Container) {
throw "No se encontro contenedor. Pasa -Container explicito (ej: postgres-c11xzy2tx2cdapm32f5b89vy)."
}
} finally {
Remove-Item -LiteralPath $tmpDetect -ErrorAction SilentlyContinue
}
}
Write-Host "[+] Contenedor: $Container"
# --- 1b) Detectar credenciales reales desde docker inspect ----------------
$inspectScript = @'
#!/bin/bash
pct exec __LXC__ -- docker inspect __CT__ --format '{{range .Config.Env}}{{println .}}{{end}}' | awk -F= '
/^POSTGRES_USER=/ { u=$2 }
/^POSTGRES_DB=/ { d=$2 }
/^POSTGRES_PASSWORD=/ { p=$2 }
/^POSTGRES_HOST=/ { h=$2 }
/^SERVICE_NAME_POSTGRES_5432_TCP=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES=/ { skip=1; next }
/^SERVICE_USER_POSTGRES=/ { skip=1; next }
/^SERVICE_PASSWORD_POSTGRES=/ { skip=1; next }
/^COOLIFY_CONTAINER_NAME=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES_5432_TCP_PORT=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES_PORT=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES_PORT_5432_TCP=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES_PORT_5432_TCP_PROTO=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES_PORT_5432_TCP_ADDR=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES_PORT_5432_TCP_PORT=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES_HOST=/ { skip=1; next }
/^SERVICE_NAME_POSTGRES_/ { skip=1; next }
/^POSTGRES_HOST=/ { skip=1; next }
/^POSTGRES_PORT=/ { skip=1; next }
/^POSTGRES_USERNAME=/ { skip=1; next }
/^POSTGRES_PASSWORD=/ { skip=1; next }
END { print "USER=" u; print "DB=" d; print "PASSWORD=" p }
'
'@
$inspectScript = $inspectScript.Replace('__LXC__', $LxcId).Replace('__CT__', $Container)
$tmpInspect = [IO.Path]::GetTempFileName() + ".sh"
[IO.File]::WriteAllText($tmpInspect, $inspectScript, $utf8NoBom)
try {
$localName = Split-Path -Leaf $tmpInspect
$scpArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=15"
"-o", "StrictHostKeyChecking=no"
"-i", $SshKey
$tmpInspect
"root@${ProxmoxHost}:/tmp/$localName"
)
& scp @scpArgs | Out-Null
if ($LASTEXITCODE -ne 0) { throw "scp inspect fallo (exit $LASTEXITCODE)." }
$envOut = Invoke-Remote "bash /tmp/$localName"
Invoke-Remote "rm -f /tmp/$localName" | Out-Null
} finally {
Remove-Item -LiteralPath $tmpInspect -ErrorAction SilentlyContinue
}
$pgUser = ($envOut | Where-Object { $_ -match '^USER=' } | ForEach-Object { $_ -split '=', 2 } | Select-Object -Last 1) | Select-Object -First 1
$pgDb = ($envOut | Where-Object { $_ -match '^DB=' } | ForEach-Object { $_ -split '=', 2 } | Select-Object -Last 1) | Select-Object -First 1
$pgPass = ($envOut | Where-Object { $_ -match '^PASSWORD=' } | ForEach-Object { $_ -split '=', 2 } | Select-Object -Last 1) | Select-Object -First 1
if (-not $pgUser) { $pgUser = "postgres" }
if (-not $pgDb) { $pgDb = "chatwoot" }
Write-Host ("[+] PG user={0} db={1} password={2}" -f $pgUser, $pgDb, ($pgPass -replace '.', '*'))
# --- 2) SQL (3 sentencias identicas al comando original) -------------------
$sql = @'
UPDATE public.installation_configs
SET serialized_value = '"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: enterprise\n"'
WHERE name = 'INSTALLATION_PRICING_PLAN';
UPDATE public.installation_configs
SET serialized_value = '"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: 10000\n"'
WHERE name = 'INSTALLATION_PRICING_PLAN_QUANTITY';
UPDATE public.installation_configs
SET serialized_value = '"--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess\nvalue: e04t63ee-5gg8-4b94-8914-ed8137a7d938\n"'
WHERE name = 'INSTALLATION_IDENTIFIER';
'@
# Script bash que envuelve el original: docker exec -i CT psql -U USER -d DB < sql.
$applyScript = @'
#!/bin/bash
pct exec __LXC__ -- docker exec -i __CT__ env PGPASSWORD=__PASS__ psql -U __USER__ -d __DB__ -v ON_ERROR_STOP=1 < "$1"
'@
$applyScript = $applyScript.Replace('__LXC__', $LxcId).Replace('__CT__', $Container).Replace('__USER__', $pgUser).Replace('__DB__', $pgDb).Replace('__PASS__', $pgPass)
$verifyScript = @'
#!/bin/bash
pct exec __LXC__ -- docker exec -i __CT__ env PGPASSWORD=__PASS__ psql -U __USER__ -d __DB__ -c "SELECT name, serialized_value FROM public.installation_configs WHERE name IN ('INSTALLATION_PRICING_PLAN','INSTALLATION_PRICING_PLAN_QUANTITY','INSTALLATION_IDENTIFIER') ORDER BY name;"
'@
$verifyScript = $verifyScript.Replace('__LXC__', $LxcId).Replace('__CT__', $Container).Replace('__USER__', $pgUser).Replace('__DB__', $pgDb).Replace('__PASS__', $pgPass)
if ($DryRun) {
Write-Host "[dry-run] SQL que se aplicaria:"
Write-Host "------"
Write-Host $sql
Write-Host "------"
Write-Host "[dry-run] apply.sh:"
Write-Host "------"
Write-Host $applyScript
Write-Host "------"
return
}
# --- 3) Subir SQL + scripts y ejecutar ------------------------------------
$tmpSql = [IO.Path]::GetTempFileName() + ".sql"
$tmpApply = [IO.Path]::GetTempFileName() + ".sh"
$tmpVerify = [IO.Path]::GetTempFileName() + ".sh"
$remoteSql = "/tmp/chatwoot-enterprise-patch.sql"
$remoteApply = "/tmp/chatwoot-apply.sh"
$remoteVerify = "/tmp/chatwoot-verify.sh"
try {
[IO.File]::WriteAllText($tmpSql, $sql, $utf8NoBom)
[IO.File]::WriteAllText($tmpApply, $applyScript, $utf8NoBom)
[IO.File]::WriteAllText($tmpVerify, $verifyScript, $utf8NoBom)
# Subir SQL al HOST Proxmox (no al LXC; lo lee bash local y redirige a pct exec).
$sqlLocal = Split-Path -Leaf $tmpSql
$scpArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=15"
"-o", "StrictHostKeyChecking=no"
"-i", $SshKey
$tmpSql
"root@${ProxmoxHost}:$remoteSql"
)
& scp @scpArgs | Out-Null
if ($LASTEXITCODE -ne 0) { throw "scp SQL fallo (exit $LASTEXITCODE)." }
# Subir apply.sh y verify.sh al host.
$applyLocal = Split-Path -Leaf $tmpApply
$scpArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=15"
"-o", "StrictHostKeyChecking=no"
"-i", $SshKey
$tmpApply
"root@${ProxmoxHost}:$remoteApply"
)
& scp @scpArgs | Out-Null
if ($LASTEXITCODE -ne 0) { throw "scp apply fallo (exit $LASTEXITCODE)." }
$verifyLocal = Split-Path -Leaf $tmpVerify
$scpArgs = @(
"-o", "BatchMode=yes"
"-o", "ConnectTimeout=15"
"-o", "StrictHostKeyChecking=no"
"-i", $SshKey
$tmpVerify
"root@${ProxmoxHost}:$remoteVerify"
)
& scp @scpArgs | Out-Null
if ($LASTEXITCODE -ne 0) { throw "scp verify fallo (exit $LASTEXITCODE)." }
Write-Host "[*] Aplicando 3 UPDATE en $Container ..."
$output = Invoke-Remote "bash $remoteApply $remoteSql"
$exitCode = $LASTEXITCODE
Write-Host "----- psql output -----"
$output | ForEach-Object { Write-Host $_ }
Write-Host "-----------------------"
}
finally {
Remove-Item -LiteralPath $tmpSql, $tmpApply, $tmpVerify -ErrorAction SilentlyContinue
}
# --- 4) Validacion --------------------------------------------------------
$updateLines = @($output | Where-Object { $_ -match '^UPDATE\s+1\s*$' })
Write-Host ("UPDATE 1 count = {0}" -f $updateLines.Count)
if ($exitCode -ne 0) {
Invoke-Remote "rm -f $remoteSql $remoteApply $remoteVerify" | Out-Null
throw "psql finalizo con exit code $exitCode."
}
if ($updateLines.Count -ne 3) {
Invoke-Remote "rm -f $remoteSql $remoteApply $remoteVerify" | Out-Null
throw "Se esperaban 3 lineas 'UPDATE 1', se obtuvieron $($updateLines.Count)."
}
# --- 5) Verificacion SELECT -----------------------------------------------
Write-Host "[*] Verificando valores finales ..."
$verify = Invoke-Remote "bash $remoteVerify"
$verify | ForEach-Object { Write-Host $_ }
# Limpieza final.
Invoke-Remote "rm -f $remoteSql $remoteApply $remoteVerify" | Out-Null
Write-Host ""
Write-Host "[OK] Parche enterprise aplicado correctamente (3/3 UPDATE 1)."
Write-Host " NO pulsar 'Refresh' en /super_admin/settings."
+166
View File
@@ -0,0 +1,166 @@
<#
.SYNOPSIS
Installs power-outage auto-start for the Coolify stack on the Proxmox host.
.DESCRIPTION
Makes sure that after a power outage (host reboot) LXC 102 (Coolify) and its
Cloudflare tunnel come back up on their own. It does three things:
1. Sets the LXC to start on boot -> pct set <id> --onboot 1 --startup order=1,up=30
2. Installs a self-healing guardian -> /usr/local/bin/coolify-autostart.sh
+ systemd unit /etc/systemd/system/coolify-autostart.service
3. Enables the guardian so it runs on every boot.
The guardian is idempotent: on each boot it ensures the LXC is running, waits
for Docker inside it, and starts any core Coolify container or tunnel that
isn't up. Everything is additive and reversible (see -Uninstall).
Source files live in scripts/host/ and are pushed over SSH (base64, so no
quoting issues and CRLF is normalised to LF on the host).
.PARAMETER VerifyOnly
Only report current state (onboot flag, unit enabled, log tail). No changes.
.PARAMETER SkipOnboot
Install/enable the guardian but do NOT touch the LXC onboot flag.
.PARAMETER RunNow
After installing, trigger the guardian once (systemctl start) to test it and
print the resulting log. Safe: the guardian only starts things, never stops.
.PARAMETER Uninstall
Remove the guardian (disable + delete unit + script). Does NOT clear onboot.
.EXAMPLE
.\scripts\Install-CoolifyAutostart.ps1 -RunNow
.EXAMPLE
.\scripts\Install-CoolifyAutostart.ps1 -VerifyOnly
#>
[CmdletBinding()]
param(
[switch]$VerifyOnly,
[switch]$SkipOnboot,
[switch]$RunNow,
[switch]$Uninstall
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot\ProxmoxAgent.ps1"
$config = Get-ProxmoxConfig
$lxc = $config.CoolifyLxc
$svcName = "coolify-autostart.service"
$svcPath = "/etc/systemd/system/$svcName"
$binPath = "/usr/local/bin/coolify-autostart.sh"
$logPath = "/var/log/coolify-autostart.log"
$localSh = Join-Path $PSScriptRoot "host\coolify-autostart.sh"
$localSvc = Join-Path $PSScriptRoot "host\coolify-autostart.service"
function Write-Section([string]$Text) {
Write-Host ""
Write-Host "== $Text ==" -ForegroundColor Cyan
}
# Run a remote command, normalising CRLF -> LF first. Multi-line command strings
# authored in this (CRLF) file would otherwise carry a trailing '\r' on every
# line, which breaks commands whose last token matters (e.g. unit names).
function Invoke-Remote([string]$Command) {
Invoke-ProxmoxSshCommand -Command ($Command -replace "`r`n", "`n")
}
# Push text to the host as LF-normalised bytes via base64 (avoids every
# quoting/CRLF pitfall of heredocs over SSH). Pass -Content to push a
# pre-built string instead of a file.
function Push-TextFile {
param(
[string]$LocalPath,
[Parameter(Mandatory)][string]$RemotePath,
[string]$Content
)
if ($Content) { $text = $Content }
else { $text = Get-Content -LiteralPath $LocalPath -Raw }
$text = $text -replace "`r`n", "`n"
$bytes = [Text.Encoding]::UTF8.GetBytes($text)
$b64 = [Convert]::ToBase64String($bytes)
Invoke-ProxmoxSshCommand -Command "echo '$b64' | base64 -d > '$RemotePath'"
}
# ---------------------------------------------------------------------------
# VERIFY-ONLY
# ---------------------------------------------------------------------------
if ($VerifyOnly) {
Write-Section "Verification (read-only) - LXC $lxc"
Invoke-Remote @"
echo '--- onboot / startup on LXC $lxc ---'
grep -Ei 'onboot|startup' /etc/pve/lxc/$lxc.conf 2>/dev/null || echo '(onboot not set -> defaults to 0, will NOT auto-start)'
echo '--- guardian unit ---'
systemctl is-enabled $svcName 2>/dev/null || echo '($svcName not installed/enabled)'
echo '--- guardian script present? ---'
test -x $binPath && echo 'present ($binPath)' || echo 'missing ($binPath)'
echo '--- last log lines ---'
tail -n 15 $logPath 2>/dev/null || echo '(no log yet)'
"@
return
}
# ---------------------------------------------------------------------------
# UNINSTALL
# ---------------------------------------------------------------------------
if ($Uninstall) {
Write-Section "Uninstalling guardian (onboot flag left untouched)"
Invoke-Remote @"
systemctl disable --now $svcName 2>/dev/null || true
rm -f $svcPath $binPath
systemctl daemon-reload
echo 'guardian removed. To also stop auto-start of the LXC: pct set $lxc --onboot 0'
"@
Write-Host "Done. onboot flag NOT changed." -ForegroundColor Yellow
return
}
# ---------------------------------------------------------------------------
# INSTALL
# ---------------------------------------------------------------------------
foreach ($f in @($localSh, $localSvc)) {
if (-not (Test-Path -LiteralPath $f)) { throw "Missing source file: $f" }
}
# Bake the configured LXC id into the unit so the guardian targets the right one.
$svcText = (Get-Content -LiteralPath $localSvc -Raw) -replace "`r`n", "`n"
$svcText = $svcText -replace "(?m)^\[Service\]\n", "[Service]`nEnvironment=COOLIFY_LXC=$lxc`n"
Write-Section "1/4 Push guardian script + unit to host"
Push-TextFile -LocalPath $localSh -RemotePath $binPath
Push-TextFile -RemotePath $svcPath -Content $svcText
Invoke-Remote "chmod 755 '$binPath' && bash -n '$binPath' && echo 'script pushed + syntax OK'"
Write-Host " script -> $binPath" -ForegroundColor Green
Write-Host " unit -> $svcPath (COOLIFY_LXC=$lxc)" -ForegroundColor Green
if (-not $SkipOnboot) {
Write-Section "2/4 Enable LXC $lxc onboot (native Proxmox auto-start)"
Invoke-Remote "pct set $lxc --onboot 1 --startup order=1,up=30 && echo 'onboot=1 set' && grep -Ei 'onboot|startup' /etc/pve/lxc/$lxc.conf"
} else {
Write-Section "2/4 Skipping onboot flag (-SkipOnboot)"
}
Write-Section "3/4 Enable guardian on every boot"
Invoke-Remote "systemctl daemon-reload && systemctl enable $svcName && echo 'guardian enabled'"
Write-Section "4/4 Verify"
Invoke-Remote @"
printf 'onboot: '; grep -Ei 'onboot' /etc/pve/lxc/$lxc.conf 2>/dev/null || echo '(not set)'
printf 'guardian enabled: '; systemctl is-enabled $svcName 2>/dev/null || echo 'no'
printf 'script executable: '; test -x $binPath && echo yes || echo no
"@
if ($RunNow) {
Write-Section "RunNow Triggering guardian once (test)"
Invoke-Remote "systemctl start $svcName; sleep 2; systemctl is-active $svcName; echo '--- log ---'; tail -n 25 $logPath"
}
Write-Host ""
Write-Host "Done. After a power outage the host will auto-start LXC $lxc, Docker, the" -ForegroundColor Green
Write-Host "Coolify core containers and the Cloudflare tunnel. Re-run with -VerifyOnly" -ForegroundColor Green
Write-Host "to check state, or -Uninstall to remove the guardian." -ForegroundColor Green
+77
View File
@@ -0,0 +1,77 @@
# Thin wrapper for the Cloudflare API (Bearer token).
# Mirrors coolify_skill/scripts/Invoke-CoolifyApi.ps1.
# Requires CLOUDFLARE_API_TOKEN (load from .env.local.ps1).
# Optional: CLOUDFLARE_API_URL (defaults to the public Cloudflare v4 base).
# Useful IDs to keep in env: CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ZONE_ID, CLOUDFLARE_TUNNEL_ID.
#
# Examples:
# .\scripts\Invoke-CloudflareApi.ps1 -Path "/user/tokens/verify"
# .\scripts\Invoke-CloudflareApi.ps1 -Path "/accounts/$($env:CLOUDFLARE_ACCOUNT_ID)/cfd_tunnel/$($env:CLOUDFLARE_TUNNEL_ID)/configurations"
# .\scripts\Invoke-CloudflareApi.ps1 -Method PUT -Path "/accounts/.../cfd_tunnel/.../configurations" -BodyJson $body
param(
[ValidateSet("GET", "POST", "PUT", "PATCH", "DELETE")]
[string]$Method = "GET",
[Parameter(Mandatory = $true)]
[string]$Path,
[string]$BodyJson,
[switch]$Raw
)
$ErrorActionPreference = "Stop"
$baseUrl = if ($env:CLOUDFLARE_API_URL) {
$env:CLOUDFLARE_API_URL
}
else {
"https://api.cloudflare.com/client/v4"
}
if ([string]::IsNullOrWhiteSpace($env:CLOUDFLARE_API_TOKEN)) {
throw "Set CLOUDFLARE_API_TOKEN before calling the Cloudflare API."
}
$baseUrl = $baseUrl.TrimEnd("/")
$cleanPath = if ($Path.StartsWith("/")) { $Path } else { "/$Path" }
$uri = "$baseUrl$cleanPath"
$headers = @{
Authorization = "Bearer $($env:CLOUDFLARE_API_TOKEN)"
Accept = "application/json"
}
$request = @{
Method = $Method
Uri = $uri
Headers = $headers
TimeoutSec = 30
}
if ($PSBoundParameters.ContainsKey("BodyJson")) {
try {
$null = $BodyJson | ConvertFrom-Json
}
catch {
throw "BodyJson is not valid JSON: $($_.Exception.Message)"
}
$request.Body = $BodyJson
$request.ContentType = "application/json"
}
try {
$response = Invoke-RestMethod @request
}
catch {
throw "Cloudflare API request failed: $($_.Exception.Message)"
}
if ($Raw) {
$response
}
else {
$response | ConvertTo-Json -Depth 50
}
+8
View File
@@ -12,4 +12,12 @@ $env:PROXMOX_COOLIFY_LXC = "102"
$env:COOLIFY_API_URL = "https://coolify.urieljareth.org/api/v1"
$env:COOLIFY_TOKEN = "REPLACE_WITH_COOLIFY_TOKEN"
# Cloudflare API (optional — for full tunnel/DNS control via scripts\Invoke-CloudflareApi.ps1)
# Token perms: Account → Cloudflare Tunnel → Edit, and Zone → DNS → Edit for urieljareth.org
$env:CLOUDFLARE_API_URL = "https://api.cloudflare.com/client/v4"
$env:CLOUDFLARE_API_TOKEN = "REPLACE_WITH_CLOUDFLARE_TOKEN"
$env:CLOUDFLARE_ACCOUNT_ID = "REPLACE_WITH_ACCOUNT_ID"
$env:CLOUDFLARE_ZONE_ID = "REPLACE_WITH_ZONE_ID"
$env:CLOUDFLARE_TUNNEL_ID = "5f0e5c5b-a180-46f2-a090-44d151006a62"
Write-Host "Example Proxmox/Coolify environment loaded. Replace token secrets before API tests."
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Auto-start Coolify LXC + Docker stack + Cloudflare tunnel after boot
# Managed by the Proxmox & Coolify Manager repo (scripts/host/coolify-autostart.service).
# Run after Proxmox has processed onboot guests and the network is up. This is a
# self-healing safety net on top of the LXC onboot flag and Docker restart
# policies, not a replacement for them.
After=pve-guests.service network-online.target
Wants=pve-guests.service network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/coolify-autostart.sh
RemainAfterExit=yes
TimeoutStartSec=300
[Install]
WantedBy=multi-user.target
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# coolify-autostart.sh
# -----------------------------------------------------------------------------
# Ensures the Coolify stack survives a power outage: on every host boot it
# starts LXC 102 (if not already up via onboot), waits for Docker inside the
# LXC, and makes sure the core Coolify containers + the Cloudflare tunnel are
# running. Idempotent and self-healing: safe to run any number of times.
#
# Installed by scripts/Install-CoolifyAutostart.ps1 to /usr/local/bin/ and
# invoked by the systemd unit coolify-autostart.service on multi-user.target.
# -----------------------------------------------------------------------------
set -uo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LXC_ID="${COOLIFY_LXC:-102}"
LOG="/var/log/coolify-autostart.log"
MAX_WAIT="${COOLIFY_MAX_WAIT:-180}" # seconds to wait for docker inside the LXC
# Dependencies first, then the app, proxy and tunnel. These all normally come
# up on their own via Docker restart policies; this loop only heals the ones
# that didn't (e.g. restart=no, or a wedged start).
CORE_CONTAINERS=(coolify-db coolify-redis coolify-realtime coolify coolify-proxy cloudflared)
log() { echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }
log "=== coolify-autostart start (LXC ${LXC_ID}) ==="
# 1. Ensure the LXC is running -------------------------------------------------
status="$(pct status "$LXC_ID" 2>/dev/null | awk '{print $2}')"
if [ "$status" != "running" ]; then
log "LXC ${LXC_ID} status='${status:-unknown}' -> starting"
if pct start "$LXC_ID"; then
log "pct start issued"
else
log "ERROR: pct start ${LXC_ID} failed"
fi
else
log "LXC ${LXC_ID} already running"
fi
# 2. Wait for the Docker daemon inside the LXC to respond ----------------------
waited=0
until pct exec "$LXC_ID" -- docker info >/dev/null 2>&1; do
if [ "$waited" -ge "$MAX_WAIT" ]; then
log "ERROR: docker not ready after ${MAX_WAIT}s -> aborting"
exit 1
fi
sleep 5
waited=$((waited + 5))
done
log "docker ready after ${waited}s"
# 3. Ensure the core Coolify containers + tunnel container are running ---------
for c in "${CORE_CONTAINERS[@]}"; do
st="$(pct exec "$LXC_ID" -- docker inspect -f '{{.State.Running}}' "$c" 2>/dev/null || echo missing)"
case "$st" in
true) log "container ${c}: running" ;;
missing) log "WARN container ${c}: not found (skipping)" ;;
*)
log "container ${c}: running=${st} -> starting"
if pct exec "$LXC_ID" -- docker start "$c" >/dev/null 2>&1; then
log "started ${c}"
else
log "ERROR: could not start ${c}"
fi
;;
esac
done
# 4. Ensure the systemd cloudflared tunnel inside the LXC is up ----------------
# (second connector to the same tunnel; belt-and-suspenders alongside the
# cloudflared Docker container above.)
if pct exec "$LXC_ID" -- systemctl is-enabled cloudflared >/dev/null 2>&1; then
if pct exec "$LXC_ID" -- systemctl start cloudflared >/dev/null 2>&1; then
log "cloudflared.service ensured up"
else
log "WARN: cloudflared.service start returned non-zero"
fi
fi
log "=== coolify-autostart done ==="