Compare commits
3
Commits
cd998ce6b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b7209dcc1 | ||
|
|
f587a8baa2 | ||
|
|
e7d1c33cd5 |
@@ -9,3 +9,20 @@ 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
|
||||
|
||||
# Used by gitea_skill (Invoke-GiteaApi.ps1, New-GiteaRepo.ps1, Sync-GiteaRemote.ps1).
|
||||
# Self-hosted Gitea behind the Cloudflare tunnel. Generate a token at:
|
||||
# <GITEA_URL>/user/settings/applications
|
||||
# Scopes needed: read:repository, write:repository, read:user (and write:admin
|
||||
# only if you manage other users). git push uses a one-shot http.extraHeader
|
||||
# injected by Sync-GiteaRemote.ps1 — the token is NOT persisted to .git/config.
|
||||
GITEA_URL=https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org
|
||||
GITEA_USER=urieljareth
|
||||
GITEA_TOKEN=REPLACE_WITH_GITEA_TOKEN
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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,48 @@ 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"
|
||||
|
||||
# Gitea API (requires GITEA_URL + GITEA_TOKEN) — self-hosted git hosting
|
||||
.\gitea_skill\scripts\Test-GiteaConnection.ps1
|
||||
.\gitea_skill\scripts\Get-GiteaRepo.ps1 -List
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/user"
|
||||
.\gitea_skill\scripts\Sync-GiteaRemote.ps1 -AppPath .\my-app -CreateIfMissing -Force
|
||||
```
|
||||
|
||||
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 +118,11 @@ 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).
|
||||
- [gitea_skill/SKILL.md](gitea_skill/SKILL.md) + [gitea_skill/TOOLS.md](gitea_skill/TOOLS.md) — self-hosted Gitea skill: list/create/mirror repos, wire a `gitea` remote, push headlessly via one-shot `http.extraHeader` (token never persisted). Distinct from deploy_skill (which targets Coolify); this manages the git hosting layer.
|
||||
- `PROXMOX/`, `proxmox-agent/`, `proxmox-skill/` are **legacy pointer folders** — they only redirect to the live docs above. Don't add content there.
|
||||
|
||||
@@ -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 }
|
||||
@@ -25,10 +25,20 @@ 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`.
|
||||
- `gitea_skill/`: skill para operar la instancia Gitea self-hosted (crear/listar/
|
||||
buscar repos, migrar desde GitHub, wire de un remoto `gitea` y push headless
|
||||
con token inyectado por invocacion — sin persistirlo en `.git/config`).
|
||||
Necesita `GITEA_URL`, `GITEA_USER`, `GITEA_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.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ $request = @{
|
||||
Method = $Method
|
||||
Uri = $uri
|
||||
Headers = $headers
|
||||
TimeoutSec = 30
|
||||
TimeoutSec = 240
|
||||
}
|
||||
|
||||
if ($PSBoundParameters.ContainsKey("BodyJson")) {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
---
|
||||
name: coolify-deploy
|
||||
description: Deploy new projects to the local self-hosted Coolify instance from end to end. Use when the user wants to take a local project (or a new idea) and ship it to Coolify: scaffold a compliant Dockerfile/compose, create a GitHub repo, push, register the app in Coolify, deploy it, and verify. Also covers rollback to a previous commit and redeploys. Distinct from coolify-agent (which operates already-deployed apps) and the proxmox-agent (host operations).
|
||||
---
|
||||
|
||||
# Coolify Deploy Skill
|
||||
|
||||
Use this skill to take a project from "local code" to "live on
|
||||
`https://<name>.urieljareth.org`". It wraps the existing repo tooling
|
||||
(Proxmox SSH, Coolify API, Cloudflare API) plus new GitHub scaffolding into a
|
||||
single end-to-end pipeline.
|
||||
|
||||
## ⚠️ Instance reality check (READ FIRST — verified 2026-07 on coolify.urieljareth.org, v4.1.2)
|
||||
|
||||
This instance's REST API is **partial**: the entire `/applications/*` namespace is
|
||||
**404** (create public/dockerfile/private-*, list, get, PATCH, `/envs`, `/logs`).
|
||||
`POST /services` only takes raw compose (no git repo). Consequences:
|
||||
|
||||
- You **cannot create OR configure a git-based build-from-source app via the API** here.
|
||||
`New-CoolifyApplication.ps1` (POST /applications/public) will 404.
|
||||
- To configure an app that **builds from a (private) git repo** — set its build pack to
|
||||
Docker Compose, its compose location, its **env vars**, and its **per-service domains** —
|
||||
you MUST drive the **web UI with Playwright**. Use `scripts/coolify-ui/*.mjs`
|
||||
(needs `COOLIFY_EMAIL`/`COOLIFY_PASSWORD` in `.env.local.ps1`).
|
||||
- What DOES work via API: `/resources` (inventory+status), `/projects`, `/security/keys`,
|
||||
`/services`, and **`GET /deploy?uuid=&force=true`** (trigger a deploy of any existing app),
|
||||
`GET /deployments/{uuid}` (status+logs).
|
||||
|
||||
Full playbook + gotchas (UTF-8 BOM breaks Coolify's YAML parser, "Reload Compose File" is
|
||||
mandatory, don't queue concurrent deploys, PowerShell `ReadAllText` for key payloads, etc.):
|
||||
**[`references/coolify-4.1.2-notes.md`](references/coolify-4.1.2-notes.md)**. Re-verify the API
|
||||
surface with `GET /version` if the instance was upgraded.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Deploy this project to Coolify"
|
||||
- "Create a new GitHub repo and ship it to Coolify"
|
||||
- "Roll back the Coolify app to yesterday's commit"
|
||||
- "Generate a Dockerfile that works on this Coolify instance"
|
||||
- "Validate my repo against the Coolify hard rules before pushing"
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Operating an already-deployed app (logs, restart, env edits) → use
|
||||
[`coolify_skill/SKILL.md`](../coolify_skill/SKILL.md) instead.
|
||||
- Proxmox host or LXC maintenance → use [`agent/SKILL.md`](../agent/SKILL.md).
|
||||
- Editing Cloudflare tunnel routes → dashboard-managed, see
|
||||
`docs/runbooks/cloudflare-tunnel.md`.
|
||||
|
||||
## Prerequisites (one-time setup)
|
||||
|
||||
1. `.env.local.ps1` must contain at minimum `COOLIFY_TOKEN` and `GITHUB_TOKEN`.
|
||||
See `references/templates/env.local.template.ps1`.
|
||||
2. `GITHUB_TOKEN` is a GitHub PAT (classic with `repo` scope, or fine-grained
|
||||
with `Contents: Read+Write` + `Metadata: Read`). Generate at
|
||||
https://github.com/settings/tokens.
|
||||
3. `git push` to GitHub uses Windows Credential Manager (`wincred`) — no need
|
||||
to embed the PAT in the git URL. The PAT is only for GitHub REST API calls
|
||||
(creating repos, listing).
|
||||
4. The authenticated GitHub account is `urieljarethbusiness-cpu` (verified
|
||||
via `git credential fill`). Override with `GITHUB_OWNER` if needed.
|
||||
5. **Operational verification needs Node + Playwright.** Run
|
||||
`npm install` once in the manager repo root (`H:\MegaSync\Proyectos\Proxmox & Coolify Manager`).
|
||||
Chromium binaries cache at `%LOCALAPPDATA%\ms-playwright`. If Node is
|
||||
missing, `Test-ServiceOnline.ps1` falls back to curl-only (browser layer
|
||||
is skipped, not failed).
|
||||
|
||||
## Pipeline
|
||||
|
||||
There are **two** pipelines, one per deploy strategy. Pick based on whether
|
||||
your project ships as a single Dockerfile-built app or as a multi-container
|
||||
Docker Compose stack.
|
||||
|
||||
### A. Single-app (Dockerfile) — `Publish-ProjectToCoolify.ps1`
|
||||
|
||||
```
|
||||
local project
|
||||
→ Initialize-CoolifyProject.ps1 (optional: scaffold Dockerfile/compose)
|
||||
→ Test-PreDeployChecklist.ps1 (gate; enforces AGENTS-coolify-apps.md)
|
||||
→ New-GitHubRepo.ps1 (create repo, idempotent)
|
||||
→ git push origin main (uses wincred, not the PAT)
|
||||
→ New-CoolifyApplication.ps1 (project + env + app + envs + deploy)
|
||||
→ Test-PostDeploy.ps1 (Coolify state: containers, proxy logs, sibling DNS)
|
||||
→ Test-ServiceOnline.ps1 (OPERATIONAL: HTTP 200 + Playwright page render)
|
||||
```
|
||||
|
||||
Use `Publish-ProjectToCoolify.ps1` to run all seven steps in sequence with one
|
||||
confirmation per step.
|
||||
|
||||
### B. Multi-container stack (Docker Compose) — `New-CoolifyService.ps1`
|
||||
|
||||
For projects that ship a `docker-compose.coolify.yml` (app + sibling DB +
|
||||
optional sidecars). Reads the local compose file and POSTs it to Coolify's
|
||||
`/services` endpoint — the modern replacement for the deprecated
|
||||
`/applications/dockercompose`.
|
||||
|
||||
```
|
||||
local project (with docker-compose.coolify.yml)
|
||||
→ Test-PreDeployChecklist.ps1 (gate; enforces AGENTS-coolify-apps.md)
|
||||
→ New-GitHubRepo.ps1 + git push (only if the repo isn't already on GitHub)
|
||||
→ New-CoolifyService.ps1 (project + env + service + urls + envs + deploy)
|
||||
→ Test-PostDeploy.ps1 (Coolify state: containers, proxy logs, sibling DNS)
|
||||
→ Test-ServiceOnline.ps1 (OPERATIONAL: HTTP 200 + Playwright page render)
|
||||
```
|
||||
|
||||
The compose MUST satisfy the hard rules (no published 80/443, DB by service
|
||||
name, `SERVICE_FQDN_<svc>_<port>` placeholders so Coolify wires routes, named
|
||||
volumes, healthchecks). The `urls` payload in `New-CoolifyService.ps1` maps
|
||||
one service (e.g. `web`) to the primary FQDN; pass `-ExtraUrls` for
|
||||
multi-public-service stacks.
|
||||
|
||||
The final step (`Test-ServiceOnline.ps1`) is the **definition of done** for
|
||||
both pipelines: the service is only considered live when (a) the FQDN returns
|
||||
HTTP 2xx via curl and (b) a real Chromium browser loads the page with no
|
||||
uncaught JS errors. It retries for ~80 seconds to allow Traefik/TLS warm-up.
|
||||
|
||||
For rollback: `Invoke-CoolifyRollback.ps1` pushes the chosen commit to a
|
||||
`rollback/<sha>` branch (main stays untouched), repoints the Coolify app, and
|
||||
redeploys.
|
||||
|
||||
## Hard rules (must hold for every app)
|
||||
|
||||
Read [`docs/AGENTS-coolify-apps.md`](../docs/AGENTS-coolify-apps.md) for the
|
||||
canonical version. Summary:
|
||||
|
||||
1. **Sibling DB by service name, never `localhost`.**
|
||||
2. **External `coolify` network** if the app needs shared Coolify services.
|
||||
3. **Never publish host ports `80`/`443`** — Traefik owns them.
|
||||
4. **FQDN = `https://<name>.urieljareth.org`** set before first deploy. TLS via
|
||||
DNS challenge, not HTTP-01.
|
||||
5. **Secrets as env vars only**, never committed or baked.
|
||||
6. **Healthcheck on a DB-independent endpoint.**
|
||||
7. **Named volumes** for data that must survive redeploys.
|
||||
|
||||
`Test-PreDeployChecklist.ps1` enforces these automatically.
|
||||
|
||||
## Safety policy
|
||||
|
||||
- **Read-only by default.** The first run of any deploy flow only *inspects*.
|
||||
- **Confirm before mutating.** Each step that creates a repo, pushes commits,
|
||||
creates a Coolify resource, or triggers a deploy asks for confirmation
|
||||
unless `-Force` is supplied.
|
||||
- **Never write secrets into the repo.** Tokens live in `.env.local.ps1`
|
||||
(gitignored). The skill scripts accept env files but never echo their values.
|
||||
- **No force-push to main** during rollback. The rollback branch carries the
|
||||
target SHA; main is only moved if the operator does it explicitly.
|
||||
- **State the rollback path** before any risky change.
|
||||
|
||||
## Tooling
|
||||
|
||||
See [`TOOLS.md`](TOOLS.md) for the command reference. The scripts in
|
||||
`deploy_skill/scripts/` reuse:
|
||||
|
||||
- `scripts/Invoke-ProxmoxSsh.ps1` for any LXC 102 / Docker inspection.
|
||||
- `coolify_skill/scripts/Invoke-CoolifyApi.ps1` for all Coolify API calls.
|
||||
- `scripts/Invoke-CloudflareApi.ps1` if the user asks to add a tunnel route
|
||||
(rare; tunnel is dashboard-managed).
|
||||
|
||||
## Reference docs
|
||||
|
||||
- [`docs/AGENTS-coolify-apps.md`](../docs/AGENTS-coolify-apps.md) — the
|
||||
compatibility contract every project must satisfy. Read before scaffolding.
|
||||
- [`docs/proxmox-inventory.md`](../docs/proxmox-inventory.md) — verified
|
||||
topology.
|
||||
- [`docs/runbooks/coolify-docker.md`](../docs/runbooks/coolify-docker.md) —
|
||||
Docker operations inside LXC 102.
|
||||
- [`coolify_skill/references/ops/`](../coolify_skill/references/ops/) —
|
||||
per-endpoint Coolify API reference. Search with `rg`, do not load all.
|
||||
- [`references/templates/`](references/templates/) — Dockerfile/compose/env
|
||||
templates that already satisfy the hard rules.
|
||||
|
||||
## Common workflows
|
||||
|
||||
### 1. New project, full pipeline
|
||||
|
||||
```powershell
|
||||
. .\.env.local.ps1
|
||||
.\deploy_skill\scripts\Publish-ProjectToCoolify.ps1 `
|
||||
-AppPath .\my-new-app `
|
||||
-AppName my-new-app `
|
||||
-Fqdn https://my-new-app.urieljareth.org `
|
||||
-Stack node -AppPort 8080 -Init
|
||||
```
|
||||
|
||||
### 2. Existing local repo, just register + deploy
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\Publish-ProjectToCoolify.ps1 `
|
||||
-AppPath .\existing-app `
|
||||
-AppName existing-app `
|
||||
-Fqdn https://existing-app.urieljareth.org `
|
||||
-AppPort 3000
|
||||
```
|
||||
|
||||
### 2b. Multi-container stack (Docker Compose) — register + deploy
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\New-CoolifyService.ps1 `
|
||||
-AppPath .\my-stack `
|
||||
-AppName my-stack `
|
||||
-ComposeFile docker-compose.coolify.yml `
|
||||
-Fqdn https://my-stack.urieljareth.org `
|
||||
-PrimaryService web `
|
||||
-EnvFile .\my-stack\.env.coolify `
|
||||
-InstantDeploy
|
||||
```
|
||||
|
||||
### 3. Rollback to a previous commit
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\Invoke-CoolifyRollback.ps1 `
|
||||
-AppPath .\my-app `
|
||||
-ApplicationUuid og88wk4 `
|
||||
-CommitSha a1b2c3d
|
||||
```
|
||||
|
||||
### 4. Only validate, no deploy
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\Test-PreDeployChecklist.ps1 -Path .\my-app -ExpectedPort 8080 -Strict
|
||||
```
|
||||
|
||||
### 5. Verify an already-deployed service is truly live (HTTP 200 + browser)
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\Test-ServiceOnline.ps1 `
|
||||
-Fqdn https://my-app.urieljareth.org `
|
||||
-ExpectTitle "My App" -Retries 8
|
||||
```
|
||||
|
||||
This is the **operational** gate — the answer to "did the deploy actually ship
|
||||
a working page?". Use it after any redeploy, env change, or rollback. It
|
||||
captures a PNG screenshot as evidence.
|
||||
@@ -0,0 +1,246 @@
|
||||
# Coolify Deploy — Tooling
|
||||
|
||||
All commands assume PowerShell from the project root. Load env first:
|
||||
|
||||
```powershell
|
||||
. .\.env.local.ps1
|
||||
```
|
||||
|
||||
Required env vars (in `.env.local.ps1`, gitignored):
|
||||
|
||||
- `COOLIFY_TOKEN` — for Coolify API.
|
||||
- `GITHUB_TOKEN` — PAT for GitHub API (create repo, list). `git push` uses
|
||||
wincred, NOT this PAT.
|
||||
- (Optional) `GITHUB_OWNER` — override the default `urieljarethbusiness-cpu`.
|
||||
- `COOLIFY_EMAIL` / `COOLIFY_PASSWORD` — Coolify **web UI** login. Required for the
|
||||
UI-driven flow below (this instance's `/applications/*` API is 404 — see
|
||||
`references/coolify-4.1.2-notes.md`).
|
||||
|
||||
## UI-driven config for git build-from-source apps (this instance, v4.1.2)
|
||||
|
||||
Because `/applications/*` is 404 here, configure git-based Docker-Compose apps through the
|
||||
UI with Playwright (run from the manager repo root so `require('playwright')` resolves):
|
||||
|
||||
```powershell
|
||||
. .\.env.local.ps1
|
||||
$env:CF_EMAIL = $env:COOLIFY_EMAIL; $env:CF_PASS = $env:COOLIFY_PASSWORD
|
||||
$scratch = "$env:TEMP\cf"; New-Item -ItemType Directory -Force $scratch | Out-Null
|
||||
|
||||
# 1. Log in once, save the session
|
||||
node .\deploy_skill\scripts\coolify-ui\coolify-login.mjs "$scratch\state.json"
|
||||
|
||||
# 2. Configure an EXISTING app (find its uuid/project/env via GET /resources + /projects/{u}/environments)
|
||||
$env:CF_STATE = "$scratch\state.json"
|
||||
$env:CF_APP_URL = "https://coolify.urieljareth.org/project/<proj>/environment/<env>/application/<app>"
|
||||
$env:CF_BUILDPACK = "dockercompose" # optional
|
||||
$env:CF_RELOAD = "1" # MANDATORY after buildpack switch / repo compose change
|
||||
$env:CF_ENVBULK = (Get-Content .\myapp\.env.coolify -Raw) # KEY=VALUE lines -> "Save All Environment Variables"
|
||||
$env:CF_DOMAINS = '{"web":"https://myapp.urieljareth.org"}' # pin per-service domains (else Coolify assigns random ones -> 503)
|
||||
node .\deploy_skill\scripts\coolify-ui\Configure-CoolifyComposeApp.mjs
|
||||
|
||||
# 3. Trigger the deploy (API works even though app CRUD is 404) and watch it
|
||||
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/deploy?uuid=<app>"
|
||||
# poll GET /deployments/{deployment_uuid} for status+logs; then verify:
|
||||
.\deploy_skill\scripts\Test-ServiceOnline.ps1 -Fqdn https://myapp.urieljareth.org -ExpectTitle "<regex>"
|
||||
```
|
||||
|
||||
## Pipeline scripts
|
||||
|
||||
> **One-time setup:** run `npm install` in the manager repo root to enable the
|
||||
> Playwright-based operational verification (final pipeline step). Chromium
|
||||
> binaries live at `%LOCALAPPDATA%\ms-playwright`. Without it,
|
||||
> `Test-ServiceOnline.ps1` falls back to curl-only.
|
||||
|
||||
> **Two pipelines:** `Publish-ProjectToCoolify.ps1` is for single-app
|
||||
> Dockerfile builds (POST /applications/public). `New-CoolifyService.ps1` is
|
||||
> for multi-container Docker Compose stacks (POST /services). Use the right
|
||||
> one for your project shape.
|
||||
|
||||
### Full pipeline (one shot)
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\Publish-ProjectToCoolify.ps1 `
|
||||
-AppPath .\my-app `
|
||||
-AppName my-app `
|
||||
-Fqdn https://my-app.urieljareth.org `
|
||||
-Stack node -AppPort 8080 -Init
|
||||
```
|
||||
|
||||
Runs: init → checklist → create GitHub repo → push → create Coolify app →
|
||||
deploy → Coolify-state inspection → **operational verification (HTTP 200 +
|
||||
Playwright browser)**. One confirmation per step unless `-Force`.
|
||||
|
||||
### Step by step
|
||||
|
||||
```powershell
|
||||
# 1. Scaffold Dockerfile/compose/.dockerignore/.gitignore additions
|
||||
.\deploy_skill\scripts\Initialize-CoolifyProject.ps1 -Path .\my-app -Stack node -AppPort 8080
|
||||
|
||||
# 2. Validate against AGENTS-coolify-apps.md hard rules
|
||||
.\deploy_skill\scripts\Test-PreDeployChecklist.ps1 -Path .\my-app -ExpectedPort 8080 -Strict
|
||||
|
||||
# 3. Create GitHub repo (idempotent; aborts if it already exists)
|
||||
.\deploy_skill\scripts\New-GitHubRepo.ps1 -Name my-app -Description "..." [-Private]
|
||||
|
||||
# 4. Push (uses wincred, not the PAT)
|
||||
cd .\my-app
|
||||
git init -b main
|
||||
git remote add origin https://github.com/urieljarethbusiness-cpu/my-app.git
|
||||
git add -A
|
||||
git commit -m "Initial commit"
|
||||
git push -u origin main
|
||||
|
||||
# 5. Create Coolify app + push envs + trigger deploy
|
||||
.\deploy_skill\scripts\New-CoolifyApplication.ps1 `
|
||||
-RepoUrl https://github.com/urieljarethbusiness-cpu/my-app.git `
|
||||
-Branch main `
|
||||
-Fqdn https://my-app.urieljareth.org `
|
||||
-PortsExposes 8080 `
|
||||
-EnvFile .\.env.coolify
|
||||
|
||||
# 6. Verify (Coolify state: containers, proxy logs, sibling DNS)
|
||||
.\deploy_skill\scripts\Test-PostDeploy.ps1 `
|
||||
-Fqdn https://my-app.urieljareth.org `
|
||||
-ContainerName my-app-main `
|
||||
-SiblingService postgres
|
||||
|
||||
# 7. Verify (OPERATIONAL: HTTP 200 + Playwright browser render)
|
||||
.\deploy_skill\scripts\Test-ServiceOnline.ps1 `
|
||||
-Fqdn https://my-app.urieljareth.org `
|
||||
-ExpectTitle "My App" -Retries 8
|
||||
```
|
||||
|
||||
### Multi-container stack (Docker Compose) — `New-CoolifyService.ps1`
|
||||
|
||||
For projects that ship a `docker-compose.coolify.yml` (app + DB + sidecars).
|
||||
POSTs the compose to Coolify's `/services` endpoint. The compose must already
|
||||
satisfy the hard rules (no published 80/443, DB by service name,
|
||||
`SERVICE_FQDN_<svc>_<port>` placeholders for Coolify to wire routes, named
|
||||
volumes, healthchecks).
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\New-CoolifyService.ps1 `
|
||||
-AppPath .\my-stack `
|
||||
-AppName my-stack `
|
||||
-ComposeFile docker-compose.coolify.yml `
|
||||
-Fqdn https://my-stack.urieljareth.org `
|
||||
-PrimaryService web `
|
||||
[-ExtraUrls @{ api = 'https://my-stack-api.urieljareth.org' }] `
|
||||
[-ProjectName default] [-EnvironmentName production] `
|
||||
[-EnvFile .\my-stack\.env.coolify] `
|
||||
[-InstantDeploy | -NoDeploy] `
|
||||
[-ServiceUuid <existing-uuid-to-update>] `
|
||||
[-Force]
|
||||
```
|
||||
|
||||
`-PrimaryService web` maps the FQDN to the `web` service in the compose (via
|
||||
the `urls` payload). `-ExtraUrls` adds extra service→FQDN mappings for stacks
|
||||
with multiple public services. On success, prints `service_uuid`,
|
||||
`primary_fqdn`, `primary_service` — pipe those into `Test-PostDeploy.ps1` and
|
||||
`Test-ServiceOnline.ps1` for verification.
|
||||
|
||||
## Rollback / redeploy from a previous commit
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\Invoke-CoolifyRollback.ps1 `
|
||||
-AppPath .\my-app `
|
||||
-ApplicationUuid og88wk4 `
|
||||
-CommitSha a1b2c3d
|
||||
```
|
||||
|
||||
Pushes the SHA to `rollback/<sha>` (main untouched), repoints Coolify, deploys.
|
||||
You will be prompted for the SHA if you omit `-CommitSha`.
|
||||
|
||||
## GitHub API (low level)
|
||||
|
||||
```powershell
|
||||
# Who am I?
|
||||
.\deploy_skill\scripts\Invoke-GitHubApi.ps1 -Path "/user"
|
||||
|
||||
# List my repos (paginated; first 30)
|
||||
.\deploy_skill\scripts\Invoke-GitHubApi.ps1 -Path "/user/repos?per_page=30"
|
||||
|
||||
# Check if a repo exists
|
||||
.\deploy_skill\scripts\Invoke-GitHubApi.ps1 -Path "/repos/urieljarethbusiness-cpu/my-app"
|
||||
|
||||
# Anything else: see https://docs.github.com/rest
|
||||
```
|
||||
|
||||
## Coolify API (already documented in coolify_skill)
|
||||
|
||||
The deploy skill reuses `coolify_skill/scripts/Invoke-CoolifyApi.ps1`. Useful
|
||||
endpoints for the deploy flow:
|
||||
|
||||
```powershell
|
||||
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/projects"
|
||||
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/servers"
|
||||
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/applications"
|
||||
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/applications/<uuid>"
|
||||
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Path "/applications/<uuid>/deployments"
|
||||
.\coolify_skill\scripts\Invoke-CoolifyApi.ps1 -Method POST -Path "/deploy" -BodyJson (@{ uuid = "<uuid>" } | ConvertTo-Json)
|
||||
```
|
||||
|
||||
For per-endpoint schemas, search `coolify_skill/references/ops/` with `rg`.
|
||||
|
||||
## Templates
|
||||
|
||||
| File | Use |
|
||||
|------|-----|
|
||||
| `references/templates/Dockerfile.node` | Node app with healthcheck on `/health` |
|
||||
| `references/templates/Dockerfile.python` | Python/gunicorn app |
|
||||
| `references/templates/docker-compose.app-db.yml` | App + sibling Postgres, external `coolify` network, named volumes |
|
||||
| `references/templates/env.local.template.ps1` | Template for the operator's `.env.local.ps1` |
|
||||
|
||||
## Operational verification — Test-ServiceOnline.ps1
|
||||
|
||||
The "definition of done". Confirms the deployed service actually serves a
|
||||
working page (not just that Coolify reports the container as running).
|
||||
|
||||
```powershell
|
||||
.\deploy_skill\scripts\Test-ServiceOnline.ps1 `
|
||||
-Fqdn https://my-app.urieljareth.org `
|
||||
[-Path /] `
|
||||
[-ExpectedStatusCode 200] `
|
||||
[-ExpectTitle "<regex>"] `
|
||||
[-Screenshot <png-path>] `
|
||||
[-SkipBrowser] `
|
||||
[-Retries 8] [-TimeoutMs 30000]
|
||||
```
|
||||
|
||||
Two layers (both must pass):
|
||||
|
||||
1. **HTTP (curl)**: status code in 2xx/3xx range. Catches Traefik 502, wrong
|
||||
`ports_exposes`, TLS DNS challenge not done, missing Cloudflare tunnel route.
|
||||
2. **Browser (Playwright + Chromium)**: real page navigation. Catches
|
||||
client-side JS crashes (`pageerror`), failed main-frame requests, blank
|
||||
error pages. Saves a PNG screenshot for evidence.
|
||||
|
||||
Retries default to 8 (≈80s of attempts with 10s gaps) to absorb Traefik/TLS
|
||||
warm-up after a fresh deploy. Exits 0 on success, non-zero on failure.
|
||||
|
||||
`-SkipBrowser` runs curl-only (for CI without a display, or when Chromium is
|
||||
unavailable). If Node/Playwright are missing entirely, the browser layer is
|
||||
auto-skipped with a warning (NOT a failure) — install with `npm install` in
|
||||
the manager repo root.
|
||||
|
||||
## Commands that always require confirmation
|
||||
|
||||
- Creating a GitHub repo (`New-GitHubRepo.ps1` warns if it already exists).
|
||||
- Any `git push` (the pipeline asks before pushing).
|
||||
- Any Coolify `POST`/`PATCH`/`DELETE` (project, environment, application, env,
|
||||
deploy).
|
||||
- Rollback (push + repoint + deploy).
|
||||
- Anything touching Cloudflare tunnel/DNS.
|
||||
|
||||
`-Force` skips the prompts. Use only after manually reviewing the plan.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| `git push` asks for username/password | wincred cache miss | Run any git HTTPS op once interactively; or `cmdkey /generic:git:https://github.com /user:urieljarethbusiness-cpu /pass:<PAT>` |
|
||||
| GitHub API 401 | `GITHUB_TOKEN` not loaded or expired | `. .\.env.local.ps1`; regenerate PAT at https://github.com/settings/tokens |
|
||||
| Coolify API 401 | `COOLIFY_TOKEN` not loaded | `. .\.env.local.ps1` |
|
||||
| App at 502 after deploy | `ports_exposes` mismatch (default is 3000) | `New-CoolifyApplication.ps1 -PortsExposes <real-port>` |
|
||||
| App can't reach DB | used `localhost` or wrong network | Use service name + external `coolify` network (see §2.1, §2.2 in AGENTS-coolify-apps.md) |
|
||||
| FQDN auto-generated UUID | FQDN not set before first deploy | Always pass `-Fqdn https://<name>.urieljareth.org` |
|
||||
@@ -0,0 +1,164 @@
|
||||
# Coolify v4.1.2 on this instance — hard realities (learned the hard way)
|
||||
|
||||
> Written while deploying `cotizador` (Next.js + FastAPI + Postgres, Docker Compose
|
||||
> build pack, private repo). These are behaviors of the **specific** instance at
|
||||
> `https://coolify.urieljareth.org`, verified empirically with a root-team token.
|
||||
> Re-check with `GET /api/v1/version` — if it changed, re-verify the API surface.
|
||||
|
||||
## 1. The REST API surface is PARTIAL — `/applications/*` is 404
|
||||
|
||||
With a valid **root-team** token (`GET /teams/current` → "Root Team"):
|
||||
|
||||
| Endpoint | Result |
|
||||
|----------|--------|
|
||||
| `GET /version`, `GET /teams/current` | 200 |
|
||||
| `GET /projects`, `POST /projects` | 200 / 201 |
|
||||
| `GET /projects/{uuid}/environments` | 200 |
|
||||
| `GET /servers` | 200 |
|
||||
| `POST /security/keys` (create private key) | 201 |
|
||||
| `GET /resources` | 200 — **the only reliable app/service/db inventory** |
|
||||
| `GET /services`, `POST /services`, `/services/{uuid}/envs`, `/start` | work |
|
||||
| `GET /deploy?uuid=<uuid>[&force=true]` | 200 — **triggers a deploy of any existing resource by uuid** |
|
||||
| `GET /deployments/{deployment_uuid}` | 200 — status + logs (logs = JSON string) |
|
||||
| **`/applications` (list)** | **404** |
|
||||
| **`/applications/{uuid}` (get / PATCH)** | **404** |
|
||||
| **`/applications/{uuid}/envs` (list/create)** | **404** |
|
||||
| **`/applications/{uuid}/logs`** | **404** |
|
||||
| **`POST /applications/public`** | **404** |
|
||||
| **`POST /applications/dockerfile`** | **404** |
|
||||
| **`POST /applications/private-deploy-key`** | **404** |
|
||||
| **`POST /applications/private-github-app`** | **404** |
|
||||
| **`POST /applications/dockercompose`** | **404** (deprecated upstream anyway) |
|
||||
| `GET /github-apps` | 404 |
|
||||
|
||||
**Consequence:** you CANNOT create OR configure a **git-based application that builds
|
||||
from source** (Dockerfile or Docker Compose build pack) via the API here. The skill's
|
||||
`New-CoolifyApplication.ps1` (POST /applications/public) will 404 on this instance.
|
||||
`POST /services` only accepts `docker_compose_raw` with **no git repo** → only good for
|
||||
**prebuilt-image** stacks, not `build:`-from-source composes.
|
||||
|
||||
→ For git+build apps you MUST use the **web UI** (drive it with Playwright). Application
|
||||
config (build pack, env vars, domains) is UI-only. Only the **deploy trigger** is API-able
|
||||
(`GET /deploy?uuid=`), and reads via `/resources` + `/deployments/{uuid}`.
|
||||
|
||||
## 2. Inventory & status via `/resources`
|
||||
`GET /resources` returns every application/service/database with `uuid`, `name`,
|
||||
`status` (e.g. `running:healthy`, `exited:unhealthy`), `fqdn`, `build_pack`,
|
||||
`git_repository`, `docker_compose_location`, `docker_compose_raw`, `docker_compose`
|
||||
(parsed), `source_type`/`source_id`, `environment_id`, `destination`. Map an app's
|
||||
`environment_id` to a project by scanning `/projects/{uuid}/environments`.
|
||||
|
||||
Build the UI URL as:
|
||||
`https://coolify.urieljareth.org/project/{project_uuid}/environment/{env_uuid}/application/{app_uuid}`
|
||||
(+ `/environment-variables`, `/logs`, `/deployment`, `/source`, `/danger`, …)
|
||||
|
||||
## 3. Driving the UI with Playwright (Livewire app)
|
||||
- Login: `/login`, fill `input[type=email]` + `input[type=password]`, submit; save
|
||||
`storageState` and reuse it (see `scripts/coolify-ui/coolify-login.mjs`).
|
||||
- Import Playwright from the manager repo when the script lives elsewhere:
|
||||
`createRequire('<manager>/package.json')` then `require('playwright')`.
|
||||
- Dismiss recurring popups: buttons "Accept and Close", "Acknowledge & Disable This Popup",
|
||||
"Maybe next time".
|
||||
- **Build Pack** is a `<select wire:model="buildPack">`; robust locator
|
||||
`select:has(option[value="dockercompose"])`; `selectOption('dockercompose')`.
|
||||
- **After switching build pack to Docker Compose you MUST click "Reload Compose File"**
|
||||
so Coolify fetches+parses+persists the compose from the repo. Otherwise the deploy
|
||||
crashes: `Symfony\Component\Yaml\Yaml::parse(): Argument #1 must be of type string,
|
||||
null given` (the parsed `docker_compose` field is null). Verify via `/resources`:
|
||||
`docker_compose` must be non-null after reload.
|
||||
- **Env vars**: Environment Variables tab → "Developer view" → fill the big textarea with
|
||||
`KEY=VALUE` lines → click **"Save All Environment Variables"** (NOT a generic "Save").
|
||||
Re-saving replaces the whole set, so always send the full list.
|
||||
- **Pin a compose service's public domain** with a magic env var:
|
||||
`SERVICE_FQDN_<SERVICE>_<PORT>=https://host` (e.g. `SERVICE_FQDN_WEB_3000=https://cotizador.urieljareth.org`).
|
||||
The compose should also declare a bare `- SERVICE_FQDN_WEB_3000` in that service's env.
|
||||
- **Deploy**: prefer API `GET /deploy?uuid=&force=true` (works even though app CRUD is 404),
|
||||
or the UI "Deploy" button.
|
||||
|
||||
## 4. Deploy pitfalls seen
|
||||
- **UTF-8 BOM / mojibake in the compose file breaks Coolify's YAML parser** → `docker_compose`
|
||||
stays null → `Yaml::parse(null)` crash. Keep compose files clean UTF-8, **no BOM**.
|
||||
Check: `head -c3 file | xxd` must NOT be `ef bb bf`.
|
||||
- **Don't queue multiple deploys.** Triggering a new deploy while one is building **cancels**
|
||||
the in-progress one → the build dies with `exit code 255` + "Gracefully shutting down build
|
||||
container". Trigger ONE deploy and let it finish. Check `/deployments` (running list) is empty
|
||||
before triggering.
|
||||
- `force=true` adds `--no-cache --pull` (slow full rebuild); omit `force` to reuse BuildKit
|
||||
cache and cut build time / cancellation risk.
|
||||
- Coolify's `/resources` `status` can lag reality (showed `running:healthy` while Traefik
|
||||
returned 503). Always confirm with an actual HTTP request to the FQDN + a browser render
|
||||
(`Test-ServiceOnline.ps1`).
|
||||
|
||||
## 5. PowerShell 5.1 gotcha (secret payloads → API)
|
||||
`Get-Content -Raw` returns a string decorated with ETS NoteProperties; `ConvertTo-Json` then
|
||||
serializes it as `{"value":"..."}`, so Coolify rejects e.g. `private_key` ("must be a string").
|
||||
FIX: read with `[System.IO.File]::ReadAllText($path)`.
|
||||
|
||||
## 6. Deploy-key path (kept as fallback / for instances where the API is complete)
|
||||
1. `ssh-keygen -t ed25519`.
|
||||
2. `POST /security/keys` with `private_key` via `ReadAllText`.
|
||||
3. Add the public key as a GitHub deploy key: `POST /repos/{owner}/{repo}/keys` `{title,key,read_only:true}`.
|
||||
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,72 @@
|
||||
# End-to-End Deploy Flow
|
||||
|
||||
This is the canonical sequence the deploy_skill walks a project through.
|
||||
Every box is a script in `deploy_skill/scripts/`. Diamonds are decisions the
|
||||
operator (or the agent on behalf of the operator) makes.
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ local project dir │
|
||||
│ (with source code) │
|
||||
└────────────┬─────────────┘
|
||||
│
|
||||
┌─────────────────▼──────────────────┐
|
||||
│ Initialize-CoolifyProject.ps1 │
|
||||
│ -Stack node|python|compose|static │
|
||||
│ -AppPort <N> │
|
||||
│ writes Dockerfile, compose, │
|
||||
│ .dockerignore, .gitignore additions │
|
||||
└─────────────────┬──────────────────┘
|
||||
│
|
||||
┌─────────────────▼──────────────────┐
|
||||
│ Test-PreDeployChecklist.ps1 -Strict│
|
||||
│ enforces AGENTS-coolify-apps.md │
|
||||
└────────────┬───────────────┬────────┘
|
||||
FAIL ◄──┤ ├── PASS
|
||||
│ │
|
||||
STOP + report │
|
||||
│
|
||||
┌────────────────────────────▼────────┐
|
||||
│ New-GitHubRepo.ps1 │
|
||||
│ -Name <app> │
|
||||
│ uses GITHUB_TOKEN (PAT) │
|
||||
└────────────────────────────┬─────────┘
|
||||
│
|
||||
┌────────────────────────────▼─────────┐
|
||||
│ git init / add / commit / push │
|
||||
│ uses wincred (NOT the PAT) │
|
||||
└────────────────────────────┬─────────┘
|
||||
│
|
||||
┌────────────────────────────▼─────────┐
|
||||
│ New-CoolifyApplication.ps1 │
|
||||
│ - resolve project (+ create) │
|
||||
│ - resolve environment (+ create) │
|
||||
│ - resolve server │
|
||||
│ - POST /applications/public │
|
||||
│ - POST /applications/<uuid>/envs │
|
||||
│ - POST /deploy │
|
||||
└────────────────────────────┬─────────┘
|
||||
│
|
||||
┌────────────────────────────▼─────────┐
|
||||
│ Test-PostDeploy.ps1 │
|
||||
│ - curl -I https://<fqdn> │
|
||||
│ - docker exec getent hosts <svc> │
|
||||
│ - coolify-proxy logs grep │
|
||||
│ - /applications/<uuid> (API) │
|
||||
└────────────────────────────┬─────────┘
|
||||
│
|
||||
LIVE on FQDN ◄
|
||||
```
|
||||
|
||||
`Publish-ProjectToCoolify.ps1` chains all six steps with one confirmation
|
||||
prompt each (or `-Force` for unattended runs).
|
||||
|
||||
## Branching the flow
|
||||
|
||||
- **Already-deployed app, just env change** → skip steps 1, 3, 4; call
|
||||
`New-CoolifyApplication.ps1 -ApplicationUuid <uuid>` (it PATCHes instead of
|
||||
creating).
|
||||
- **Rollback to old commit** → `Invoke-CoolifyRollback.ps1`; pushes a
|
||||
`rollback/<sha>` branch and repoints the app, leaving `main` intact.
|
||||
- **No code yet, only validate ideas** → just run
|
||||
`Test-PreDeployChecklist.ps1` against a skeleton.
|
||||
@@ -0,0 +1,26 @@
|
||||
# Minimal compliant Dockerfile for a Node app deployed to this Coolify instance.
|
||||
#
|
||||
# Hard rules followed (see docs/AGENTS-coolify-apps.md):
|
||||
# - No published host ports 80/443 (Traefik routes them).
|
||||
# - Real listening port is set via ports_exposes in Coolify (here 8080).
|
||||
# - Healthcheck endpoint must NOT depend on the DB being reachable at boot.
|
||||
#
|
||||
# Replace the build/run commands with the ones that match your framework.
|
||||
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS run
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8080
|
||||
COPY --from=build /app /app
|
||||
# Cheap endpoint; implement /health in your app to return 200 without DB.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=20s \
|
||||
CMD wget -qO- http://localhost:8080/health || exit 1
|
||||
EXPOSE 8080
|
||||
CMD ["npm", "start"]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Minimal compliant Dockerfile for a Python app deployed to this Coolify instance.
|
||||
#
|
||||
# Hard rules followed (see docs/AGENTS-coolify-apps.md):
|
||||
# - No published host ports 80/443 (Traefik routes them).
|
||||
# - Real listening port is set via ports_exposes in Coolify (here 8080).
|
||||
# - Healthcheck endpoint must NOT depend on the DB being reachable at boot.
|
||||
|
||||
FROM python:3.12-slim AS build
|
||||
WORKDIR /app
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
COPY requirements.txt .
|
||||
RUN pip install --prefix=/install -r requirements.txt
|
||||
|
||||
FROM python:3.12-slim AS run
|
||||
WORKDIR /app
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PORT=8080
|
||||
COPY --from=build /install /usr/local
|
||||
COPY . .
|
||||
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=20s \
|
||||
CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://localhost:8080/health'); sys.exit(0)" || exit 1
|
||||
EXPOSE 8080
|
||||
# Adjust to your framework: gunicorn, uvicorn, fastapi, etc.
|
||||
CMD ["gunicorn", "-b", "0.0.0.0:8080", "app:app"]
|
||||
@@ -0,0 +1,52 @@
|
||||
# Reference docker-compose for a multi-container app on this Coolify instance.
|
||||
#
|
||||
# Implements EVERY hard rule from docs/AGENTS-coolify-apps.md:
|
||||
# 2.1 App reaches DB by Docker service name (app-db), never localhost.
|
||||
# 2.2 Joins the external 'coolify' network so it can resolve shared services.
|
||||
# 2.3 Does NOT publish 80/443 (owned by coolify-proxy). Traefik routes.
|
||||
# 2.4 FQDN is set in Coolify (https://<name>.urieljareth.org) before deploy.
|
||||
# 2.5 Secrets come as env vars from Coolify. None are baked in here.
|
||||
# 4 Healthcheck on a DB-independent endpoint.
|
||||
# 5 Named volumes for data that must survive redeploys.
|
||||
|
||||
services:
|
||||
app:
|
||||
build: .
|
||||
environment:
|
||||
DB_HOST: app-db
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD}
|
||||
APP_PUBLIC_URL: https://app.urieljareth.org
|
||||
expose:
|
||||
- "8080"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- coolify
|
||||
depends_on:
|
||||
- app-db
|
||||
|
||||
app-db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- app-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- coolify
|
||||
|
||||
volumes:
|
||||
app-db-data: {}
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
name: coolify
|
||||
external: true
|
||||
@@ -0,0 +1,31 @@
|
||||
# Reference .env file. Copy to .env.local.ps1 and fill values.
|
||||
#
|
||||
# IMPORTANT: .env.local.ps1 is gitignored (see .gitignore). NEVER commit it.
|
||||
# The .env.example file in the repo root shows the publicly-safe keys.
|
||||
|
||||
# Proxmox (already configured)
|
||||
$env:PROXMOX_HOST = "192.168.0.200"
|
||||
$env:PROXMOX_NODE = "thinkcentre"
|
||||
$env:PROXMOX_USER = "root"
|
||||
$env:PROXMOX_SSH_KEY = "C:\Users\Uriel Jareth\.openclaw\workspace\proxmox_key_win"
|
||||
$env:PROXMOX_API_BASE_URL = "https://192.168.0.200:8006/api2/json"
|
||||
$env:PROXMOX_API_TOKEN_ID = "root@pam!openclaw"
|
||||
$env:PROXMOX_API_TOKEN_SECRET = "REPLACE_WITH_TOKEN_SECRET"
|
||||
$env:PROXMOX_COOLIFY_LXC = "102"
|
||||
|
||||
# Coolify
|
||||
$env:COOLIFY_API_URL = "https://coolify.urieljareth.org/api/v1"
|
||||
$env:COOLIFY_TOKEN = "REPLACE_WITH_COOLIFY_TOKEN"
|
||||
# Coolify UI login (email/password) — REQUIRED for Playwright-driven UI operations.
|
||||
# This instance (v4.1.2) does NOT expose the /applications/* REST API (all 404),
|
||||
# so configuring a git-based / Docker-Compose application (build pack, env vars,
|
||||
# domains) can ONLY be done through the web UI. See deploy_skill/references/coolify-4.1.2-notes.md.
|
||||
$env:COOLIFY_EMAIL = "REPLACE_WITH_COOLIFY_UI_EMAIL"
|
||||
$env:COOLIFY_PASSWORD = "REPLACE_WITH_COOLIFY_UI_PASSWORD"
|
||||
|
||||
# GitHub (for deploy_skill: New-GitHubRepo.ps1, Invoke-GitHubApi.ps1)
|
||||
# Generate at: https://github.com/settings/tokens
|
||||
# Scopes needed: repo (classic) OR Contents:Read+Write, Metadata:Read (fine-grained).
|
||||
# The token is used ONLY for API calls (create repo, set deploy key, etc.).
|
||||
# git push/pull uses Windows Credential Manager (wincred) instead.
|
||||
$env:GITHUB_TOKEN = "REPLACE_WITH_GITHUB_PAT"
|
||||
@@ -0,0 +1,112 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Initialize a project directory for compliant Coolify deployment.
|
||||
|
||||
.DESCRIPTION
|
||||
Copies the right template (Node / Python / multi-container) into the target
|
||||
project: Dockerfile, docker-compose, .dockerignore, plus a Coolify env
|
||||
template. Idempotent: never overwrites existing files unless -Force.
|
||||
|
||||
.PARAMETER Path
|
||||
Target project directory. Defaults to current directory.
|
||||
|
||||
.PARAMETER Stack
|
||||
Which template to scaffold. One of: node, python, compose, static.
|
||||
|
||||
.PARAMETER AppPort
|
||||
Listening port to bake into the Dockerfile and compose. Default 8080.
|
||||
|
||||
.PARAMETER Force
|
||||
Overwrite existing Dockerfile / docker-compose / .dockerignore.
|
||||
|
||||
.EXAMPLE
|
||||
.\Initialize-CoolifyProject.ps1 -Path .\my-app -Stack node -AppPort 8080
|
||||
#>
|
||||
param(
|
||||
[string]$Path = ".",
|
||||
|
||||
[ValidateSet("node", "python", "compose", "static")]
|
||||
[string]$Stack = "node",
|
||||
|
||||
[int]$AppPort = 8080,
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$templates = Join-Path $here "..\references\templates"
|
||||
$target = (Resolve-Path -LiteralPath $Path).Path
|
||||
|
||||
function Copy-Template {
|
||||
param([string]$Source, [string]$Dest)
|
||||
if (-not (Test-Path -LiteralPath $Source)) { throw "Template not found: $Source" }
|
||||
if ((Test-Path -LiteralPath $Dest) -and -not $Force) {
|
||||
Write-Host " skip (exists): $Dest" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
Copy-Item -LiteralPath $Source -Destination $Dest -Force:$Force
|
||||
Write-Host " wrote: $Dest" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host "Initializing $target with stack=$Stack port=$AppPort" -ForegroundColor Cyan
|
||||
|
||||
switch ($Stack) {
|
||||
"node" {
|
||||
Copy-Template (Join-Path $templates "Dockerfile.node") (Join-Path $target "Dockerfile")
|
||||
}
|
||||
"python" {
|
||||
Copy-Template (Join-Path $templates "Dockerfile.python") (Join-Path $target "Dockerfile")
|
||||
}
|
||||
"compose" {
|
||||
Copy-Template (Join-Path $templates "Dockerfile.node") (Join-Path $target "Dockerfile")
|
||||
Copy-Template (Join-Path $templates "docker-compose.app-db.yml") (Join-Path $target "docker-compose.yml")
|
||||
}
|
||||
"static" {
|
||||
# Static sites are served by nginx in Coolify; no Dockerfile required.
|
||||
Write-Host " (static stack: no Dockerfile needed; Coolify uses is_static + static_image)" -ForegroundColor DarkGray
|
||||
}
|
||||
}
|
||||
|
||||
# .dockerignore (idempotent)
|
||||
$di = Join-Path $target ".dockerignore"
|
||||
if ((-not (Test-Path -LiteralPath $di)) -or $Force) {
|
||||
@(
|
||||
".git",
|
||||
".gitignore",
|
||||
".env*",
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"__pycache__",
|
||||
"*.log",
|
||||
".vscode",
|
||||
".idea",
|
||||
"README.md"
|
||||
) | Set-Content -LiteralPath $di -Encoding utf8
|
||||
Write-Host " wrote: $di" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " skip (exists): $di" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Recommended .gitignore additions (idempotent, append-only if missing)
|
||||
$gi = Join-Path $target ".gitignore"
|
||||
$existing = if (Test-Path -LiteralPath $gi) { Get-Content -LiteralPath $gi } else { @() }
|
||||
$additions = @(".env", ".env.local", ".env.*.local", "*.key", "*.pem") | Where-Object { $_ -notin $existing }
|
||||
if ($additions) {
|
||||
if (-not (Test-Path -LiteralPath $gi)) { "" | Set-Content -LiteralPath $gi -Encoding utf8 }
|
||||
Add-Content -LiteralPath $gi -Value "`n# Added by deploy_skill"
|
||||
$additions | ForEach-Object { Add-Content -LiteralPath $gi -Value $_ }
|
||||
Write-Host " updated: $gi (+$($additions.Count) lines)" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host " skip (already covered): $gi" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
Write-Host "`nNext steps:" -ForegroundColor Cyan
|
||||
Write-Host " 1. Implement /health in your app (DB-independent, returns 200)."
|
||||
Write-Host " 2. Put real secrets in a local .env.coolify file (not committed)."
|
||||
Write-Host " 3. Run Test-PreDeployChecklist.ps1 to validate."
|
||||
Write-Host " 4. Run New-GitHubRepo.ps1 to create the repo."
|
||||
Write-Host " 5. git push -u origin main."
|
||||
Write-Host " 6. Run New-CoolifyApplication.ps1 to register and deploy."
|
||||
Write-Host " 7. Run Test-PostDeploy.ps1 to verify."
|
||||
@@ -0,0 +1,112 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Re-deploy a Coolify application from a previous commit SHA.
|
||||
|
||||
.DESCRIPTION
|
||||
Implements the rollback path:
|
||||
|
||||
1. Force-push the target commit SHA to a new branch `rollback/<sha>` on
|
||||
the origin remote (does NOT touch the main branch).
|
||||
2. PATCH the Coolify application to point at the rollback branch + SHA,
|
||||
OR update only git_commit_sha if the SHA is reachable from the
|
||||
currently configured branch.
|
||||
3. Trigger a fresh deploy.
|
||||
|
||||
This script does NOT rewrite public history. To avoid surprises, it always
|
||||
asks for confirmation before pushing or deploying unless -Force is given.
|
||||
|
||||
.PARAMETER AppPath
|
||||
Local path of the project Git repo. Defaults to current directory.
|
||||
|
||||
.PARAMETER CommitSha
|
||||
Target commit SHA to roll back to (full or short). If omitted, prints the
|
||||
last 10 commits so you can choose.
|
||||
|
||||
.PARAMETER ApplicationUuid
|
||||
Coolify application UUID to update and redeploy.
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch currently configured in Coolify for this app. Default: 'main'.
|
||||
|
||||
.PARAMETER Force
|
||||
Skip confirmation prompts.
|
||||
|
||||
.EXAMPLE
|
||||
.\Invoke-CoolifyRollback.ps1 -ApplicationUuid og88wk4 -CommitSha a1b2c3d
|
||||
#>
|
||||
param(
|
||||
[string]$AppPath = ".",
|
||||
|
||||
[string]$CommitSha,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ApplicationUuid,
|
||||
|
||||
[string]$Branch = "main",
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$coolifyApi = Join-Path $here "..\..\coolify_skill\scripts\Invoke-CoolifyApi.ps1"
|
||||
if (-not (Test-Path -LiteralPath $coolifyApi)) {
|
||||
throw "Invoke-CoolifyApi.ps1 not found at: $coolifyApi"
|
||||
}
|
||||
|
||||
$appPath = (Resolve-Path -LiteralPath $AppPath).Path
|
||||
|
||||
function Confirm-Step([string]$Action) {
|
||||
if ($Force) { return $true }
|
||||
$r = Read-Host "Confirm: $Action`nProceed? (y/N)"
|
||||
return ($r -match '^(y|yes|s|si|sí)$')
|
||||
}
|
||||
|
||||
Write-Host "Recent commits in $appPath :" -ForegroundColor Cyan
|
||||
$commits = git -C $appPath log --oneline -10
|
||||
$commits | ForEach-Object { Write-Host " $_" }
|
||||
|
||||
if (-not $CommitSha) {
|
||||
$CommitSha = Read-Host "`nEnter commit SHA to roll back to"
|
||||
}
|
||||
if (-not $CommitSha) { throw "No commit SHA provided." }
|
||||
|
||||
$fullSha = git -C $appPath rev-parse $CommitSha 2>$null
|
||||
if (-not $fullSha) { throw "Could not resolve commit: $CommitSha" }
|
||||
Write-Host "`nResolved SHA: $fullSha" -ForegroundColor Green
|
||||
|
||||
$originUrl = git -C $appPath remote get-url origin 2>$null
|
||||
if (-not $originUrl) { throw "No 'origin' remote configured in $appPath" }
|
||||
Write-Host "Origin: $originUrl"
|
||||
|
||||
$rollbackBranch = "rollback/$($fullSha.Substring(0,8))"
|
||||
if (-not (Confirm-Step "Push commit $fullSha to new branch '$rollbackBranch' on origin")) {
|
||||
throw "Aborted by user."
|
||||
}
|
||||
|
||||
git -C $appPath branch -f $rollbackBranch $fullSha | Out-Null
|
||||
git -C $appPath push origin $rollbackBranch 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
if ($LASTEXITCODE -ne 0) { throw "git push failed." }
|
||||
Write-Host "Pushed $rollbackBranch." -ForegroundColor Green
|
||||
|
||||
if (-not (Confirm-Step "PATCH /applications/$ApplicationUuid (git_branch=$rollbackBranch, git_commit_sha=$fullSha)")) {
|
||||
throw "Aborted by user."
|
||||
}
|
||||
$body = @{
|
||||
git_branch = $rollbackBranch
|
||||
git_commit_sha = $fullSha
|
||||
} | ConvertTo-Json -Compress
|
||||
& $coolifyApi -Method PATCH -Path "/applications/$ApplicationUuid" -BodyJson $body -ErrorAction Stop | Out-Null
|
||||
Write-Host "Application updated." -ForegroundColor Green
|
||||
|
||||
if (-not (Confirm-Step "Trigger DEPLOY of $ApplicationUuid at $fullSha")) {
|
||||
Write-Host "Skipping deploy trigger." -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
$deployBody = @{ uuid = $ApplicationUuid } | ConvertTo-Json -Compress
|
||||
& $coolifyApi -Method POST -Path "/deploy" -BodyJson $deployBody -ErrorAction Stop
|
||||
|
||||
Write-Host "`nRollback deploy triggered." -ForegroundColor Green
|
||||
Write-Host "Note: main branch on GitHub is untouched. To make this the new HEAD of main, do it explicitly:" -ForegroundColor Yellow
|
||||
Write-Host " git -C `"$appPath`" checkout main && git -C `"$appPath`" reset --hard $fullSha && git push --force-with-lease origin main" -ForegroundColor DarkGray
|
||||
@@ -0,0 +1,62 @@
|
||||
param(
|
||||
[ValidateSet("GET", "POST", "PATCH", "PUT", "DELETE")]
|
||||
[string]$Method = "GET",
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
|
||||
[string]$BodyJson,
|
||||
|
||||
[switch]$Raw
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:GITHUB_TOKEN)) {
|
||||
throw "Set GITHUB_TOKEN before calling the GitHub API. Generate a PAT at https://github.com/settings/tokens (scope 'repo') and store it in .env.local.ps1."
|
||||
}
|
||||
|
||||
if (-not (Get-Command curl.exe -ErrorAction SilentlyContinue)) {
|
||||
throw "curl.exe not found in PATH."
|
||||
}
|
||||
|
||||
$base = if ($env:GITHUB_API_URL) { $env:GITHUB_API_URL.TrimEnd("/") } else { "https://api.github.com" }
|
||||
$cleanPath = $Path.TrimStart("/")
|
||||
$uri = "$base/$cleanPath"
|
||||
|
||||
$tmpBody = $null
|
||||
$args = @("-sS", "-i", "-X", $Method.ToUpper(), "-H", "Authorization: Bearer $env:GITHUB_TOKEN", "-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2022-11-28", "-H", "User-Agent: coolify-deploy-skill")
|
||||
if ($PSBoundParameters.ContainsKey("BodyJson")) {
|
||||
try { $null = $BodyJson | ConvertFrom-Json } catch { throw "BodyJson is not valid JSON: $($_.Exception.Message)" }
|
||||
$tmpBody = [System.IO.Path]::GetTempFileName()
|
||||
Set-Content -LiteralPath $tmpBody -Value $BodyJson -NoNewline -Encoding utf8
|
||||
$args += @("--data", "@$tmpBody", "-H", "Content-Type: application/json")
|
||||
}
|
||||
|
||||
try {
|
||||
$output = & curl.exe @args $uri
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) { throw "GitHub API request failed with exit code $exitCode." }
|
||||
|
||||
$rawStr = ($output | Out-String)
|
||||
$parts = $rawStr -split "(?:`r`n`r`n|`n`n)", 2
|
||||
$headersBlock = if ($parts.Count -ge 1) { $parts[0] } else { "" }
|
||||
$bodyBlock = if ($parts.Count -ge 2) { $parts[1] } else { "{}" }
|
||||
|
||||
$statusLine = ($headersBlock -split "`n" | Select-Object -First 1).Trim()
|
||||
if ($statusLine -notmatch " 20[04-9] ") {
|
||||
$snippet = $bodyBlock.Trim()
|
||||
if ($snippet.Length -gt 400) { $snippet = $snippet.Substring(0, 400) + "..." }
|
||||
throw "GitHub API HTTP error: $statusLine`nBody: $snippet"
|
||||
}
|
||||
|
||||
if ($Raw) {
|
||||
[pscustomobject]@{ Status = $statusLine; Headers = $headersBlock; Body = $bodyBlock }
|
||||
} else {
|
||||
try { $bodyBlock | ConvertFrom-Json }
|
||||
catch { $bodyBlock }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($tmpBody -and (Test-Path -LiteralPath $tmpBody)) { Remove-Item -LiteralPath $tmpBody -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create or update a Coolify application from a Git repository and deploy it.
|
||||
|
||||
.DESCRIPTION
|
||||
Orchestration wrapper around Invoke-CoolifyApi.ps1 that performs the full
|
||||
Coolify onboarding flow for an existing Git repository:
|
||||
|
||||
1. Resolve or create the target Project (by name).
|
||||
2. Resolve or create the target Environment (by name within the project).
|
||||
3. Resolve the local server UUID (first reachable server).
|
||||
4. Create the application (public repo by default, or via deploy key).
|
||||
5. Set the FQDN and ports_exposes on the new app.
|
||||
6. Push env vars (key=value pairs) onto the application.
|
||||
7. Trigger an initial deploy and return the deployment UUIDs.
|
||||
|
||||
Each mutating step asks for explicit confirmation unless -Force is supplied.
|
||||
The script NEVER writes secrets to stdout: env values are sent to the API
|
||||
but not echoed.
|
||||
|
||||
.PARAMETER RepoUrl
|
||||
HTTPS clone URL of the Git repository (e.g. https://github.com/user/repo).
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch to deploy. Defaults to 'main'.
|
||||
|
||||
.PARAMETER Fqdn
|
||||
Public URL Coolify will expose, e.g. https://myapp.urieljareth.org.
|
||||
|
||||
.PARAMETER ProjectName
|
||||
Coolify project name. If it does not exist, it will be created. Default: 'default'.
|
||||
|
||||
.PARAMETER EnvironmentName
|
||||
Coolify environment name within the project. Default: 'production'.
|
||||
|
||||
.PARAMETER PortsExposes
|
||||
Internal port the app listens on (Traefik targets this). Default: '3000'.
|
||||
|
||||
.PARAMETER BuildPack
|
||||
Optional Coolify build pack override (nixpacks, dockerfile, dockercompose).
|
||||
|
||||
.PARAMETER IsStatic
|
||||
Switch. Marks the app as a static site (Coolify will use a static_image).
|
||||
|
||||
.PARAMETER EnvFile
|
||||
Optional path to a .env-formatted file. Each KEY=VALUE line is pushed to
|
||||
Coolify as an environment variable. Lines starting with '#' are ignored.
|
||||
|
||||
.PARAMETER ApplicationUuid
|
||||
Optional. If the app already exists in Coolify, pass its UUID to update it
|
||||
in place (FQDN, ports, env) and redeploy, instead of creating a new one.
|
||||
|
||||
.PARAMETER Force
|
||||
Skip per-step confirmation prompts. Use only after validating the plan.
|
||||
|
||||
.EXAMPLE
|
||||
$envs = "./.env.coolify"
|
||||
.\New-CoolifyApplication.ps1 -RepoUrl https://github.com/urieljarethbusiness-cpu/my-app `
|
||||
-Fqdn https://myapp.urieljareth.org -PortsExposes 8080 -EnvFile $envs -Force
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true, ParameterSetName = "Create")]
|
||||
[string]$RepoUrl,
|
||||
|
||||
[string]$Branch = "main",
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Fqdn,
|
||||
|
||||
[string]$ProjectName = "default",
|
||||
|
||||
[string]$EnvironmentName = "production",
|
||||
|
||||
[string]$PortsExposes = "3000",
|
||||
|
||||
[string]$BuildPack,
|
||||
|
||||
[switch]$IsStatic,
|
||||
|
||||
[string]$EnvFile,
|
||||
|
||||
[Parameter(ParameterSetName = "Update")]
|
||||
[string]$ApplicationUuid,
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$coolifyApi = Join-Path $here "..\..\coolify_skill\scripts\Invoke-CoolifyApi.ps1"
|
||||
if (-not (Test-Path -LiteralPath $coolifyApi)) {
|
||||
throw "Invoke-CoolifyApi.ps1 not found at: $coolifyApi"
|
||||
}
|
||||
|
||||
function Confirm-Step {
|
||||
param([string]$Action)
|
||||
if ($Force) { return $true }
|
||||
$reply = Read-Host "Confirm action: $Action`nProceed? (y/N)"
|
||||
return ($reply -match '^(y|yes|s|si|sí)$')
|
||||
}
|
||||
|
||||
function Get-CoolifyResource {
|
||||
param([string]$Path)
|
||||
& $coolifyApi -Path $Path -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Resolve-Project {
|
||||
param([string]$Name)
|
||||
$projects = Get-CoolifyResource -Path "/projects"
|
||||
$match = $projects | Where-Object { $_.name -eq $Name } | Select-Object -First 1
|
||||
if (-not $match) {
|
||||
if (-not (Confirm-Step "Create project '$Name' in Coolify")) {
|
||||
throw "Aborted by user before creating project."
|
||||
}
|
||||
$body = @{ name = $Name } | ConvertTo-Json -Compress
|
||||
$created = & $coolifyApi -Method POST -Path "/projects" -BodyJson $body -ErrorAction Stop
|
||||
Write-Host "Project created: $($created.uuid)" -ForegroundColor Green
|
||||
return $created
|
||||
}
|
||||
return $match
|
||||
}
|
||||
|
||||
function Resolve-Environment {
|
||||
param([string]$ProjectUuid, [string]$Name)
|
||||
$envs = Get-CoolifyResource -Path "/projects/$ProjectUuid/environments"
|
||||
$match = $envs | Where-Object { $_.name -eq $Name } | Select-Object -First 1
|
||||
if (-not $match) {
|
||||
if (-not (Confirm-Step "Create environment '$Name' in project $ProjectUuid")) {
|
||||
throw "Aborted by user before creating environment."
|
||||
}
|
||||
$body = @{ name = $Name } | ConvertTo-Json -Compress
|
||||
$created = & $coolifyApi -Method POST -Path "/projects/$ProjectUuid/environments" -BodyJson $body -ErrorAction Stop
|
||||
Write-Host "Environment created: $($created.uuid)" -ForegroundColor Green
|
||||
return $created
|
||||
}
|
||||
return $match
|
||||
}
|
||||
|
||||
function Resolve-ServerUuid {
|
||||
$servers = Get-CoolifyResource -Path "/servers"
|
||||
if (-not $servers) { throw "No Coolify servers configured." }
|
||||
$srv = $servers | Where-Object { $_.settings.is_usable -eq $true } | Select-Object -First 1
|
||||
if (-not $srv) { $srv = $servers | Select-Object -First 1 }
|
||||
return $srv.uuid
|
||||
}
|
||||
|
||||
function Resolve-AppUuid {
|
||||
param([string]$Uuid)
|
||||
if ($Uuid) {
|
||||
$app = Get-CoolifyResource -Path "/applications/$Uuid"
|
||||
return @{ uuid = $app.uuid; created = $false }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
Write-Host "Resolving Coolify project '$ProjectName'..." -ForegroundColor Cyan
|
||||
$proj = Resolve-Project -Name $ProjectName
|
||||
Write-Host "Resolving environment '$EnvironmentName'..." -ForegroundColor Cyan
|
||||
$env = Resolve-Environment -ProjectUuid $proj.uuid -Name $EnvironmentName
|
||||
Write-Host "Resolving server..." -ForegroundColor Cyan
|
||||
$serverUuid = Resolve-ServerUuid
|
||||
Write-Host "Using server: $serverUuid" -ForegroundColor Cyan
|
||||
|
||||
$existing = Resolve-AppUuid -Uuid $ApplicationUuid
|
||||
if ($existing) {
|
||||
Write-Host "Updating existing application: $($existing.uuid)" -ForegroundColor Cyan
|
||||
$appUuid = $existing.uuid
|
||||
$patch = [ordered]@{
|
||||
domains = $Fqdn
|
||||
ports_exposes = $PortsExposes
|
||||
}
|
||||
if ($BuildPack) { $patch.build_pack = $BuildPack }
|
||||
if (-not (Confirm-Step "PATCH /applications/$appUuid (domain=$Fqdn, ports=$PortsExposes)")) {
|
||||
throw "Aborted by user."
|
||||
}
|
||||
& $coolifyApi -Method PATCH -Path "/applications/$appUuid" -BodyJson ($patch | ConvertTo-Json -Compress) -ErrorAction Stop | Out-Null
|
||||
} else {
|
||||
$createBody = [ordered]@{
|
||||
project_uuid = $proj.uuid
|
||||
environment_name = $env.name
|
||||
server_uuid = $serverUuid
|
||||
git_repository = $RepoUrl
|
||||
git_branch = $Branch
|
||||
ports_exposes = $PortsExposes
|
||||
domains = $Fqdn
|
||||
name = Split-Path -Leaf ($RepoUrl -replace '\.git$', '')
|
||||
is_static = [bool]$IsStatic
|
||||
instant_deploy = $false
|
||||
}
|
||||
if ($BuildPack) { $createBody.build_pack = $BuildPack }
|
||||
if (-not (Confirm-Step "POST /applications/public with repo=$RepoUrl, branch=$Branch, fqdn=$Fqdn, ports=$PortsExposes")) {
|
||||
throw "Aborted by user."
|
||||
}
|
||||
$created = & $coolifyApi -Method POST -Path "/applications/public" -BodyJson ($createBody | ConvertTo-Json -Compress) -ErrorAction Stop
|
||||
$appUuid = $created.uuid
|
||||
Write-Host "Application created: $appUuid" -ForegroundColor Green
|
||||
}
|
||||
|
||||
if ($EnvFile -and (Test-Path -LiteralPath $EnvFile)) {
|
||||
if (-not (Confirm-Step "Push env vars from $EnvFile to application $appUuid")) {
|
||||
Write-Host "Skipping env push." -ForegroundColor Yellow
|
||||
} else {
|
||||
Get-Content -LiteralPath $EnvFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
$eq = $line.IndexOf("=")
|
||||
if ($eq -le 0) { return }
|
||||
$k = $line.Substring(0, $eq).Trim()
|
||||
$v = $line.Substring($eq + 1).Trim().Trim('"').Trim("'")
|
||||
$body = [ordered]@{
|
||||
key = $k
|
||||
value = $v
|
||||
is_literal = $true
|
||||
is_shown_once = $false
|
||||
} | ConvertTo-Json -Compress
|
||||
& $coolifyApi -Method POST -Path "/applications/$appUuid/envs" -BodyJson $body -ErrorAction Stop | Out-Null
|
||||
Write-Host " env: $k set" -ForegroundColor DarkGray
|
||||
}
|
||||
Write-Host "Env vars pushed." -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Confirm-Step "Trigger DEPLOY of $appUuid")) {
|
||||
Write-Host "Skipping deploy trigger. Application is created but not deployed." -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
|
||||
$deployResult = & $coolifyApi -Method POST -Path "/deploy" -BodyJson (@{ uuid = $appUuid } | ConvertTo-Json -Compress) -ErrorAction Stop
|
||||
Write-Host "Deploy triggered." -ForegroundColor Green
|
||||
$deployResult
|
||||
|
||||
[PSCustomObject]@{
|
||||
project_uuid = $proj.uuid
|
||||
environment_name = $env.name
|
||||
server_uuid = $serverUuid
|
||||
application_uuid = $appUuid
|
||||
fqdn = $Fqdn
|
||||
repo = $RepoUrl
|
||||
branch = $Branch
|
||||
} | Format-List
|
||||
@@ -0,0 +1,319 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create or update a Coolify SERVICE (Docker Compose multi-container stack)
|
||||
from a local project's compose file.
|
||||
|
||||
.DESCRIPTION
|
||||
Complements New-CoolifyApplication.ps1 (which only handles single-app
|
||||
Dockerfile builds via POST /applications/public). This script handles
|
||||
multi-container stacks via POST /services - the modern replacement for
|
||||
the deprecated POST /applications/dockercompose.
|
||||
|
||||
Workflow:
|
||||
1. Resolve (or create) the target Coolify Project and Environment.
|
||||
2. Resolve the local server UUID.
|
||||
3. Read the docker-compose file from the project (default:
|
||||
docker-compose.coolify.yml). The compose must already satisfy the
|
||||
hard rules from docs/AGENTS-coolify-apps.md (no published 80/443,
|
||||
service-name DB host, healthchecks, named volumes, SERVICE_FQDN_*
|
||||
placeholders so Coolify can wire routes).
|
||||
4. POST /services with the raw compose + URL mappings (which service
|
||||
gets which FQDN).
|
||||
5. Push env vars from an optional .env file to the service.
|
||||
6. Trigger an initial deploy (unless -NoDeploy).
|
||||
|
||||
Each mutating step asks for confirmation unless -Force is supplied.
|
||||
|
||||
.PARAMETER AppPath
|
||||
Local path of the project that contains the compose file. Required.
|
||||
|
||||
.PARAMETER AppName
|
||||
Service name in Coolify. Required. Becomes the resource name; Coolify
|
||||
auto-suffixes with a short UUID when it instantiates sub-services.
|
||||
|
||||
.PARAMETER ComposeFile
|
||||
Compose file name (relative to AppPath) or absolute path.
|
||||
Default: docker-compose.coolify.yml
|
||||
|
||||
.PARAMETER Fqdn
|
||||
Primary public URL, e.g. https://cotizador.urieljareth.org. The FQDN is
|
||||
mapped to the -PrimaryService in the compose via the `urls` payload.
|
||||
Required.
|
||||
|
||||
.PARAMETER PrimaryService
|
||||
The compose service name that should receive the -Fqdn. Default: 'web'.
|
||||
Set this to whatever your compose calls the main app service.
|
||||
|
||||
.PARAMETER ExtraUrls
|
||||
Optional hashtable mapping additional compose service names to FQDNs.
|
||||
Example: @{ api = 'https://cotizador-api.urieljareth.org' }
|
||||
Use when your compose exposes more than one public service.
|
||||
|
||||
.PARAMETER ProjectName
|
||||
Coolify project name. Created if missing. Default: 'default'.
|
||||
|
||||
.PARAMETER EnvironmentName
|
||||
Coolify environment name within the project. Default: 'production'.
|
||||
|
||||
.PARAMETER EnvFile
|
||||
Optional .env-formatted file. Each KEY=VALUE line is pushed to Coolify
|
||||
as a service-level env var. Lines starting with '#' are ignored.
|
||||
|
||||
.PARAMETER InstantDeploy
|
||||
Pass instant_deploy=true to the create call. Coolify starts the stack
|
||||
immediately after creation. Mutually exclusive with -NoDeploy.
|
||||
|
||||
.PARAMETER NoDeploy
|
||||
Do not trigger a deploy. Service is created with env vars but not started.
|
||||
Use this when you want to review in the UI before the first start.
|
||||
|
||||
.PARAMETER ServiceUuid
|
||||
Optional. If a service already exists in Coolify, pass its UUID to PATCH
|
||||
it (compose_raw, urls) in place instead of creating a new one.
|
||||
|
||||
.PARAMETER Force
|
||||
Skip per-step confirmation prompts.
|
||||
|
||||
.EXAMPLE
|
||||
.\New-CoolifyService.ps1 `
|
||||
-AppPath .\cotizador `
|
||||
-AppName cotizador `
|
||||
-Fqdn https://cotizador.urieljareth.org `
|
||||
-PrimaryService web `
|
||||
-EnvFile .\cotizador\.env.coolify `
|
||||
-InstantDeploy
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$AppPath,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
||||
[string]$AppName,
|
||||
|
||||
[string]$ComposeFile = "docker-compose.coolify.yml",
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Fqdn,
|
||||
|
||||
[string]$PrimaryService = "web",
|
||||
|
||||
[hashtable]$ExtraUrls,
|
||||
|
||||
[string]$ProjectName = "default",
|
||||
|
||||
[string]$EnvironmentName = "production",
|
||||
|
||||
[string]$EnvFile,
|
||||
|
||||
[switch]$InstantDeploy,
|
||||
|
||||
[switch]$NoDeploy,
|
||||
|
||||
[string]$ServiceUuid,
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$coolifyApi = Join-Path $here "..\..\coolify_skill\scripts\Invoke-CoolifyApi.ps1"
|
||||
if (-not (Test-Path -LiteralPath $coolifyApi)) {
|
||||
throw "Invoke-CoolifyApi.ps1 not found at: $coolifyApi"
|
||||
}
|
||||
|
||||
function Confirm-Step {
|
||||
param([string]$Action)
|
||||
if ($Force) { return $true }
|
||||
$reply = Read-Host "Confirm action: $Action`nProceed? (y/N)"
|
||||
return ($reply -match '^(y|yes|s|si|sí)$')
|
||||
}
|
||||
|
||||
function Get-CoolifyResource {
|
||||
param([string]$Path)
|
||||
& $coolifyApi -Path $Path -Raw -ErrorAction Stop
|
||||
}
|
||||
|
||||
function Resolve-Project {
|
||||
param([string]$Name)
|
||||
$projects = Get-CoolifyResource -Path "/projects"
|
||||
$match = $projects | Where-Object { $_.name -eq $Name } | Select-Object -First 1
|
||||
if (-not $match) {
|
||||
if (-not (Confirm-Step "Create project '$Name' in Coolify")) {
|
||||
throw "Aborted by user before creating project."
|
||||
}
|
||||
$body = @{ name = $Name } | ConvertTo-Json -Compress
|
||||
$created = & $coolifyApi -Method POST -Path "/projects" -BodyJson $body -Raw -ErrorAction Stop
|
||||
Write-Host "Project created: $($created.uuid)" -ForegroundColor Green
|
||||
return $created
|
||||
}
|
||||
return $match
|
||||
}
|
||||
|
||||
function Resolve-Environment {
|
||||
param([string]$ProjectUuid, [string]$Name)
|
||||
$envs = Get-CoolifyResource -Path "/projects/$ProjectUuid/environments"
|
||||
$match = $envs | Where-Object { $_.name -eq $Name } | Select-Object -First 1
|
||||
if (-not $match) {
|
||||
if (-not (Confirm-Step "Create environment '$Name' in project $ProjectUuid")) {
|
||||
throw "Aborted by user before creating environment."
|
||||
}
|
||||
$body = @{ name = $Name } | ConvertTo-Json -Compress
|
||||
$created = & $coolifyApi -Method POST -Path "/projects/$ProjectUuid/environments" -BodyJson $body -Raw -ErrorAction Stop
|
||||
Write-Host "Environment created: $($created.uuid)" -ForegroundColor Green
|
||||
return $created
|
||||
}
|
||||
return $match
|
||||
}
|
||||
|
||||
function Resolve-ServerUuid {
|
||||
$servers = Get-CoolifyResource -Path "/servers"
|
||||
if (-not $servers) { throw "No Coolify servers configured." }
|
||||
$srv = $servers | Where-Object { $_.settings.is_usable -eq $true } | Select-Object -First 1
|
||||
if (-not $srv) { $srv = $servers | Select-Object -First 1 }
|
||||
return $srv.uuid
|
||||
}
|
||||
|
||||
# ─── Resolve inputs ──────────────────────────────────────────────────────────
|
||||
|
||||
$appPath = (Resolve-Path -LiteralPath $AppPath).Path
|
||||
|
||||
# Compose file: try as-is (absolute), else relative to AppPath
|
||||
$composePath = if ([System.IO.Path]::IsPathRooted($ComposeFile)) {
|
||||
$ComposeFile
|
||||
} else {
|
||||
Join-Path $appPath $ComposeFile
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath $composePath)) {
|
||||
throw "Compose file not found: $composePath (resolved from -ComposeFile '$ComposeFile' against AppPath '$appPath')"
|
||||
}
|
||||
$composeRaw = Get-Content -LiteralPath $composePath -Raw -ErrorAction Stop
|
||||
Write-Host "Loaded compose: $composePath ($($composeRaw.Length) bytes)" -ForegroundColor Cyan
|
||||
|
||||
# URLs array: primary + extras
|
||||
$urlsList = @(
|
||||
[pscustomobject]@{ name = $PrimaryService; url = $Fqdn }
|
||||
)
|
||||
if ($ExtraUrls -and $ExtraUrls.Count -gt 0) {
|
||||
foreach ($k in $ExtraUrls.Keys) {
|
||||
$urlsList += [pscustomobject]@{ name = $k; url = $ExtraUrls[$k] }
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Resolving Coolify project '$ProjectName'..." -ForegroundColor Cyan
|
||||
$proj = Resolve-Project -Name $ProjectName
|
||||
Write-Host "Resolving environment '$EnvironmentName'..." -ForegroundColor Cyan
|
||||
$env = Resolve-Environment -ProjectUuid $proj.uuid -Name $EnvironmentName
|
||||
Write-Host "Resolving server..." -ForegroundColor Cyan
|
||||
$serverUuid = Resolve-ServerUuid
|
||||
Write-Host "Using server: $serverUuid" -ForegroundColor Cyan
|
||||
|
||||
# ─── Create or update the service ────────────────────────────────────────────
|
||||
|
||||
if ($ServiceUuid) {
|
||||
Write-Host "Updating existing service: $ServiceUuid" -ForegroundColor Cyan
|
||||
$patch = [ordered]@{
|
||||
name = $AppName
|
||||
docker_compose_raw = $composeRaw
|
||||
urls = $urlsList
|
||||
project_uuid = $proj.uuid
|
||||
environment_name = $env.name
|
||||
server_uuid = $serverUuid
|
||||
}
|
||||
if (-not (Confirm-Step "PATCH /services/$ServiceUuid (compose=$($composePath), urls=$($urlsList.Count), fqdn=$Fqdn)")) {
|
||||
throw "Aborted by user."
|
||||
}
|
||||
$result = & $coolifyApi -Method PATCH -Path "/services/$ServiceUuid" -BodyJson ($patch | ConvertTo-Json -Depth 6) -Raw -ErrorAction Stop
|
||||
$svcUuid = $ServiceUuid
|
||||
} else {
|
||||
$createBody = [ordered]@{
|
||||
type = "one-click-service" # custom service; Coolify accepts any non-empty string here
|
||||
name = $AppName
|
||||
project_uuid = $proj.uuid
|
||||
environment_name = $env.name
|
||||
server_uuid = $serverUuid
|
||||
docker_compose_raw = $composeRaw
|
||||
urls = $urlsList
|
||||
instant_deploy = [bool]$InstantDeploy
|
||||
}
|
||||
if (-not (Confirm-Step "POST /services with AppName=$AppName, compose=$($composePath), fqdn=$Fqdn -> $PrimaryService, instant_deploy=$InstantDeploy")) {
|
||||
throw "Aborted by user."
|
||||
}
|
||||
$result = & $coolifyApi -Method POST -Path "/services" -BodyJson ($createBody | ConvertTo-Json -Depth 6) -Raw -ErrorAction Stop
|
||||
$svcUuid = $result.uuid
|
||||
Write-Host "Service created: $svcUuid" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# Show domains assigned (Coolify echoes the resolved FQDNs)
|
||||
if ($result.domains) {
|
||||
Write-Host "Domains assigned:" -ForegroundColor Cyan
|
||||
$result.domains | ForEach-Object { Write-Host " $_" }
|
||||
}
|
||||
|
||||
# ─── Push env vars ───────────────────────────────────────────────────────────
|
||||
|
||||
if ($EnvFile -and (Test-Path -LiteralPath $EnvFile)) {
|
||||
if (-not (Confirm-Step "Push env vars from $EnvFile to service $svcUuid")) {
|
||||
Write-Host "Skipping env push." -ForegroundColor Yellow
|
||||
} else {
|
||||
$pushed = 0
|
||||
Get-Content -LiteralPath $EnvFile | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
$eq = $line.IndexOf("=")
|
||||
if ($eq -le 0) { return }
|
||||
$k = $line.Substring(0, $eq).Trim()
|
||||
$v = $line.Substring($eq + 1).Trim().Trim('"').Trim("'")
|
||||
$body = [ordered]@{
|
||||
key = $k
|
||||
value = $v
|
||||
is_literal = $true
|
||||
is_shown_once = $false
|
||||
} | ConvertTo-Json -Compress
|
||||
try {
|
||||
& $coolifyApi -Method POST -Path "/services/$svcUuid/envs" -BodyJson $body -ErrorAction Stop | Out-Null
|
||||
Write-Host " env: $k set" -ForegroundColor DarkGray
|
||||
$pushed++
|
||||
} catch {
|
||||
Write-Host " env: $k FAILED -> $($_.Exception.Message)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
Write-Host "Env vars pushed: $pushed" -ForegroundColor Green
|
||||
}
|
||||
} elseif ($EnvFile) {
|
||||
Write-Host "EnvFile specified but not found: $EnvFile" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# ─── Deploy trigger (if not instant) ─────────────────────────────────────────
|
||||
|
||||
if ($NoDeploy) {
|
||||
Write-Host "Service created with env vars. -NoDeploy set: skipping deploy trigger." -ForegroundColor Yellow
|
||||
} elseif ($InstantDeploy -and -not $ServiceUuid) {
|
||||
Write-Host "instant_deploy=true: Coolify is already starting the stack." -ForegroundColor Green
|
||||
} else {
|
||||
if (-not (Confirm-Step "Trigger DEPLOY of service $svcUuid")) {
|
||||
Write-Host "Skipping deploy trigger. Service is created but not started." -ForegroundColor Yellow
|
||||
} else {
|
||||
# Coolify's "deploy" endpoint for services is POST /services/{uuid}/start (or the unified /deploy with type).
|
||||
# /services/{uuid}/start is the documented happy path; it queues the stack start.
|
||||
try {
|
||||
& $coolifyApi -Method POST -Path "/services/$svcUuid/start" -ErrorAction Stop | Out-Null
|
||||
Write-Host "Deploy triggered (POST /services/$svcUuid/start)." -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host " POST /services/$svcUuid/start failed: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
Write-Host " Try the Coolify UI 'Deploy' button or POST /services/$svcUuid/start manually." -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[PSCustomObject]@{
|
||||
project_uuid = $proj.uuid
|
||||
environment_name = $env.name
|
||||
server_uuid = $serverUuid
|
||||
service_uuid = $svcUuid
|
||||
name = $AppName
|
||||
primary_fqdn = $Fqdn
|
||||
primary_service = $PrimaryService
|
||||
compose_source = $composePath
|
||||
} | Format-List
|
||||
@@ -0,0 +1,85 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create a new GitHub repository for a project that will be deployed to Coolify.
|
||||
|
||||
.DESCRIPTION
|
||||
Calls the GitHub REST API with GITHUB_TOKEN to create a new repository under
|
||||
the authenticated user. After creation, prints the clone URL (HTTPS) and the
|
||||
SSH push URL. Idempotent in the sense that if the repo already exists it
|
||||
aborts with a clear message instead of failing.
|
||||
|
||||
.PARAMETER Name
|
||||
Repository name (required). GitHub naming rules: lowercase, digits, '-' '_' '.'.
|
||||
|
||||
.PARAMETER Description
|
||||
Short description shown on GitHub.
|
||||
|
||||
.PARAMETER Private
|
||||
Switch. If set, creates a private repository. Default is public (Coolify
|
||||
public-app deploy does not need a deploy key).
|
||||
|
||||
.PARAMETER Owner
|
||||
Optional. Organization or user to create the repo under. Defaults to the
|
||||
authenticated user (the owner of GITHUB_TOKEN).
|
||||
|
||||
.EXAMPLE
|
||||
.\New-GitHubRepo.ps1 -Name "my-cool-app" -Description "Demo" -Private
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
||||
[string]$Name,
|
||||
|
||||
[string]$Description = "",
|
||||
|
||||
[switch]$Private,
|
||||
|
||||
[string]$Owner
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$ghApi = Join-Path $here "Invoke-GitHubApi.ps1"
|
||||
|
||||
$me = & $ghApi -Path "/user" -ErrorAction Stop
|
||||
$effectiveOwner = if ($Owner) { $Owner } else { $me.login }
|
||||
|
||||
Write-Host "Authenticated GitHub user: $($me.login)" -ForegroundColor Cyan
|
||||
|
||||
$exists = $null
|
||||
try {
|
||||
$exists = & $ghApi -Path "/repos/$effectiveOwner/$Name" -ErrorAction Stop
|
||||
} catch {
|
||||
$exists = $null
|
||||
}
|
||||
if ($exists) {
|
||||
Write-Host "Repository already exists: https://github.com/$effectiveOwner/$Name" -ForegroundColor Yellow
|
||||
Write-Host "clone_url: $($exists.clone_url)"
|
||||
return $exists
|
||||
}
|
||||
|
||||
$body = [ordered]@{
|
||||
name = $Name
|
||||
description = $Description
|
||||
private = [bool]$Private
|
||||
auto_init = $true
|
||||
has_issues = $true
|
||||
has_wiki = $false
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
$endpoint = if ($Owner) { "/orgs/$Owner/repos" } else { "/user/repos" }
|
||||
|
||||
Write-Host "Creating repository at: $endpoint" -ForegroundColor Cyan
|
||||
$created = & $ghApi -Method POST -Path $endpoint -BodyJson $body -ErrorAction Stop
|
||||
|
||||
[PSCustomObject]@{
|
||||
full_name = $created.full_name
|
||||
html_url = $created.html_url
|
||||
clone_url = $created.clone_url
|
||||
ssh_url = $created.ssh_url
|
||||
private = $created.private
|
||||
default_branch = $created.default_branch
|
||||
} | Format-List
|
||||
|
||||
$created
|
||||
@@ -0,0 +1,156 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
End-to-end deployment pipeline for a project going to this Coolify instance.
|
||||
|
||||
.DESCRIPTION
|
||||
Orchestrates the full flow:
|
||||
1. Initialize-CoolifyProject (optional, if -Init)
|
||||
2. Test-PreDeployChecklist (gate; aborts on FAIL)
|
||||
3. New-GitHubRepo (creates the repo, idempotent)
|
||||
4. git init/add/commit/push (local repo -> origin)
|
||||
5. New-CoolifyApplication (project + env + app + envs + deploy)
|
||||
6. Test-PostDeploy (verification)
|
||||
|
||||
All mutating steps ask for explicit confirmation unless -Force is given.
|
||||
|
||||
.PARAMETER AppPath
|
||||
Local path of the project. Defaults to current directory.
|
||||
|
||||
.PARAMETER AppName
|
||||
Name used for the GitHub repo and the Coolify app. Required.
|
||||
|
||||
.PARAMETER Fqdn
|
||||
https://<name>.urieljareth.org. Required.
|
||||
|
||||
.PARAMETER Stack
|
||||
Template to scaffold if -Init: node, python, compose, static.
|
||||
|
||||
.PARAMETER AppPort
|
||||
Internal listening port. Default 8080.
|
||||
|
||||
.PARAMETER EnvFile
|
||||
Optional .env file with KEY=VALUE lines to push into Coolify.
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch to push and deploy. Default: main.
|
||||
|
||||
.PARAMETER Init
|
||||
Scaffolde Dockerfile/compose before pushing (skip if already present).
|
||||
|
||||
.PARAMETER Force
|
||||
Skip per-step confirmation. Use with care.
|
||||
|
||||
.EXAMPLE
|
||||
.\Publish-ProjectToCoolify.ps1 -AppPath .\my-app -AppName my-app `
|
||||
-Fqdn https://my-app.urieljareth.org -Stack node -AppPort 8080
|
||||
#>
|
||||
param(
|
||||
[string]$AppPath = ".",
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
||||
[string]$AppName,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Fqdn,
|
||||
|
||||
[ValidateSet("node", "python", "compose", "static")]
|
||||
[string]$Stack = "node",
|
||||
|
||||
[int]$AppPort = 8080,
|
||||
|
||||
[string]$EnvFile,
|
||||
|
||||
[string]$Branch = "main",
|
||||
|
||||
[switch]$Init,
|
||||
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
function Call-Step([string]$Label, [scriptblock]$Block) {
|
||||
Write-Host "`n>>> $Label`n" -ForegroundColor Magenta
|
||||
& $Block
|
||||
if ($LASTEXITCODE -and $LASTEXITCODE -ne 0) { throw "Step failed: $Label (exit $LASTEXITCODE)" }
|
||||
}
|
||||
|
||||
function Confirm-Step([string]$Action) {
|
||||
if ($Force) { return $true }
|
||||
$r = Read-Host "Confirm: $Action`nProceed? (y/N)"
|
||||
return ($r -match '^(y|yes|s|si|sí)$')
|
||||
}
|
||||
|
||||
$appPath = (Resolve-Path -LiteralPath $AppPath).Path
|
||||
|
||||
if ($Init) {
|
||||
Call-Step "1. Scaffold Dockerfile/compose (Initialize-CoolifyProject)" {
|
||||
& (Join-Path $here "Initialize-CoolifyProject.ps1") -Path $appPath -Stack $Stack -AppPort $AppPort
|
||||
}
|
||||
}
|
||||
|
||||
Call-Step "2. Pre-deploy checklist (Test-PreDeployChecklist)" {
|
||||
& (Join-Path $here "Test-PreDeployChecklist.ps1") -Path $appPath -ExpectedPort $AppPort -Strict
|
||||
}
|
||||
|
||||
Call-Step "3. Create GitHub repo (New-GitHubRepo)" {
|
||||
& (Join-Path $here "New-GitHubRepo.ps1") -Name $AppName -Description "Deployed via coolify-deploy skill"
|
||||
}
|
||||
|
||||
$origin = "https://github.com/urieljarethbusiness-cpu/$AppName.git"
|
||||
Write-Host "`nOrigin: $origin" -ForegroundColor Cyan
|
||||
|
||||
Push-Location -LiteralPath $appPath
|
||||
try {
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $appPath ".git"))) {
|
||||
if (-not (Confirm-Step "git init + add remote origin")) { throw "Aborted." }
|
||||
git init -b $Branch
|
||||
git remote add origin $origin
|
||||
} else {
|
||||
$existing = git remote get-url origin 2>$null
|
||||
if ($existing -ne $origin) {
|
||||
if (-not (Confirm-Step "git remote set-url origin -> $origin")) { throw "Aborted." }
|
||||
git remote set-url origin $origin
|
||||
}
|
||||
}
|
||||
|
||||
Call-Step "4. git add / commit / push" {
|
||||
git add -A
|
||||
$pending = (git status --porcelain).Count
|
||||
if ($pending -gt 0) {
|
||||
if (-not (Confirm-Step "Commit $pending change(s) and push to origin/$Branch")) { throw "Aborted." }
|
||||
git commit -m "Initial deployment via coolify-deploy skill"
|
||||
} else {
|
||||
Write-Host " nothing to commit (clean tree)" -ForegroundColor DarkGray
|
||||
}
|
||||
git push -u origin $Branch 2>&1 | ForEach-Object { Write-Host " $_" -ForegroundColor DarkGray }
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Call-Step "5. Create Coolify application + deploy (New-CoolifyApplication)" {
|
||||
$args = @(
|
||||
"-RepoUrl", $origin,
|
||||
"-Branch", $Branch,
|
||||
"-Fqdn", $Fqdn,
|
||||
"-PortsExposes", "$AppPort"
|
||||
)
|
||||
if ($EnvFile) { $args += @("-EnvFile", $EnvFile) }
|
||||
if ($Force) { $args += "-Force" }
|
||||
& (Join-Path $here "New-CoolifyApplication.ps1") @args
|
||||
}
|
||||
|
||||
Call-Step "6. Post-deploy inspection (Test-PostDeploy)" {
|
||||
& (Join-Path $here "Test-PostDeploy.ps1") -Fqdn $Fqdn -ContainerName "$AppName-main"
|
||||
}
|
||||
|
||||
Call-Step "7. Operational verification: HTTP 200 + Playwright (Test-ServiceOnline)" {
|
||||
& (Join-Path $here "Test-ServiceOnline.ps1") -Fqdn $Fqdn -Retries 8
|
||||
}
|
||||
|
||||
Write-Host "`n=== Pipeline complete ===" -ForegroundColor Green
|
||||
Write-Host "App: $Fqdn" -ForegroundColor Cyan
|
||||
Write-Host "Repo: $origin" -ForegroundColor Cyan
|
||||
@@ -0,0 +1,75 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Verify a Coolify application after a deploy.
|
||||
|
||||
.DESCRIPTION
|
||||
Implements section 8 of docs/AGENTS-coolify-apps.md: runs the public
|
||||
endpoint check, the sibling DNS check from inside the app container, the
|
||||
container status snapshot, and the proxy logs for routing/cert errors.
|
||||
|
||||
Requires the existing repo scripts (Invoke-ProxmoxSsh.ps1,
|
||||
Get-CoolifyDockerStatus.ps1, Invoke-CoolifyApi.ps1). No new dependencies.
|
||||
|
||||
.PARAMETER Fqdn
|
||||
Full public URL, e.g. https://myapp.urieljareth.org.
|
||||
|
||||
.PARAMETER ContainerName
|
||||
App container name as seen by `docker ps` inside LXC 102 (used for the
|
||||
sibling DNS check and the proxy log filter).
|
||||
|
||||
.PARAMETER SiblingService
|
||||
Optional Docker service name to verify with `getent hosts` from inside the
|
||||
app container. Example: 'postgres' or 'app-db'.
|
||||
|
||||
.PARAMETER ApplicationUuid
|
||||
Optional Coolify application UUID. If provided, also fetches the app status
|
||||
via API and lists recent deployments.
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-PostDeploy.ps1 -Fqdn https://myapp.urieljareth.org `
|
||||
-ContainerName my-app-main -SiblingService postgres
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Fqdn,
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$ContainerName,
|
||||
|
||||
[string]$SiblingService,
|
||||
|
||||
[string]$ApplicationUuid
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = (Resolve-Path (Join-Path $here "..\..")).Path
|
||||
$ssh = Join-Path $repoRoot "scripts\Invoke-ProxmoxSsh.ps1"
|
||||
$dockerStatus = Join-Path $repoRoot "coolify_skill\scripts\Get-CoolifyDockerStatus.ps1"
|
||||
$coolifyApi = Join-Path $repoRoot "coolify_skill\scripts\Invoke-CoolifyApi.ps1"
|
||||
|
||||
function Get-SectionTitle($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan }
|
||||
|
||||
Get-SectionTitle "1. Public endpoint HTTP check ($Fqdn)"
|
||||
& curl.exe -sSI $Fqdn --max-time 15 | Select-Object -First 8
|
||||
|
||||
Get-SectionTitle "2. Container status in LXC 102"
|
||||
& $dockerStatus -Filter $ContainerName
|
||||
|
||||
if ($SiblingService) {
|
||||
Get-SectionTitle "3. Sibling DNS from inside '$ContainerName' -> '$SiblingService'"
|
||||
& $ssh -Command "pct exec 102 -- docker exec $ContainerName getent hosts $SiblingService" 2>&1
|
||||
}
|
||||
|
||||
Get-SectionTitle "4. coolify-proxy logs (filter: error | acme | certificate | $ContainerName)"
|
||||
$cmd = "pct exec 102 -- docker logs coolify-proxy --tail 80 2>&1 | grep -iE 'error|certificate|acme|$ContainerName' | tail -30"
|
||||
& $ssh -Command $cmd 2>&1
|
||||
|
||||
if ($ApplicationUuid) {
|
||||
Get-SectionTitle "5. Coolify API: app status + recent deployments"
|
||||
& $coolifyApi -Path "/applications/$ApplicationUuid" 2>&1 | Select-String -Pattern '"status"|"fqdn"|"name"|"ports_exposes"'
|
||||
& $coolifyApi -Path "/applications/$ApplicationUuid/deployments" 2>&1 | Select-Object -First 40
|
||||
}
|
||||
|
||||
Write-Host "`nVerification complete." -ForegroundColor Green
|
||||
@@ -0,0 +1,177 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Validate a project directory against the Coolify hard rules before pushing.
|
||||
|
||||
.DESCRIPTION
|
||||
Implements the pre-deploy checklist from docs/AGENTS-coolify-apps.md as an
|
||||
automated gate. Run this BEFORE pushing to GitHub and BEFORE creating the
|
||||
Coolify application. Reports each rule as PASS / WARN / FAIL with the
|
||||
concrete fix for any failure.
|
||||
|
||||
The script does NOT modify files. It only inspects.
|
||||
|
||||
.PARAMETER Path
|
||||
Project directory to validate. Defaults to the current directory.
|
||||
|
||||
.PARAMETER ExpectedPort
|
||||
Port the app is expected to listen on. Used to cross-check against
|
||||
ports_exposes if a compose file declares it, and against Dockerfile EXPOSE.
|
||||
|
||||
.PARAMETER Strict
|
||||
Treat WARN as failures (exit code 1). Default: only FAIL fails.
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-PreDeployChecklist.ps1 -Path .\my-app -ExpectedPort 8080
|
||||
#>
|
||||
param(
|
||||
[string]$Path = ".",
|
||||
|
||||
[string]$ExpectedPort,
|
||||
|
||||
[switch]$Strict
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = (Resolve-Path -LiteralPath $Path).Path
|
||||
$results = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
function Add-Result {
|
||||
param([string]$Id, [string]$Status, [string]$Message, [string]$Fix = "")
|
||||
$results.Add([PSCustomObject]@{
|
||||
Id = $Id
|
||||
Status = $Status
|
||||
Message = $Message
|
||||
Fix = $Fix
|
||||
})
|
||||
}
|
||||
|
||||
$composeFiles = @("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml") |
|
||||
ForEach-Object { Join-Path $root $_ } |
|
||||
Where-Object { Test-Path -LiteralPath $_ }
|
||||
|
||||
$dockerfile = @("Dockerfile", "Dockerfile.dev") |
|
||||
ForEach-Object { Join-Path $root $_ } |
|
||||
Where-Object { Test-Path -LiteralPath $_ } |
|
||||
Select-Object -First 1
|
||||
|
||||
# Rule 2.1 - DB host is service name, not localhost
|
||||
foreach ($cf in $composeFiles) {
|
||||
$txt = Get-Content -LiteralPath $cf -Raw
|
||||
if ($txt -match 'localhost|127\.0\.0\.1') {
|
||||
$svc = ($txt -split "`n" | Select-String -Pattern 'localhost|127\.0\.0\.1' | Select-Object -First 1)
|
||||
Add-Result "2.1-sibling-dns" "WARN" "$cf references localhost/127.0.0.1; if it is a DB/cache host for a sibling service, use the Docker service name." "Replace localhost with the sibling service name (see AGENTS-coolify-apps.md 2.1)."
|
||||
}
|
||||
}
|
||||
if ($results | Where-Object Id -eq '2.1-sibling-dns') {
|
||||
# already added
|
||||
} else {
|
||||
Add-Result "2.1-sibling-dns" "PASS" "No localhost/127.0.0.1 reference detected in compose files."
|
||||
}
|
||||
|
||||
# Rule 2.2 - coolify external network if app references shared services
|
||||
foreach ($cf in $composeFiles) {
|
||||
$txt = Get-Content -LiteralPath $cf -Raw
|
||||
if (($txt -match 'postgres|redis|mysql|mariadb|mongodb') -and ($txt -notmatch '(?ms)networks:.*?coolify:.*?external:\s*true')) {
|
||||
Add-Result "2.2-coolify-network" "WARN" "$cf references a DB/cache but does not declare the external 'coolify' network." "If the app must reach shared Coolify services, add the external coolify network (see AGENTS-coolify-apps.md 2.2)."
|
||||
}
|
||||
}
|
||||
if (-not ($results | Where-Object Id -eq '2.2-coolify-network')) {
|
||||
Add-Result "2.2-coolify-network" "PASS" "No external coolify network requirement detected, or it is correctly declared."
|
||||
}
|
||||
|
||||
# Rule 2.3 - no published 80/443
|
||||
$badPorts = $false
|
||||
foreach ($cf in $composeFiles) {
|
||||
$txt = Get-Content -LiteralPath $cf -Raw
|
||||
if ($txt -match '(?m)^\s*-\s*"?80:80"?|^\s*-\s*"?443:443"?|"80:80"|"443:443"|ports_exposes.*\b80\b.*\b443\b') {
|
||||
Add-Result "2.3-no-80-443-publish" "FAIL" "$cf publishes host ports 80 or 443 (owned by coolify-proxy)." "Remove the host port mapping; let Traefik route (AGENTS-coolify-apps.md 2.3)."
|
||||
$badPorts = $true
|
||||
}
|
||||
}
|
||||
if (-not $badPorts) {
|
||||
Add-Result "2.3-no-80-443-publish" "PASS" "No host 80/443 publishing found."
|
||||
}
|
||||
|
||||
# Rule 2.4 - FQDN hint (informational)
|
||||
Add-Result "2.4-fqdn-set" "INFO" "Remember to set FQDN to https://<name>.urieljareth.org BEFORE first deploy (not via Coolify's auto UUID subdomain)." "Set -Fqdn when calling New-CoolifyApplication.ps1."
|
||||
|
||||
# Rule 2.5 - no obvious secrets in tree
|
||||
$secretPatterns = @(
|
||||
'AKIA[0-9A-Z]{16}',
|
||||
'ghp_[A-Za-z0-9]{36}',
|
||||
'github_pat_[A-Za-z0-9_]{82}',
|
||||
'-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----',
|
||||
'postgres(ql)?://[^:\s]+:[^@\s]+@'
|
||||
)
|
||||
$found = $false
|
||||
Get-ChildItem -LiteralPath $root -Recurse -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.FullName -notmatch '\\(\.git|node_modules|vendor|dist|build)\\' } |
|
||||
ForEach-Object {
|
||||
try {
|
||||
$content = Get-Content -LiteralPath $_.FullName -Raw -ErrorAction Stop
|
||||
foreach ($pat in $secretPatterns) {
|
||||
if ($content -match $pat) {
|
||||
Add-Result "2.5-no-secrets-in-repo" "FAIL" "Potential secret matching pattern '$pat' found in $($_.FullName)." "Remove the secret and inject it as env via Coolify (AGENTS-coolify-apps.md 2.5)."
|
||||
$found = $true
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (-not $found) {
|
||||
Add-Result "2.5-no-secrets-in-repo" "PASS" "No obvious hardcoded secrets detected."
|
||||
}
|
||||
|
||||
# Rule 4 - healthcheck present
|
||||
$hcFound = $false
|
||||
foreach ($cf in $composeFiles) {
|
||||
$txt = Get-Content -LiteralPath $cf -Raw
|
||||
if ($txt -match 'healthcheck:') { $hcFound = $true }
|
||||
}
|
||||
if ($dockerfile) {
|
||||
$txt = Get-Content -LiteralPath $dockerfile -Raw
|
||||
if ($txt -match 'HEALTHCHECK') { $hcFound = $true }
|
||||
}
|
||||
if ($hcFound) {
|
||||
Add-Result "4-healthcheck" "PASS" "Healthcheck detected."
|
||||
} else {
|
||||
Add-Result "4-healthcheck" "WARN" "No healthcheck found in compose or Dockerfile." "Add a healthcheck on a DB-independent endpoint (AGENTS-coolify-apps.md section 4)."
|
||||
}
|
||||
|
||||
# Rule 5 - named volumes for persistence
|
||||
foreach ($cf in $composeFiles) {
|
||||
$txt = Get-Content -LiteralPath $cf -Raw
|
||||
if ($txt -match 'volumes:' -and $txt -notmatch '(?ms)^volumes:\s*\n\s+\S+:') {
|
||||
Add-Result "5-named-volumes" "WARN" "$cf has volumes but no top-level named volumes section." "Declare named volumes to survive redeploys (AGENTS-coolify-apps.md section 5)."
|
||||
}
|
||||
}
|
||||
if (-not ($results | Where-Object Id -eq '5-named-volumes')) {
|
||||
Add-Result "5-named-volumes" "PASS" "Volume declarations look consistent."
|
||||
}
|
||||
|
||||
# Cross-check ExpectedPort vs Dockerfile EXPOSE
|
||||
if ($ExpectedPort -and $dockerfile) {
|
||||
$txt = Get-Content -LiteralPath $dockerfile -Raw
|
||||
if ($txt -match 'EXPOSE\s+(\d+)') {
|
||||
$exposed = $matches[1]
|
||||
if ($exposed -ne $ExpectedPort) {
|
||||
Add-Result "ports-match" "WARN" "Dockerfile EXPOSE=$exposed but ExpectedPort=$ExpectedPort." "Make ports_exposes match the real listening port."
|
||||
} else {
|
||||
Add-Result "ports-match" "PASS" "Dockerfile EXPOSE matches ExpectedPort ($ExpectedPort)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$results | Format-Table -AutoSize
|
||||
|
||||
$failed = $results | Where-Object Status -eq 'FAIL'
|
||||
$warned = $results | Where-Object Status -eq 'WARN'
|
||||
if ($failed) {
|
||||
Write-Host "`nFAIL: $($failed.Count) hard rule(s) violated. Fix before pushing." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ($warned) {
|
||||
Write-Host "`nWARN: $($warned.Count) soft warning(s). Review before deploy." -ForegroundColor Yellow
|
||||
if ($Strict) { exit 1 }
|
||||
}
|
||||
Write-Host "`nChecklist complete." -ForegroundColor Green
|
||||
@@ -0,0 +1,238 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Operational verification: confirm a deployed service actually serves its
|
||||
page with HTTP 200 and no client-side crashes.
|
||||
|
||||
.DESCRIPTION
|
||||
The final "is it live and working?" gate after a Coolify deploy. Runs two
|
||||
layers of checks and FAILS (non-zero exit) if either is unhealthy:
|
||||
|
||||
Layer 1 - HTTP layer (curl.exe)
|
||||
HEAD or GET against the FQDN, follow redirects, expect 2xx.
|
||||
Cheap, no browser. Catches Traefik 502, wrong ports_exposes, TLS
|
||||
failure, missing FQDN route.
|
||||
|
||||
Layer 2 - Browser layer (Playwright via Node, verify-online.mjs)
|
||||
Real Chromium navigation. Catches client-side JS crashes
|
||||
(pageerror), failed main-frame requests, runtime exceptions, and
|
||||
verifies the page actually rendered (not a blank/error page).
|
||||
Saves a PNG screenshot for evidence.
|
||||
|
||||
This script complements Test-PostDeploy.ps1 (which inspects Coolify state,
|
||||
container status, proxy logs, sibling DNS). Run BOTH:
|
||||
- Test-PostDeploy.ps1 -> "did Coolify ship it correctly?"
|
||||
- Test-ServiceOnline.ps1 -> "does the user actually get a working page?"
|
||||
|
||||
.PARAMETER Fqdn
|
||||
Full public URL, e.g. https://cotizador.urieljareth.org. Required.
|
||||
|
||||
.PARAMETER Path
|
||||
Path appended to the FQDN. Default: "/".
|
||||
Use a non-authenticated landing route (e.g. /, /login, /health) - Playwright
|
||||
will follow redirects, so / often works even when the app forces login.
|
||||
|
||||
.PARAMETER ExpectedStatusCode
|
||||
HTTP status code expected from Layer 1. Default: 200. Use 302 if the
|
||||
landing path legitimately redirects (e.g. / -> /login).
|
||||
|
||||
.PARAMETER ExpectTitle
|
||||
Optional regex the document.title must match. Example: "Cotizador|Login".
|
||||
|
||||
.PARAMETER Screenshot
|
||||
Optional PNG path for the Playwright screenshot. Defaults to
|
||||
"$env:TEMP\coolify-<host>-<timestamp>.png".
|
||||
|
||||
.PARAMETER SkipBrowser
|
||||
Skip Layer 2 (Playwright). Use only on headless/CI environments without
|
||||
a display, or when Chromium is unavailable.
|
||||
|
||||
.PARAMETER TimeoutMs
|
||||
Per-attempt timeout (curl and Playwright). Default: 30000.
|
||||
|
||||
.PARAMETER Retries
|
||||
Number of times to retry the FULL check with a 10s gap. Coolify + Traefik
|
||||
DNS challenge + warm-up can take a minute or two on first deploy.
|
||||
Default: 6 (so up to ~1 minute of retries).
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-ServiceOnline.ps1 -Fqdn https://cotizador.urieljareth.org `
|
||||
-ExpectTitle "Cotizador|Login" -Retries 6
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-ServiceOnline.ps1 -Fqdn https://myapp.urieljareth.org -SkipBrowser
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Fqdn,
|
||||
|
||||
[string]$Path = "/",
|
||||
|
||||
[int]$ExpectedStatusCode = 200,
|
||||
|
||||
[string]$ExpectTitle,
|
||||
|
||||
[string]$Screenshot,
|
||||
|
||||
[switch]$SkipBrowser,
|
||||
|
||||
[int]$TimeoutMs = 30000,
|
||||
|
||||
[int]$Retries = 6
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = (Resolve-Path (Join-Path $here "..\..")).Path
|
||||
$verifyNode = Join-Path $here "verify-online.mjs"
|
||||
$nodeModulesRoot = Join-Path $repoRoot "node_modules"
|
||||
|
||||
function Write-Section($t) { Write-Host "`n=== $t ===" -ForegroundColor Cyan }
|
||||
|
||||
function Get-FullUrl {
|
||||
param([string]$Base, [string]$P)
|
||||
$b = $Base.TrimEnd("/")
|
||||
if ($P -eq "/") { return $b + "/" }
|
||||
if (-not $P.StartsWith("/")) { $P = "/" + $P }
|
||||
return $b + $P
|
||||
}
|
||||
|
||||
function Test-HttpLayer {
|
||||
param([string]$Url, [int]$Expected, [int]$TimeoutMs)
|
||||
Write-Section "Layer 1 - HTTP (curl) -> $Url"
|
||||
$tmp = [System.IO.Path]::GetTempFileName()
|
||||
try {
|
||||
# -L follow redirects, -o NUL discard body, -w write status to tmp, --max-time total budget.
|
||||
$codeField = "%{http_code}"
|
||||
& curl.exe -sS -L -k -o NUL `
|
||||
-w $codeField `
|
||||
--max-time ([math]::Floor($TimeoutMs / 1000)) `
|
||||
$Url 2>$null > $tmp
|
||||
$raw = (Get-Content -LiteralPath $tmp -Raw -ErrorAction SilentlyContinue)
|
||||
$status = -1
|
||||
if ([int]::TryParse(($raw -replace '\s',''), [ref]$status)) {
|
||||
Write-Host " HTTP status: $status" -ForegroundColor $(if ($status -eq $Expected -or ($status -ge 200 -and $status -lt 400)) { 'Green' } else { 'Red' })
|
||||
return $status
|
||||
}
|
||||
Write-Host " Could not parse HTTP status. Raw: '$raw'" -ForegroundColor Red
|
||||
return -1
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
function Test-BrowserLayer {
|
||||
param(
|
||||
[string]$Url,
|
||||
[string]$TitleRegex,
|
||||
[string]$ScreenshotPath,
|
||||
[int]$TimeoutMs
|
||||
)
|
||||
Write-Section "Layer 2 - Browser (Playwright) -> $Url"
|
||||
if (-not (Test-Path -LiteralPath $nodeModulesRoot)) {
|
||||
Write-Host " node_modules not found at $nodeModulesRoot - run 'npm install' in the manager repo." -ForegroundColor Yellow
|
||||
Write-Host " Skipping browser layer (treat as inconclusive)." -ForegroundColor Yellow
|
||||
return $true # Do not fail the whole pipeline if browser deps are missing.
|
||||
}
|
||||
|
||||
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
||||
Write-Host " node not in PATH. Skipping browser layer." -ForegroundColor Yellow
|
||||
return $true
|
||||
}
|
||||
|
||||
$nodeArgs = @($verifyNode, $Url, "--timeout", "$TimeoutMs")
|
||||
if ($TitleRegex) { $nodeArgs += @("--expect-title", $TitleRegex) }
|
||||
if ($ScreenshotPath) { $nodeArgs += @("--screenshot", $ScreenshotPath) }
|
||||
|
||||
# Run from the manager repo root so `require('playwright')` resolves.
|
||||
Push-Location -LiteralPath $repoRoot
|
||||
$jsonOut = & node @nodeArgs 2>&1
|
||||
$exitCode = $LASTEXITCODE
|
||||
Pop-Location
|
||||
|
||||
# The last non-empty line that parses as JSON is the result from verify-online.mjs.
|
||||
$parsed = $null
|
||||
foreach ($line in ($jsonOut -split "`n")) {
|
||||
$t = $line.Trim()
|
||||
if (-not $t) { continue }
|
||||
try { $parsed = $t | ConvertFrom-Json } catch { }
|
||||
}
|
||||
|
||||
if ($null -eq $parsed) {
|
||||
Write-Host " No JSON result from Node script (exit=$exitCode)." -ForegroundColor Red
|
||||
Write-Host " Raw output: $jsonOut" -ForegroundColor DarkGray
|
||||
return $false
|
||||
}
|
||||
|
||||
Write-Host " ok : $($parsed.ok)" -ForegroundColor $(if ($parsed.ok) { 'Green' } else { 'Red' })
|
||||
Write-Host " httpStatus : $($parsed.httpStatus)"
|
||||
Write-Host " title : $($parsed.title)"
|
||||
Write-Host " elapsedMs : $($parsed.elapsedMs)"
|
||||
if ($parsed.pageErrors -and $parsed.pageErrors.Count -gt 0) {
|
||||
Write-Host " pageErrors : $($parsed.pageErrors.Count)" -ForegroundColor Yellow
|
||||
$parsed.pageErrors | Select-Object -First 3 | ForEach-Object { Write-Host " - $_" -ForegroundColor DarkYellow }
|
||||
}
|
||||
if ($parsed.requestFailures -and $parsed.requestFailures.Count -gt 0) {
|
||||
Write-Host " reqFailures : $($parsed.requestFailures.Count)" -ForegroundColor Yellow
|
||||
$parsed.requestFailures | Select-Object -First 3 | ForEach-Object { Write-Host " - $($_.method) $($_.url) [$($_.errorText)]" -ForegroundColor DarkYellow }
|
||||
}
|
||||
if ($parsed.consoleErrors -and $parsed.consoleErrors.Count -gt 0) {
|
||||
Write-Host " consoleErrors: $($parsed.consoleErrors.Count)" -ForegroundColor Yellow
|
||||
}
|
||||
if ($parsed.error) {
|
||||
Write-Host " error : $($parsed.error)" -ForegroundColor Red
|
||||
}
|
||||
if ($parsed.screenshot) {
|
||||
Write-Host " screenshot : $($parsed.screenshot)" -ForegroundColor DarkGray
|
||||
}
|
||||
return [bool]$parsed.ok
|
||||
}
|
||||
|
||||
$url = Get-FullUrl -Base $Fqdn -P $Path
|
||||
|
||||
if (-not $Screenshot) {
|
||||
$host_ = ([System.Uri]$Fqdn).Host
|
||||
$ts = (Get-Date -Format "yyyyMMdd-HHmmss")
|
||||
$Screenshot = Join-Path $env:TEMP "coolify-$host_-$ts.png"
|
||||
}
|
||||
|
||||
$attempt = 0
|
||||
$lastResult = $false
|
||||
while ($attempt -lt $Retries) {
|
||||
$attempt++
|
||||
Write-Host "`n>>> Attempt $attempt/$Retries" -ForegroundColor Magenta
|
||||
|
||||
$httpStatus = Test-HttpLayer -Url $url -Expected $ExpectedStatusCode -TimeoutMs $TimeoutMs
|
||||
$httpOk = ($httpStatus -ge 200 -and $httpStatus -lt 400)
|
||||
if (-not $httpOk) {
|
||||
Write-Host " HTTP layer not ready (status=$httpStatus)." -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$browserOk = $true
|
||||
if ($httpOk -and -not $SkipBrowser) {
|
||||
$browserOk = Test-BrowserLayer -Url $url -TitleRegex $ExpectTitle -ScreenshotPath $Screenshot -TimeoutMs $TimeoutMs
|
||||
} elseif ($SkipBrowser) {
|
||||
Write-Host " Browser layer skipped (-SkipBrowser)." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
if ($httpOk -and $browserOk) {
|
||||
$lastResult = $true
|
||||
break
|
||||
}
|
||||
|
||||
if ($attempt -lt $Retries) {
|
||||
Write-Host " Waiting 10s before retry..." -ForegroundColor DarkGray
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
Write-Section "Verdict"
|
||||
if ($lastResult) {
|
||||
Write-Host " SERVICE ONLINE: $url returned healthy HTTP and a clean page render." -ForegroundColor Green
|
||||
Write-Host " Screenshot: $Screenshot" -ForegroundColor DarkGray
|
||||
exit 0
|
||||
} else {
|
||||
Write-Host " SERVICE NOT VERIFIED ONLINE after $attempt attempt(s)." -ForegroundColor Red
|
||||
Write-Host " Check: Coolify deploy logs, ports_exposes, FQDN route, TLS DNS challenge, app boot." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Configure-CoolifyComposeApp.mjs
|
||||
// Drive the Coolify web UI (v4.1.2, Livewire) to configure an EXISTING git-based
|
||||
// application as a Docker Compose build-pack app. Needed because this instance's
|
||||
// REST API does NOT expose /applications/* (all 404) — see references/coolify-4.1.2-notes.md.
|
||||
//
|
||||
// It performs, idempotently and each guarded by a flag, the steps that the API can't:
|
||||
// 1. Build Pack -> Docker Compose (+ optional compose location)
|
||||
// 2. Reload Compose File (MANDATORY once, else deploy crashes Yaml::parse(null))
|
||||
// 3. Env vars (Developer view bulk -> "Save All Environment Variables")
|
||||
// 4. Per-service domains (parsedServiceDomains.<svc>.domain) so Traefik routes
|
||||
// the real FQDN instead of a random *.urieljareth.org subdomain.
|
||||
//
|
||||
// Trigger the actual deploy separately via the API: GET /deploy?uuid=<uuid>
|
||||
// (that endpoint works even though app CRUD is 404). Then verify with Test-ServiceOnline.ps1.
|
||||
//
|
||||
// Env / args:
|
||||
// CF_STATE Playwright storageState from coolify-login.mjs (required)
|
||||
// CF_APP_URL .../project/{p}/environment/{e}/application/{a} (required)
|
||||
// CF_PW_BASE package.json whose node_modules has playwright (default: manager repo)
|
||||
// CF_BUILDPACK=dockercompose set the build pack (optional)
|
||||
// CF_COMPOSE_LOCATION=/docker-compose.coolify.yml (optional)
|
||||
// CF_RELOAD=1 click "Reload Compose File" (do this after buildpack switch / repo change)
|
||||
// CF_ENVBULK="K=V\nK2=V2" bulk env vars to Save All (optional)
|
||||
// CF_DOMAINS='{"web":"https://x.org","api":"https://y.org"}' per-service domains (optional)
|
||||
// CF_SHOT=path.png screenshot (optional)
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(process.env.CF_PW_BASE || 'H:/MegaSync/Proyectos/Proxmox & Coolify Manager/package.json');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const STATE = process.env.CF_STATE, URL = process.env.CF_APP_URL;
|
||||
if (!STATE || !URL) { console.error('Need CF_STATE and CF_APP_URL'); process.exit(2); }
|
||||
const SHOT = process.env.CF_SHOT || 'cf_configure.png';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1600, height: 1400 }, ignoreHTTPSErrors: true, storageState: STATE });
|
||||
const page = await ctx.newPage();
|
||||
const dismiss = async () => { for (const t of ['Accept and Close','Acknowledge & Disable This Popup','Maybe next time']) { const b = page.getByRole('button',{name:t,exact:false}); if (await b.count().catch(()=>0)) { await b.first().click().catch(()=>{}); await page.waitForTimeout(400);} } };
|
||||
const clickSave = async (label='Save') => { const b = page.getByRole('button',{name:label,exact:(label==='Save')}); const n = await b.count().catch(()=>0); for (let i=0;i<n;i++){ const x=b.nth(i); if(await x.isVisible().catch(()=>false)){ await x.scrollIntoViewIfNeeded().catch(()=>{}); await x.click().catch(()=>{}); return true;} } return false; };
|
||||
const go = async () => { await page.goto(URL,{waitUntil:'domcontentloaded',timeout:45000}); await page.waitForTimeout(3500); await dismiss(); };
|
||||
|
||||
try {
|
||||
// 1. Build Pack
|
||||
if (process.env.CF_BUILDPACK) {
|
||||
await go();
|
||||
const bp = page.locator('select:has(option[value="dockercompose"])').first();
|
||||
await bp.waitFor({ state:'attached', timeout:15000 });
|
||||
if (await bp.inputValue().catch(()=>'') !== process.env.CF_BUILDPACK) {
|
||||
await bp.selectOption(process.env.CF_BUILDPACK); await page.waitForTimeout(1500);
|
||||
await clickSave(); await page.waitForTimeout(2500);
|
||||
console.log('buildPack ->', process.env.CF_BUILDPACK);
|
||||
} else console.log('buildPack already', process.env.CF_BUILDPACK);
|
||||
}
|
||||
// (optional) compose location
|
||||
if (process.env.CF_COMPOSE_LOCATION) {
|
||||
await go();
|
||||
const inputs = await page.locator('input[type="text"]').all();
|
||||
for (const inp of inputs) { const v = await inp.inputValue().catch(()=>''); const ph = await inp.getAttribute('placeholder').catch(()=>''); if (/docker-compose\.ya?ml/.test(v)||/docker-compose\.ya?ml/.test(ph||'')) { await inp.fill(process.env.CF_COMPOSE_LOCATION); await inp.blur().catch(()=>{}); break; } }
|
||||
await clickSave(); await page.waitForTimeout(2000);
|
||||
console.log('composeLocation ->', process.env.CF_COMPOSE_LOCATION);
|
||||
}
|
||||
// 2. Reload Compose File (persists parsed docker_compose; avoids Yaml::parse(null))
|
||||
if (process.env.CF_RELOAD) {
|
||||
await go();
|
||||
const rc = page.getByRole('button',{name:'Reload Compose File',exact:false});
|
||||
if (await rc.count().catch(()=>0)) { await rc.first().click(); await page.waitForTimeout(4000); await clickSave(); await page.waitForTimeout(2500); console.log('reloaded compose file'); }
|
||||
else console.log('Reload Compose File button not found');
|
||||
}
|
||||
// 3. Env vars (Developer view bulk)
|
||||
if (process.env.CF_ENVBULK) {
|
||||
await page.goto(URL.replace(/\/?$/, '') + '/environment-variables', { waitUntil:'domcontentloaded', timeout:45000 });
|
||||
await page.waitForTimeout(3000); await dismiss();
|
||||
const dev = page.getByRole('button',{name:'Developer view',exact:false});
|
||||
if (await dev.count().catch(()=>0)) { await dev.first().click(); await page.waitForTimeout(1200); }
|
||||
const tas = await page.locator('textarea').all(); let best=null, area=-1;
|
||||
for (const ta of tas){ if(!(await ta.isVisible().catch(()=>false)))continue; const bb=await ta.boundingBox().catch(()=>null); const a=bb?bb.width*bb.height:0; if(a>area){area=a;best=ta;} }
|
||||
if (best){ await best.click(); await best.fill(process.env.CF_ENVBULK); await best.blur().catch(()=>{}); await page.waitForTimeout(500); await clickSave('Save All Environment Variables'); await page.waitForTimeout(2500); console.log('env vars saved'); }
|
||||
else console.log('bulk textarea not found');
|
||||
}
|
||||
// 4. Per-service domains
|
||||
if (process.env.CF_DOMAINS) {
|
||||
const map = JSON.parse(process.env.CF_DOMAINS);
|
||||
await go();
|
||||
for (const [svc, dom] of Object.entries(map)) {
|
||||
const sel = `input[wire\\:model="parsedServiceDomains.${svc}.domain"]`;
|
||||
const loc = page.locator(sel).first();
|
||||
if (await loc.count().catch(()=>0)) { await loc.fill(dom); await loc.blur().catch(()=>{}); await page.waitForTimeout(400); console.log(`domain ${svc} -> ${dom}`); }
|
||||
else console.log(`domain input for '${svc}' not found`);
|
||||
}
|
||||
await clickSave(); await page.waitForTimeout(2500);
|
||||
}
|
||||
await page.screenshot({ path: SHOT, fullPage: true }).catch(()=>{});
|
||||
console.log('DONE');
|
||||
} catch (e) { console.error('ERR', e.message); await page.screenshot({ path: SHOT }).catch(()=>{}); process.exitCode = 1; }
|
||||
finally { await browser.close(); }
|
||||
@@ -0,0 +1,40 @@
|
||||
// coolify-login.mjs — log into the Coolify web UI and save a Playwright storageState.
|
||||
// Needed because this Coolify instance (v4.1.2) does NOT expose the /applications/*
|
||||
// REST API (all 404), so application config must be driven through the UI.
|
||||
//
|
||||
// Usage (from the manager repo root, env loaded via .env.local.ps1):
|
||||
// node deploy_skill/scripts/coolify-ui/coolify-login.mjs <out-state.json> [shot.png]
|
||||
// Env:
|
||||
// CF_EMAIL / CF_PASS credentials (map from $env:COOLIFY_EMAIL / $env:COOLIFY_PASSWORD)
|
||||
// CF_BASE base URL, default https://coolify.urieljareth.org
|
||||
// CF_PW_BASE path to a package.json whose node_modules has playwright
|
||||
// (default: this manager repo)
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(process.env.CF_PW_BASE || 'H:/MegaSync/Proyectos/Proxmox & Coolify Manager/package.json');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const BASE = (process.env.CF_BASE || 'https://coolify.urieljareth.org').replace(/\/$/, '');
|
||||
const EMAIL = process.env.CF_EMAIL, PASS = process.env.CF_PASS;
|
||||
const OUT = process.argv[2] || 'coolify_state.json';
|
||||
const SHOT = process.argv[3] || 'coolify_login.png';
|
||||
if (!EMAIL || !PASS) { console.error('Missing CF_EMAIL/CF_PASS'); process.exit(2); }
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const ctx = await browser.newContext({ viewport: { width: 1600, height: 1000 }, ignoreHTTPSErrors: true });
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded', timeout: 45000 });
|
||||
await page.waitForTimeout(1500);
|
||||
for (const s of ['input[type="email"]','input[name="email"]','#email']) { const el = await page.$(s); if (el) { await el.fill(EMAIL); break; } }
|
||||
for (const s of ['input[type="password"]','input[name="password"]','#password']) { const el = await page.$(s); if (el) { await el.fill(PASS); break; } }
|
||||
const btn = await page.$('button[type="submit"]') || await page.$('button:has-text("Login")');
|
||||
if (btn) await btn.click(); else await page.keyboard.press('Enter');
|
||||
await page.waitForTimeout(4000);
|
||||
await page.waitForLoadState('networkidle', { timeout: 20000 }).catch(()=>{});
|
||||
const ok = !/\/login/.test(page.url());
|
||||
console.log('LOGIN_OK:', ok, 'url:', page.url());
|
||||
await page.screenshot({ path: SHOT }).catch(()=>{});
|
||||
if (ok) { await ctx.storageState({ path: OUT }); console.log('storageState ->', OUT); process.exitCode = 0; }
|
||||
else { console.log('login failed'); process.exitCode = 1; }
|
||||
} catch (e) { console.error('ERR', e.message); process.exitCode = 1; }
|
||||
finally { await browser.close(); }
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
// verify-online.mjs — Browser-level operational verification for a deployed service.
|
||||
// Used by Test-ServiceOnline.ps1. Exits 0 on success, non-zero on failure.
|
||||
//
|
||||
// Usage:
|
||||
// node verify-online.mjs <url> [--screenshot <path>] [--timeout <ms>] [--expect-title <regex>]
|
||||
//
|
||||
// Checks performed:
|
||||
// 1. Page navigates to status < 400 (HTTP layer via Playwright)
|
||||
// 2. No uncaught `pageerror` events (JS crashes)
|
||||
// 3. No failed main-frame network requests (DNS, 5xx on document, etc.)
|
||||
// 4. Optional: document.title matches a regex
|
||||
// 5. Saves a screenshot (PNG) when --screenshot is provided
|
||||
//
|
||||
// Output: a single JSON line on stdout with the result; human logs go to stderr.
|
||||
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
console.error("Usage: verify-online.mjs <url> [--screenshot <path>] [--timeout <ms>] [--expect-title <regex>] [--no-headless]");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const url = args[0];
|
||||
const opts = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === "--screenshot") opts.screenshot = args[++i];
|
||||
else if (a === "--timeout") opts.timeout = parseInt(args[++i], 10);
|
||||
else if (a === "--expect-title") opts.expectTitle = args[++i];
|
||||
else if (a === "--no-headless") opts.headless = false;
|
||||
}
|
||||
|
||||
const timeout = opts.timeout || 30000;
|
||||
const headless = opts.headless !== false;
|
||||
|
||||
const result = {
|
||||
url,
|
||||
ok: false,
|
||||
httpStatus: null,
|
||||
title: null,
|
||||
pageErrors: [],
|
||||
requestFailures: [],
|
||||
consoleErrors: [],
|
||||
screenshot: null,
|
||||
elapsedMs: null,
|
||||
};
|
||||
|
||||
const startedAt = Date.now();
|
||||
let browser;
|
||||
try {
|
||||
browser = await chromium.launch({ headless });
|
||||
const context = await browser.newContext({
|
||||
ignoreHTTPSErrors: true,
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
|
||||
page.on("pageerror", (err) => {
|
||||
result.pageErrors.push(err.message);
|
||||
});
|
||||
page.on("requestfailed", (req) => {
|
||||
const u = req.url();
|
||||
// Ignore third-party analytics/CDN noise; surface only main-frame document/sub-resource
|
||||
// failures relevant to the app itself.
|
||||
const failure = req.failure();
|
||||
result.requestFailures.push({
|
||||
url: u,
|
||||
method: req.method(),
|
||||
errorText: failure ? failure.errorText : "unknown",
|
||||
resourceType: req.resourceType(),
|
||||
});
|
||||
});
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
const txt = msg.text();
|
||||
// Suppress noisy third-party errors that don't affect operability.
|
||||
if (!/favicon|googletagmanager|google-analytics|doubleclick|sentry/i.test(txt)) {
|
||||
result.consoleErrors.push(txt);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const response = await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
||||
if (!response) {
|
||||
throw new Error("Navigation returned no response (blocked or timeout).");
|
||||
}
|
||||
result.httpStatus = response.status();
|
||||
result.title = await page.title();
|
||||
|
||||
if (opts.expectTitle) {
|
||||
const re = new RegExp(opts.expectTitle);
|
||||
if (!re.test(result.title || "")) {
|
||||
throw new Error(`Title "${result.title}" does not match /${opts.expectTitle}/`);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.httpStatus >= 400) {
|
||||
throw new Error(`HTTP ${result.httpStatus} from ${url}`);
|
||||
}
|
||||
if (result.pageErrors.length > 0) {
|
||||
throw new Error(`Page threw ${result.pageErrors.length} uncaught error(s): ${result.pageErrors.slice(0, 3).join(" | ")}`);
|
||||
}
|
||||
|
||||
// Give SPA a moment to settle, then capture screenshot.
|
||||
await page.waitForTimeout(1500);
|
||||
if (opts.screenshot) {
|
||||
try {
|
||||
await page.screenshot({ path: opts.screenshot, fullPage: false });
|
||||
result.screenshot = opts.screenshot;
|
||||
} catch (e) {
|
||||
// Non-fatal: screenshot is best-effort.
|
||||
console.error(`[warn] screenshot failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
result.ok = true;
|
||||
} catch (err) {
|
||||
result.error = err.message;
|
||||
result.ok = false;
|
||||
} finally {
|
||||
if (browser) await browser.close().catch(() => {});
|
||||
result.elapsedMs = Date.now() - startedAt;
|
||||
console.log(JSON.stringify(result));
|
||||
}
|
||||
|
||||
process.exit(result.ok ? 0 : 1);
|
||||
@@ -0,0 +1,309 @@
|
||||
# AGENTS — Building apps that deploy cleanly on this Coolify instance
|
||||
|
||||
> **Audience:** LLMs and code agents that are *developing* an application or service
|
||||
> intended to run on this specific self-hosted Coolify instance.
|
||||
>
|
||||
> **Purpose:** This is the single source of truth for *compatibility*. Follow it while
|
||||
> writing the app (Dockerfile, `docker-compose.yaml`, env handling, ports) so the first
|
||||
> deploy works instead of going through the trial-and-error captured in the `docs/`
|
||||
> issues. For *operating* an already-deployed app, see [`../CLAUDE.md`](../CLAUDE.md)
|
||||
> and the runbooks under [`runbooks/`](runbooks/).
|
||||
|
||||
This guide is portable: copy it into the new app's repo (e.g. as its own `AGENTS.md`)
|
||||
so the agent building that app has the constraints on hand.
|
||||
|
||||
---
|
||||
|
||||
## 1. The deployment environment (you cannot change this)
|
||||
|
||||
```
|
||||
Internet
|
||||
→ Cloudflare Tunnel (dashboard-managed, *.urieljareth.org)
|
||||
→ Traefik (container: coolify-proxy, terminates TLS, routes by Host header)
|
||||
→ your app container (Docker, inside LXC 102)
|
||||
```
|
||||
|
||||
- Everything runs as Docker containers **inside Proxmox LXC `102`** on host
|
||||
`192.168.0.200`. There is no direct Docker or LAN access — operators reach it only
|
||||
via `ssh [email protected] → pct exec 102 -- docker ...`.
|
||||
- The public edge is a **dashboard-managed Cloudflare Tunnel**; ingress rules live in
|
||||
the Cloudflare Zero Trust dashboard, **not** in files on the host.
|
||||
- TLS is terminated by **Traefik** (`coolify-proxy`), which also obtains Let's Encrypt
|
||||
certificates automatically via **DNS challenge**.
|
||||
- Coolify manages the lifecycle (build, deploy, env, domains) and writes a generated
|
||||
compose to `/data/coolify/applications/<UUID>/docker-compose.yaml`.
|
||||
|
||||
Source of truth for topology: [`proxmox-inventory.md`](proxmox-inventory.md) and
|
||||
[`runbooks/cloudflare-tunnel.md`](runbooks/cloudflare-tunnel.md).
|
||||
|
||||
---
|
||||
|
||||
## 2. Hard rules (non-negotiable)
|
||||
|
||||
Each rule lists **what**, **why**, and **how to verify**.
|
||||
|
||||
### 2.1 Networking — reach siblings by Docker service name, never `localhost`
|
||||
|
||||
- **Rule:** When your app and its database/cache run as sibling containers on the same
|
||||
Docker network, the app must connect using the **Docker service name**, not
|
||||
`localhost`/`127.0.0.1`.
|
||||
- **Why:** `localhost` resolves to the app's own container, not the DB. This is the
|
||||
single most common "cannot connect to database" failure on this host
|
||||
(see [`../CLAUDE.md`](../CLAUDE.md) and [`runbooks/nextcloud.md`](runbooks/nextcloud.md),
|
||||
where Nextcloud's `dbhost` must be `nextcloud-db`).
|
||||
- **Verify:**
|
||||
```powershell
|
||||
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec <app> getent hosts <db-service-name>"
|
||||
```
|
||||
It must return the DB container's IP.
|
||||
|
||||
### 2.2 Networking — join the external `coolify` network to reach shared services
|
||||
|
||||
- **Rule:** If the app must reach services managed separately by Coolify (a shared
|
||||
Postgres/Redis, or another app), declare the global `coolify` network as external and
|
||||
attach your service to it:
|
||||
```yaml
|
||||
networks:
|
||||
coolify:
|
||||
name: coolify
|
||||
external: true
|
||||
```
|
||||
- **Why:** Without this, service-name DNS to those resources fails. This is the fix
|
||||
documented for Baserow ([`runbooks/baserow.md`](runbooks/baserow.md)), which resolves
|
||||
its external Postgres/Redis only through the `coolify` network.
|
||||
- **Verify:** same `getent hosts` check against the target service name.
|
||||
|
||||
### 2.3 Ports — do not publish 80/443; let Traefik route
|
||||
|
||||
- **Rule:** **Never** publish host ports `80` or `443` in your compose (no
|
||||
`ports: ["80:80"]` / `["443:443"]`). Your app listens on its own internal port; Traefik
|
||||
connects to it and handles the public 80/443.
|
||||
- **Why:** `80`/`443` on the host belong to `coolify-proxy`. Publishing them collides and
|
||||
the app fails to start — exactly the Baserow startup failure in
|
||||
[`runbooks/baserow.md`](runbooks/baserow.md).
|
||||
- **Also:** Coolify defaults a new app's exposed port to `3000`. If your app actually
|
||||
listens elsewhere (e.g. `80` for nginx, `8080`, etc.), the exposed port **must** match
|
||||
the real listening port or Traefik routes to a dead port (502). Set it correctly in the
|
||||
Coolify UI/API before the first deploy (`ports_exposes`).
|
||||
|
||||
### 2.4 Domains & TLS — `*.urieljareth.org`, DNS challenge, set the FQDN early
|
||||
|
||||
- **Rule:** Public hostname pattern is `https://<name>.urieljareth.org`. Set the app's
|
||||
**FQDN before the first deploy** — do not rely on Coolify's auto-generated UUID
|
||||
subdomain (it has no working DNS route and produces 502s).
|
||||
- **Why:** TLS is issued by Traefik via **DNS challenge** (Cloudflare API token), so it
|
||||
works regardless of Cloudflare's "Always Use HTTPS". Do **not** assume HTTP-01 challenge
|
||||
— that path is intentionally not used here
|
||||
(see [`issue-coolify-static-app-deploy.md`](issue-coolify-static-app-deploy.md)).
|
||||
- **Verify:**
|
||||
```powershell
|
||||
curl.exe -k -sSI https://<name>.urieljareth.org/
|
||||
```
|
||||
|
||||
### 2.5 Secrets — env vars only, never in the repo or image
|
||||
|
||||
- **Rule:** All credentials (DB passwords, API tokens, keys) come from **environment
|
||||
variables** injected by Coolify. Never hardcode them in source, Dockerfile, compose, or
|
||||
committed files, and never bake them into the image.
|
||||
- **Why:** Repo policy forbids secrets in the tree ([`../CLAUDE.md`](../CLAUDE.md),
|
||||
[`runbooks/seguridad.md`](runbooks/seguridad.md)); baked secrets also leak through image
|
||||
layers.
|
||||
- **Verify:** the app boots with secrets supplied only as env vars; the image contains no
|
||||
credential values.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build / deploy options
|
||||
|
||||
Coolify can deploy your app in several ways. Pick the simplest that fits, and design the
|
||||
repo accordingly. (API endpoints are under `/api/v1/...`; reference files in
|
||||
[`../coolify_skill/references/ops/`](../coolify_skill/references/ops/).)
|
||||
|
||||
| Type | When to use | Coolify create endpoint |
|
||||
|------|-------------|-------------------------|
|
||||
| Public Git + Nixpacks | Standard app, language auto-detected (Node, Python, Go…) | `POST /applications/public` |
|
||||
| Static / SPA | Pure HTML/CSS/JS or built front-end (React/Vue) | `POST /applications/public` with `is_static: true` (and `is_spa: true` for SPAs), `static_image` for the serving image |
|
||||
| Dockerfile | Custom build, no separate registry | `POST /applications/dockerfile` |
|
||||
| Prebuilt image | Image already in a registry (ghcr.io, Docker Hub) | `POST /applications/dockerimage` |
|
||||
| Private Git | Private repo via deploy key / GitHub App | `POST /applications/private-deploy-key` or `/private-github-app` |
|
||||
| Multi-container stack | App + its own DB/cache defined together | `POST /services` (compose). **Note:** `/applications/dockercompose` is deprecated — use `/services`. |
|
||||
|
||||
For static sites, prefer `is_static: true` with a known `static_image` (e.g. nginx) and
|
||||
remember the listening port is then `80` (§2.3).
|
||||
|
||||
---
|
||||
|
||||
## 4. App configuration reference
|
||||
|
||||
Fields your app must expose or that you'll set in Coolify. Schemas come from
|
||||
[`../coolify_skill/references/ops/`](../coolify_skill/references/ops/) (`healthcheck.md`,
|
||||
`create-*.md`, `create-env-by-application-uuid.md`).
|
||||
|
||||
### Environment variables (`POST /applications/{uuid}/envs`)
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `key` / `value` | The variable |
|
||||
| `is_literal` | Do not interpolate `$`-style references in the value |
|
||||
| `is_multiline` | Value spans multiple lines |
|
||||
| `is_preview` | Applies to preview deployments only |
|
||||
| `is_shown_once` | Hide in UI after first reveal (for secrets) |
|
||||
|
||||
Bulk update: `PATCH /applications/{uuid}/envs/bulk`.
|
||||
|
||||
### Ports
|
||||
|
||||
| Field | Meaning |
|
||||
|-------|---------|
|
||||
| `ports_exposes` | Port the app actually listens on (Traefik targets this). Must match reality — default is `3000`. |
|
||||
| `ports_mappings` | Optional `host:container` mapping. Avoid `80`/`443` (§2.3). |
|
||||
|
||||
### Healthcheck (strongly recommended)
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| `health_check_enabled` | Turn it on |
|
||||
| `health_check_path` | e.g. `/health` (expose a cheap, dependency-light endpoint) |
|
||||
| `health_check_port` | Defaults to the exposed port |
|
||||
| `health_check_method` / `health_check_return_code` | e.g. `GET` / `200` |
|
||||
| `health_check_scheme` | `http` between Traefik and the container (TLS is terminated upstream) |
|
||||
| `health_check_interval` / `_timeout` / `_retries` / `_start_period` | Tune for slow boots |
|
||||
|
||||
Design the app to serve a healthcheck endpoint that does **not** depend on the DB being
|
||||
reachable at boot, so a slow DB doesn't flap the container.
|
||||
|
||||
### Resource limits & deployment hooks (optional)
|
||||
|
||||
- Limits: `limits_memory`, `limits_memory_swap`, `limits_cpus`, `limits_cpuset`,
|
||||
`limits_cpu_shares`.
|
||||
- Hooks: `pre_deployment_command` / `post_deployment_command` (+ their `_container`),
|
||||
e.g. run DB migrations post-deploy. Keep these idempotent.
|
||||
|
||||
---
|
||||
|
||||
## 5. Volumes / persistence
|
||||
|
||||
- Persistence is modeled in **docker-compose** with **named volumes**. Anonymous volumes
|
||||
and container-filesystem writes are lost on redeploy.
|
||||
- Name every volume that must survive a redeploy, and mount it at the app's data path.
|
||||
- The compose Coolify actually runs is generated at
|
||||
`/data/coolify/applications/<UUID>/docker-compose.yaml` — inspect it after deploy to
|
||||
confirm your volumes and network landed as intended.
|
||||
|
||||
---
|
||||
|
||||
## 6. Reference docker-compose snippet
|
||||
|
||||
Minimal compose that satisfies every hard rule: app + sibling DB by service name, joined
|
||||
to the external `coolify` network, **no published 80/443**, a healthcheck, and a named
|
||||
volume. Secrets arrive as env vars (no literal values here).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: your-app:latest # or build: .
|
||||
environment:
|
||||
# Reach the DB by SERVICE NAME, never localhost (§2.1)
|
||||
DB_HOST: app-db
|
||||
DB_PORT: "5432"
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_PASSWORD: ${DB_PASSWORD} # injected by Coolify env, not hardcoded (§2.5)
|
||||
APP_PUBLIC_URL: https://yourapp.urieljareth.org
|
||||
# No `ports:` block — Traefik routes to the internal port (§2.3)
|
||||
expose:
|
||||
- "8080" # the port the app listens on -> set ports_exposes=8080
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-fsS", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
networks:
|
||||
- coolify # only if it must reach shared Coolify services (§2.2)
|
||||
depends_on:
|
||||
- app-db
|
||||
|
||||
app-db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- app-db-data:/var/lib/postgresql/data # named volume survives redeploys (§5)
|
||||
|
||||
volumes:
|
||||
app-db-data: {}
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
name: coolify
|
||||
external: true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Pre-deploy checklist
|
||||
|
||||
- [ ] App reaches its DB/cache by **Docker service name**, not `localhost` (§2.1)
|
||||
- [ ] If it needs shared Coolify services, compose declares **`coolify` network, `external: true`** (§2.2)
|
||||
- [ ] Compose does **not** publish host ports `80`/`443` (§2.3)
|
||||
- [ ] `ports_exposes` in Coolify matches the app's **real listening port** (not the `3000` default) (§2.3)
|
||||
- [ ] **FQDN set** (`https://<name>.urieljareth.org`) before the first deploy — no auto UUID subdomain (§2.4)
|
||||
- [ ] TLS assumed via **DNS challenge** — no HTTP-01 / `.well-known` dependency in the app (§2.4)
|
||||
- [ ] All secrets injected as **env vars**, none committed or baked into the image (§2.5)
|
||||
- [ ] **Healthcheck** endpoint defined and DB-independent at boot (§4)
|
||||
- [ ] Persistent data uses **named volumes** (§5)
|
||||
|
||||
---
|
||||
|
||||
## 8. Post-deploy verification
|
||||
|
||||
Run from the repo root (PowerShell). These reuse existing scripts — no new tooling.
|
||||
|
||||
```powershell
|
||||
# 1. Public endpoint responds (TLS issued, Traefik routing correct)
|
||||
curl.exe -k -sSI https://<name>.urieljareth.org/
|
||||
|
||||
# 2. Sibling/shared DNS resolves from inside the app container
|
||||
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker exec <app> getent hosts <db-service-name>"
|
||||
|
||||
# 3. Container status overview
|
||||
.\coolify_skill\scripts\Get-CoolifyDockerStatus.ps1 -All
|
||||
|
||||
# 4. Certificate / routing errors at the proxy
|
||||
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- docker logs coolify-proxy --tail 50 2>&1 | grep -Ei 'error|certificate|acme|<name>'"
|
||||
|
||||
# 5. Inspect the generated compose actually in use
|
||||
.\scripts\Invoke-ProxmoxSsh.ps1 -Command "pct exec 102 -- cat /data/coolify/applications/<UUID>/docker-compose.yaml"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Known pitfalls (symptom → cause → fix)
|
||||
|
||||
| Symptom | Root cause | Fix | Source |
|
||||
|---------|-----------|-----|--------|
|
||||
| App's domain returns **502 Bad Gateway** | `ports_exposes` doesn't match the real listening port (often `3000` vs `80`) | Set `ports_exposes` to the actual port before deploy | [issue-coolify-static-app-deploy.md](issue-coolify-static-app-deploy.md) |
|
||||
| 502 / no certificate on the app domain | Traefik couldn't get a cert via HTTP challenge | Environment uses **DNS challenge**; ensure FQDN is set and don't depend on HTTP-01 | [issue-coolify-static-app-deploy.md](issue-coolify-static-app-deploy.md) |
|
||||
| App reachable only at an ugly **UUID subdomain** | FQDN not set, Coolify auto-generated it | Set `fqdn` to `https://<name>.urieljareth.org` before first deploy | [issue-coolify-static-app-deploy.md](issue-coolify-static-app-deploy.md) |
|
||||
| App **won't start**, port conflict | Compose publishes `80`/`443` (owned by `coolify-proxy`) | Remove host port publishing; let Traefik route | [runbooks/baserow.md](runbooks/baserow.md) |
|
||||
| App **can't reach its DB** | Used `localhost`, or DB is on a different network | Use the DB **service name**; for shared services join the `coolify` network | [runbooks/nextcloud.md](runbooks/nextcloud.md), [runbooks/baserow.md](runbooks/baserow.md) |
|
||||
| Coolify **UI blank** when opening the app page | Cloudflare tunnel route order — `/app/*` captured `/application/...` | Operator fix: `/project/*` route must precede `/app/*` in the dashboard | [issue-coolify-static-app-deploy.md](issue-coolify-static-app-deploy.md) |
|
||||
| **WebSocket / terminal** drops, `tls: first record does not look like a TLS handshake` | Tunnel routes for ports `6001`/`6002` set to `https://` | Operator fix: those routes must be `http://` | [ISSUE_cloudflare-tunnel_routing_websocket-tls-handshake.md](ISSUE_cloudflare-tunnel_routing_websocket-tls-handshake.md) |
|
||||
|
||||
> The last two are *operator/infrastructure* fixes (Cloudflare dashboard), not things the
|
||||
> app developer changes — listed here so an agent recognizes the symptom and points the
|
||||
> operator to the right runbook instead of debugging the app.
|
||||
|
||||
---
|
||||
|
||||
## 10. See also
|
||||
|
||||
- [`../CLAUDE.md`](../CLAUDE.md) — repo architecture, SSH path, operating rules
|
||||
- [`proxmox-inventory.md`](proxmox-inventory.md) — verified topology
|
||||
- [`runbooks/cloudflare-tunnel.md`](runbooks/cloudflare-tunnel.md) — tunnel routes
|
||||
- [`runbooks/coolify-docker.md`](runbooks/coolify-docker.md) — Docker ops inside LXC 102
|
||||
- [`runbooks/nextcloud.md`](runbooks/nextcloud.md), [`runbooks/baserow.md`](runbooks/baserow.md) — real app patterns
|
||||
- [`../coolify_skill/references/`](../coolify_skill/references/) — Coolify API operation reference (search with `rg`, open one `ops/*.md`)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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` |
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: gitea-agent
|
||||
description: Operate the local self-hosted Gitea instance (gitea-...urieljareth.org) for this Proxmox and Coolify Manager project. Use when Codex needs to create/list/search/mirror Gitea repositories, push a local project to Gitea headlessly (token in extraHeader, no wincred), inspect branches/releases/hooks, or wire a `gitea` remote alongside a GitHub `origin`. Distinct from coolify-deploy (which ships to Coolify) — this skill only manages the git hosting layer on Gitea.
|
||||
---
|
||||
|
||||
# Gitea Agent
|
||||
|
||||
Use this project-local skill for Gitea work in this repository. The Gitea
|
||||
instance lives behind the same Cloudflare tunnel as Coolify and is reachable
|
||||
only via its public URL.
|
||||
|
||||
## Startup routine
|
||||
|
||||
1. Load private values: `. .\.env.local.ps1` (provides `GITEA_URL`, `GITEA_USER`,
|
||||
`GITEA_TOKEN`).
|
||||
2. Run the smoke test: `.\gitea_skill\scripts\Test-GiteaConnection.ps1`.
|
||||
3. Only after it passes, perform the requested operation.
|
||||
|
||||
## Local context (verified 2026-07-19)
|
||||
|
||||
- Gitea URL: `https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org`
|
||||
- Gitea version: `1.26.2`
|
||||
- Authenticated user: `urieljareth` (admin)
|
||||
- Auth header: `Authorization: token <GITEA_TOKEN>` (canonical Gitea form;
|
||||
`Bearer` also works on 1.16+).
|
||||
- The Gitea instance runs as a Coolify container inside LXC `102` on the
|
||||
Proxmox host `192.168.0.200`. Container-level work goes through
|
||||
`pct exec 102 -- docker ...` (see `coolify_skill`); this skill speaks only
|
||||
to the public REST API.
|
||||
- Secrets live only in `.env.local.ps1` (gitignored). Never write tokens into
|
||||
Markdown, scripts, or `.git/config`.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Push this project to Gitea" / "crea un repo en Gitea y sube esto"
|
||||
- "List my Gitea repos" / "¿qué repos tengo en Gitea?"
|
||||
- "Mirror this GitHub repo to Gitea" (`/repos/migrate`)
|
||||
- "Set up a `gitea` remote alongside GitHub `origin`"
|
||||
- "Create a release / tag on Gitea"
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Deploying an app to run on the server → use `deploy_skill/` (Coolify).
|
||||
- Operating an already-deployed container → use `coolify_skill/`.
|
||||
- Proxmox host / LXC maintenance → use `agent/`.
|
||||
|
||||
## Safety policy
|
||||
|
||||
- **Read-only by default**: prefer `Get-GiteaRepo.ps1` and GET endpoints before
|
||||
any write.
|
||||
- **Confirm before mutating**: repo creation, visibility changes, branch
|
||||
protection, deletes, releases, and any `git push` require explicit
|
||||
confirmation unless `-Force` is supplied.
|
||||
- **Never write secrets into the repo.** The token is injected per-invocation
|
||||
via `http.extraHeader` for pushes; it is NOT baked into remote URLs and NOT
|
||||
stored in wincred by these scripts.
|
||||
- **No force-push to main** unless the operator explicitly asks.
|
||||
- **State the rollback path** before destructive actions (delete repo, force
|
||||
push): once a Gitea repo is deleted the data is gone (no Coolify-side
|
||||
recycle bin).
|
||||
|
||||
## Tooling
|
||||
|
||||
See [`TOOLS.md`](TOOLS.md) for the full command reference. Core scripts:
|
||||
|
||||
- `scripts/Invoke-GiteaApi.ps1` — low-level REST wrapper (curl-based, BOM-safe).
|
||||
- `scripts/Test-GiteaConnection.ps1` — smoke test (version + auth + repos).
|
||||
- `scripts/Get-GiteaRepo.ps1` — list / search / get a repo.
|
||||
- `scripts/New-GiteaRepo.ps1` — idempotent repo creation.
|
||||
- `scripts/Sync-GiteaRemote.ps1` — wire a `gitea` remote + push headlessly.
|
||||
|
||||
## API reference
|
||||
|
||||
See [`references/api-index.md`](references/api-index.md) for the verified
|
||||
endpoint cheatsheet. Full Swagger at `$GITEA_URL/api/swagger`.
|
||||
|
||||
## Common workflows
|
||||
|
||||
### Push an existing local repo to Gitea (one shot)
|
||||
|
||||
```powershell
|
||||
. .\.env.local.ps1
|
||||
.\gitea_skill\scripts\Sync-GiteaRemote.ps1 `
|
||||
-AppPath .\my-app `
|
||||
-Name my-app `
|
||||
-CreateIfMissing -Force
|
||||
```
|
||||
|
||||
This creates `urieljareth/my-app` if missing, adds a `gitea` remote (so it
|
||||
coexists with a GitHub `origin`), and pushes the current branch with a
|
||||
one-shot auth header (token not persisted).
|
||||
|
||||
### Mirror a GitHub repo into Gitea
|
||||
|
||||
```powershell
|
||||
. .\.env.local.ps1
|
||||
$body = @{
|
||||
clone_addr = "https://github.com/urieljarethbusiness-cpu/my-app.git"
|
||||
repo_owner = "urieljareth"
|
||||
repo_name = "my-app"
|
||||
service = "github"
|
||||
mirror = $true
|
||||
private = $false
|
||||
wiki = $false
|
||||
issues = $false
|
||||
pull_requests = $false
|
||||
releases = $true
|
||||
} | ConvertTo-Json -Compress
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Method POST -Path "/repos/migrate" -BodyJson $body
|
||||
```
|
||||
|
||||
### Add a Gitea webhook so Coolify auto-pulls on push
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
type = "gitea"
|
||||
active = $true
|
||||
events = @("push")
|
||||
config = @{ url = "https://coolify.urieljareth.org/webhooks/source.manual_webhook_secret_gitea"; content_type = "json" }
|
||||
} | ConvertTo-Json -Depth 5 -Compress
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Method POST `
|
||||
-Path "/repos/urieljareth/my-app/hooks" -BodyJson $body
|
||||
```
|
||||
(Replace the `manual_webhook_secret_*` segment with the real webhook URL from
|
||||
the Coolify app's "Webhooks" section.)
|
||||
@@ -0,0 +1,125 @@
|
||||
# Gitea — Tooling
|
||||
|
||||
All commands assume PowerShell from the project root. Load env first:
|
||||
|
||||
```powershell
|
||||
. .\.env.local.ps1
|
||||
```
|
||||
|
||||
Required env vars (in `.env.local.ps1`, gitignored):
|
||||
|
||||
- `GITEA_URL` — base URL, e.g. `https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org`.
|
||||
- `GITEA_USER` — auth user (informational; `GITEA_TOKEN` encodes the identity).
|
||||
- `GITEA_TOKEN` — app token with `read:repository`, `write:repository`,
|
||||
`read:user` scopes (generate at `$GITEA_URL/user/settings/applications`).
|
||||
|
||||
## Smoke test
|
||||
|
||||
```powershell
|
||||
.\gitea_skill\scripts\Test-GiteaConnection.ps1
|
||||
```
|
||||
|
||||
Calls `/version`, `/user`, `/settings/api`, `/repos/search`. Exits 1 on any
|
||||
FAIL — gate operational work on this.
|
||||
|
||||
## Read-only
|
||||
|
||||
```powershell
|
||||
# Who am I?
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/user"
|
||||
|
||||
# List visible repos
|
||||
.\gitea_skill\scripts\Get-GiteaRepo.ps1 -List
|
||||
|
||||
# Get one repo (owner defaults to auth user)
|
||||
.\gitea_skill\scripts\Get-GiteaRepo.ps1 -Name Proxmox-Coolify-Manager
|
||||
.\gitea_skill\scripts\Get-GiteaRepo.ps1 -Owner urieljareth -Name MP-Manager
|
||||
|
||||
# Search
|
||||
.\gitea_skill\scripts\Get-GiteaRepo.ps1 -Search manager
|
||||
|
||||
# Branches + tags
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/repos/urieljareth/MP-Manager/branches"
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/repos/urieljareth/MP-Manager/tags"
|
||||
```
|
||||
|
||||
## Create a repo (idempotent)
|
||||
|
||||
```powershell
|
||||
.\gitea_skill\scripts\New-GiteaRepo.ps1 -Name my-app -Description "demo" [-Private] [-NoAutoInit]
|
||||
```
|
||||
|
||||
If the repo already exists, prints its URL and returns the existing object
|
||||
(no failure). `-NoAutoInit` skips the Gitea-side README so you can push an
|
||||
existing local history without conflicts.
|
||||
|
||||
## Push a local repo to Gitea (headless, token NOT persisted)
|
||||
|
||||
```powershell
|
||||
.\gitea_skill\scripts\Sync-GiteaRemote.ps1 `
|
||||
-AppPath .\my-app `
|
||||
-Name my-app `
|
||||
-RemoteName gitea `
|
||||
-CreateIfMissing [-Private] [-Force]
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
1. Ensures `urieljareth/<Name>` exists (creates if `-CreateIfMissing`).
|
||||
2. Adds (or updates) the `gitea` remote with the **clean** HTTPS URL — **no
|
||||
token in `.git/config`**.
|
||||
3. Pushes the current branch with a **one-shot** `http.extraHeader=Authorization:
|
||||
token <T>` and a blanked `credential.helper`. The token never reaches disk.
|
||||
|
||||
`-RemoteName gitea` (default) coexists with a GitHub `origin`. To replace an
|
||||
existing `origin` that already points at Gitea (as in this manager repo),
|
||||
pass `-RemoteName origin`.
|
||||
|
||||
### If you prefer wincred for interactive pushes (one-time)
|
||||
|
||||
```powershell
|
||||
cmdkey /generic:"git:$env:GITEA_URL" /user:"$env:GITEA_USER" /pass:"$env:GITEA_TOKEN"
|
||||
```
|
||||
|
||||
After that, plain `git push` works. The skill scripts do NOT require this —
|
||||
they work headlessly out of the box.
|
||||
|
||||
## Low-level API
|
||||
|
||||
```powershell
|
||||
# GET
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/repos/urieljareth/MP-Manager/releases"
|
||||
|
||||
# POST with body
|
||||
$body = @{ name = "v1.0.0"; tag_name = "v1.0.0" } | ConvertTo-Json -Compress
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Method POST `
|
||||
-Path "/repos/urieljareth/MP-Manager/releases" -BodyJson $body
|
||||
|
||||
# Raw (status + headers + body string)
|
||||
.\gitea_skill\scripts\Invoke-GiteaApi.ps1 -Path "/user" -Raw
|
||||
```
|
||||
|
||||
`Invoke-GiteaApi.ps1` uses `curl.exe --data-binary @<tmpfile>` (UTF-8 no BOM)
|
||||
so Gitea's JSON parser never trips on a BOM — same pattern as
|
||||
`Invoke-GitHubApi.ps1`.
|
||||
|
||||
## Commands that always require confirmation
|
||||
|
||||
- Creating a repo (`New-GiteaRepo.ps1` warns if it already exists).
|
||||
- Any `git push` (`Sync-GiteaRemote.ps1` asks unless `-Force`).
|
||||
- Any Gitea `POST`/`PATCH`/`DELETE` (repo, release, hook, branch protection).
|
||||
- Mirror/migrate operations.
|
||||
- Deleting a repo (no Coolify-side recycle bin — data is gone).
|
||||
|
||||
`-Force` skips the interactive prompts. Use only after manually reviewing the plan.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Likely cause | Fix |
|
||||
|---------|--------------|-----|
|
||||
| `GITEA_TOKEN not set` | `.env.local.ps1` not loaded | `. .\.env.local.ps1` |
|
||||
| `401`/`403` on writes | Token lacks `write:repository` | Regenerate token with the right scope at `$GITEA_URL/user/settings/applications` |
|
||||
| `invalid character '\u00ef'` | Body sent with a BOM | Already handled by `Invoke-GiteaApi.ps1` (uses `[IO.File]::WriteAllText` no-BOM); do not switch to `Set-Content -Encoding utf8` |
|
||||
| `git push` prompts for credentials | The one-shot header was overridden by a stored helper | The script blanks `credential.helper` for the push; if you configured wincred manually, that's fine too. If a different helper intercepts, run from a clean shell. |
|
||||
| `404` creating repo under an org | Org does not exist or token lacks access | Create the org first at `$GITEA_URL/org/create` |
|
||||
| Cloudflare 5xx on Gitea URL | Tunnel routing or the Gitea container is down | `coolify_skill/scripts/Get-CoolifyDockerStatus.ps1 -Filter gitea`; tunnel is dashboard-managed, see `docs/runbooks/cloudflare-tunnel.md` |
|
||||
@@ -0,0 +1,66 @@
|
||||
# Gitea API quick reference (v1.26.x)
|
||||
|
||||
Base URL: `$GITEA_URL/api/v1` (default `https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org/api/v1`).
|
||||
Auth header: `Authorization: token <GITEA_TOKEN>` (also accepts `Bearer` on 1.16+).
|
||||
Docs (Swagger): `$GITEA_URL/api/swagger` (browsable in a browser).
|
||||
|
||||
## Verified 2026-07-19 on this instance
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/version` | GET | Server version (anonymous). Returns `{"version":"1.26.2"}`. |
|
||||
| `/user` | GET | Authenticated user. Proves token. |
|
||||
| `/settings/api` | GET | Pagination/limits config. |
|
||||
| `/repos/search?limit=50` | GET | All repos visible to the token. |
|
||||
| `/repos/{owner}/{name}` | GET | Single repo (404 if missing or no access). |
|
||||
| `/user/repos` | POST | Create repo under auth user. |
|
||||
| `/orgs/{org}/repos` | POST | Create repo under an org. |
|
||||
| `/repos/{owner}/{name}` | PATCH | Update description, visibility, default branch. |
|
||||
| `/repos/{owner}/{name}` | DELETE | Delete repo. |
|
||||
| `/repos/{owner}/{name}/branches` | GET | List branches. |
|
||||
| `/repos/{owner}/{name}/branch protections` | POST | Branch protection rules. |
|
||||
| `/repos/{owner}/{name}/releases` | GET/POST | List / create releases. |
|
||||
| `/repos/{owner}/{name}/tags` | GET | List tags. |
|
||||
| `/repos/{owner}/{name}/hooks` | GET/POST | Webhooks (e.g. Coolify pull). |
|
||||
| `/repos/{owner}/{name}/mirror-sync` | POST | Sync a mirror repo. |
|
||||
| `/repos/migrate` | POST | Migrate/mirror from GitHub/GitLab/etc. |
|
||||
| `/users/{username}/tokens` | POST | Create a new app token (needs username+password or admin). |
|
||||
| `/orgs` | GET | List orgs. |
|
||||
| `/admin/users` | GET | List users (admin only). |
|
||||
|
||||
## Token scopes (Gitea 1.20+ fine-grained)
|
||||
|
||||
Tokens carry scopes. The current skill token (`GITEA_TOKEN` in `.env.local.ps1`)
|
||||
needs at minimum: `read:repository`, `write:repository`, `read:user`. Create at
|
||||
`$GITEA_URL/user/settings/applications` (or via API `/users/{user}/tokens` with
|
||||
basic auth). Verify scopes with:
|
||||
|
||||
```powershell
|
||||
.\gitea_skill\scripts\Test-GiteaConnection.ps1
|
||||
```
|
||||
|
||||
## Auth header convention
|
||||
|
||||
Gitea's canonical header is `Authorization: token <T>` (note: literal `token`,
|
||||
not `Bearer`). This differs from GitHub/Coolify. `Invoke-GiteaApi.ps1` uses the
|
||||
canonical form. `Bearer` also works on 1.16+ but stick to `token` for max
|
||||
compat.
|
||||
|
||||
## Git push with token (headless, no wincred)
|
||||
|
||||
The token is NOT persisted into `.git/config` by `Sync-GiteaRemote.ps1`. It is
|
||||
sent via a one-shot `http.extraHeader`:
|
||||
|
||||
```powershell
|
||||
git -c "http.extraHeader=Authorization: token $env:GITEA_TOKEN" `
|
||||
-c "credential.helper=" `
|
||||
push -u gitea main
|
||||
```
|
||||
|
||||
If you prefer wincred (so plain `git push` works afterwards), seed it once:
|
||||
|
||||
```powershell
|
||||
cmdkey /generic:"git:$env:GITEA_URL" /user:"$env:GITEA_USER" /pass:"$env:GITEA_TOKEN"
|
||||
```
|
||||
|
||||
But note: the skill scripts do not require this. They work headlessly.
|
||||
@@ -0,0 +1,81 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get or search a Gitea repository.
|
||||
|
||||
.DESCRIPTION
|
||||
Three modes:
|
||||
-Name <repo> -> GET /repos/{owner}/{name} (default owner = auth user)
|
||||
-Search <term> -> GET /repos/search?q=<term>
|
||||
-List -> GET /repos/search (all visible to the token)
|
||||
|
||||
.PARAMETER Owner
|
||||
Owner (user or org). Defaults to the authenticated user.
|
||||
|
||||
.PARAMETER Name
|
||||
Exact repository name to look up.
|
||||
|
||||
.PARAMETER Search
|
||||
Free-text search term.
|
||||
|
||||
.PARAMETER List
|
||||
Switch: list all visible repos.
|
||||
|
||||
.EXAMPLE
|
||||
.\Get-GiteaRepo.ps1 -Name Proxmox-Coolify-Manager
|
||||
.\Get-GiteaRepo.ps1 -Search manager
|
||||
.\Get-GiteaRepo.ps1 -List
|
||||
#>
|
||||
param(
|
||||
[string]$Owner,
|
||||
[Parameter(ParameterSetName = "ByName")]
|
||||
[string]$Name,
|
||||
[Parameter(ParameterSetName = "BySearch")]
|
||||
[string]$Search,
|
||||
[Parameter(ParameterSetName = "ListAll")]
|
||||
[switch]$List
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$api = Join-Path $here "Invoke-GiteaApi.ps1"
|
||||
|
||||
function Resolve-GiteaOwner {
|
||||
param([string]$OwnerArg)
|
||||
if ($OwnerArg) { return $OwnerArg }
|
||||
$me = & $api -Path "/user"
|
||||
return $me.login
|
||||
}
|
||||
|
||||
switch ($PSCmdlet.ParameterSetName) {
|
||||
"ByName" {
|
||||
if (-not $Name) { throw "Provide -Name." }
|
||||
$o = Resolve-GiteaOwner -OwnerArg $Owner
|
||||
try {
|
||||
$r = & $api -Path "/repos/$o/$Name"
|
||||
[PSCustomObject]@{
|
||||
full_name = $r.full_name
|
||||
private = $r.private
|
||||
default_branch = $r.default_branch
|
||||
clone_url = $r.clone_url
|
||||
html_url = $r.html_url
|
||||
ssh_url = $r.ssh_url
|
||||
updated_at = $r.updated_at
|
||||
} | Format-List
|
||||
} catch {
|
||||
if ($_.Exception.Message -match "404") {
|
||||
Write-Host "Repository not found: $o/$Name" -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
throw
|
||||
}
|
||||
}
|
||||
"BySearch" {
|
||||
$term = [uri]::EscapeDataString($Search)
|
||||
$r = & $api -Path "/repos/search?q=$term&limit=50"
|
||||
$r.data | Select-Object full_name, private, default_branch, updated_at | Format-Table -AutoSize
|
||||
}
|
||||
"ListAll" {
|
||||
$r = & $api -Path "/repos/search?limit=50"
|
||||
$r.data | Select-Object full_name, private, default_branch, updated_at | Format-Table -AutoSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Low-level Gitea REST API wrapper (curl-based, BOM-safe).
|
||||
|
||||
.DESCRIPTION
|
||||
Mirrors Invoke-GitHubApi.ps1's design: uses curl.exe so JSON bodies are
|
||||
written via --data-binary @file (no PowerShell Set-Content BOM, which
|
||||
Gitea rejects with "invalid character '\u00ef' looking for beginning of
|
||||
value"). Reads GITEA_URL + GITEA_TOKEN from the environment.
|
||||
|
||||
.PARAMETER Method
|
||||
HTTP verb. Default GET.
|
||||
|
||||
.PARAMETER Path
|
||||
Path under /api/v1 (with or without leading slash). Query params allowed.
|
||||
|
||||
.PARAMETER BodyJson
|
||||
JSON body for write verbs. Validated as JSON before sending.
|
||||
|
||||
.PARAMETER Raw
|
||||
Return a pscustomobject with Status, Headers, Body instead of parsed JSON.
|
||||
|
||||
.EXAMPLE
|
||||
.\Invoke-GiteaApi.ps1 -Path "/version"
|
||||
.\Invoke-GiteaApi.ps1 -Path "/user"
|
||||
.\Invoke-GiteaApi.ps1 -Method POST -Path "/user/repos" -BodyJson $json
|
||||
#>
|
||||
param(
|
||||
[ValidateSet("GET", "POST", "PATCH", "PUT", "DELETE")]
|
||||
[string]$Method = "GET",
|
||||
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path,
|
||||
|
||||
[string]$BodyJson,
|
||||
|
||||
[switch]$Raw
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
|
||||
throw "Set GITEA_TOKEN before calling the Gitea API. Store it in .env.local.ps1 (gitignored)."
|
||||
}
|
||||
if (-not (Get-Command curl.exe -ErrorAction SilentlyContinue)) {
|
||||
throw "curl.exe not found in PATH."
|
||||
}
|
||||
|
||||
$base = if ($env:GITEA_URL) { $env:GITEA_URL.TrimEnd("/") } else { "https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org" }
|
||||
$base = "$base/api/v1"
|
||||
$cleanPath = $Path.TrimStart("/")
|
||||
$uri = "$base/$cleanPath"
|
||||
|
||||
# Gitea accepts "token <T>" (canonical) and "Bearer <T>" (since 1.16+).
|
||||
$curlArgs = @(
|
||||
"-sS", "-i",
|
||||
"-X", $Method.ToUpper(),
|
||||
"-H", "Authorization: token $env:GITEA_TOKEN",
|
||||
"-H", "Accept: application/json",
|
||||
"-H", "User-Agent: gitea-skill"
|
||||
)
|
||||
$tmpBody = $null
|
||||
if ($PSBoundParameters.ContainsKey("BodyJson")) {
|
||||
try { $null = $BodyJson | ConvertFrom-Json } catch { throw "BodyJson is not valid JSON: $($_.Exception.Message)" }
|
||||
$tmpBody = [System.IO.Path]::GetTempFileName()
|
||||
[System.IO.File]::WriteAllText($tmpBody, $BodyJson, (New-Object System.Text.UTF8Encoding($false)))
|
||||
$curlArgs += @("--data-binary", "@$tmpBody", "-H", "Content-Type: application/json")
|
||||
}
|
||||
|
||||
try {
|
||||
$output = & curl.exe @curlArgs $uri
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ne 0) { throw "Gitea API request failed with exit code $exitCode." }
|
||||
|
||||
$rawStr = ($output | Out-String)
|
||||
$parts = $rawStr -split "(?:`r`n`r`n|`n`n)", 2
|
||||
$headersBlock = if ($parts.Count -ge 1) { $parts[0] } else { "" }
|
||||
$bodyBlock = if ($parts.Count -ge 2) { $parts[1] } else { "{}" }
|
||||
|
||||
$statusLine = ($headersBlock -split "`n" | Select-Object -First 1).Trim()
|
||||
if ($statusLine -notmatch " 20[04-9] ") {
|
||||
$snippet = $bodyBlock.Trim()
|
||||
if ($snippet.Length -gt 500) { $snippet = $snippet.Substring(0, 500) + "..." }
|
||||
throw "Gitea API HTTP error: $statusLine`nURI: $uri`nBody: $snippet"
|
||||
}
|
||||
|
||||
if ($Raw) {
|
||||
[pscustomobject]@{ Status = $statusLine; Headers = $headersBlock; Body = $bodyBlock }
|
||||
} else {
|
||||
try { $bodyBlock | ConvertFrom-Json }
|
||||
catch { $bodyBlock }
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($tmpBody -and (Test-Path -LiteralPath $tmpBody)) {
|
||||
Remove-Item -LiteralPath $tmpBody -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create a Gitea repository (idempotent).
|
||||
|
||||
.DESCRIPTION
|
||||
Mirrors deploy_skill/scripts/New-GitHubRepo.ps1. If the repo already exists,
|
||||
prints its URL and returns the existing object instead of failing.
|
||||
|
||||
.PARAMETER Name
|
||||
Repository name (required).
|
||||
|
||||
.PARAMETER Description
|
||||
Short description.
|
||||
|
||||
.PARAMETER Private
|
||||
Switch. If set, creates a private repo. Default public.
|
||||
|
||||
.PARAMETER Owner
|
||||
Optional. User (default) or org to create under.
|
||||
|
||||
.PARAMETER NoAutoInit
|
||||
Skip the Gitea-side README init (use when you will push an existing local
|
||||
repo with its own history).
|
||||
|
||||
.EXAMPLE
|
||||
.\New-GiteaRepo.ps1 -Name my-app -Description "demo" -Private
|
||||
.\New-GiteaRepo.ps1 -Name my-app -NoAutoInit
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[ValidatePattern('^[A-Za-z0-9._-]+$')]
|
||||
[string]$Name,
|
||||
|
||||
[string]$Description = "",
|
||||
|
||||
[switch]$Private,
|
||||
|
||||
[string]$Owner,
|
||||
|
||||
[switch]$NoAutoInit
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$api = Join-Path $here "Invoke-GiteaApi.ps1"
|
||||
|
||||
$me = & $api -Path "/user"
|
||||
$effectiveOwner = if ($Owner) { $Owner } else { $me.login }
|
||||
Write-Host "Authenticated Gitea user: $($me.login)" -ForegroundColor Cyan
|
||||
|
||||
$existing = $null
|
||||
try {
|
||||
$existing = & $api -Path "/repos/$effectiveOwner/$Name"
|
||||
} catch {
|
||||
if ($_.Exception.Message -notmatch "404") { throw }
|
||||
$existing = $null
|
||||
}
|
||||
if ($existing) {
|
||||
Write-Host "Repository already exists: $($existing.html_url)" -ForegroundColor Yellow
|
||||
[PSCustomObject]@{
|
||||
full_name = $existing.full_name
|
||||
html_url = $existing.html_url
|
||||
clone_url = $existing.clone_url
|
||||
ssh_url = $existing.ssh_url
|
||||
private = $existing.private
|
||||
default_branch = $existing.default_branch
|
||||
} | Format-List
|
||||
return $existing
|
||||
}
|
||||
|
||||
$body = [ordered]@{
|
||||
name = $Name
|
||||
description = $Description
|
||||
private = [bool]$Private
|
||||
auto_init = (-not $NoAutoInit)
|
||||
has_issues = $true
|
||||
has_wiki = $false
|
||||
default_branch = "main"
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
$endpoint = if ($Owner -and $Owner -ne $me.login) {
|
||||
"/orgs/$Owner/repos"
|
||||
} else {
|
||||
"/user/repos"
|
||||
}
|
||||
|
||||
Write-Host "Creating repository at: $endpoint" -ForegroundColor Cyan
|
||||
$created = & $api -Method POST -Path $endpoint -BodyJson $body
|
||||
|
||||
[PSCustomObject]@{
|
||||
full_name = $created.full_name
|
||||
html_url = $created.html_url
|
||||
clone_url = $created.clone_url
|
||||
ssh_url = $created.ssh_url
|
||||
private = $created.private
|
||||
default_branch = $created.default_branch
|
||||
} | Format-List
|
||||
|
||||
$created
|
||||
@@ -0,0 +1,140 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Wire a local git repo to Gitea and push the current branch headlessly.
|
||||
|
||||
.DESCRIPTION
|
||||
1. Ensures the target Gitea repo exists (creates it if -CreateIfMissing).
|
||||
2. Adds (or updates) a git remote pointing at Gitea. Default remote name
|
||||
is 'gitea' so it sits alongside a GitHub 'origin' without conflict.
|
||||
3. Pushes the current branch with a one-shot Authorization header so the
|
||||
Gitea token is NOT persisted into .git/config or wincred.
|
||||
|
||||
The token is injected via `git -c http.extraHeader=...` for the single
|
||||
push invocation only. credential.helper is blanked for that push so the
|
||||
helper does not prompt and does not cache the header.
|
||||
|
||||
.PARAMETER AppPath
|
||||
Path to the local git working copy. Defaults to current directory.
|
||||
|
||||
.PARAMETER Name
|
||||
Repository name on Gitea. Defaults to the basename of AppPath.
|
||||
|
||||
.PARAMETER Owner
|
||||
Owner (user or org). Defaults to the authenticated Gitea user.
|
||||
|
||||
.PARAMETER RemoteName
|
||||
Remote name to add/update. Default 'gitea'.
|
||||
|
||||
.PARAMETER Branch
|
||||
Branch to push. Defaults to the current HEAD of AppPath.
|
||||
|
||||
.PARAMETER CreateIfMissing
|
||||
Create the Gitea repo if it does not exist. If omitted and the repo is
|
||||
missing, the script aborts with a clear message.
|
||||
|
||||
.PARAMETER Private
|
||||
Only used when -CreateIfMissing creates a new repo.
|
||||
|
||||
.PARAMETER Force
|
||||
Skip the interactive confirmation before pushing.
|
||||
|
||||
.EXAMPLE
|
||||
.\Sync-GiteaRemote.ps1 -AppPath .\my-app -CreateIfMissing
|
||||
.\Sync-GiteaRemote.ps1 -Name Proxmox-Coolify-Manager -RemoteName origin -Force
|
||||
#>
|
||||
param(
|
||||
[string]$AppPath = (Get-Location).Path,
|
||||
[string]$Name,
|
||||
[string]$Owner,
|
||||
[string]$RemoteName = "gitea",
|
||||
[string]$Branch,
|
||||
[switch]$CreateIfMissing,
|
||||
[switch]$Private,
|
||||
[switch]$Force
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$api = Join-Path $here "Invoke-GiteaApi.ps1"
|
||||
$newRepo = Join-Path $here "New-GiteaRepo.ps1"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
|
||||
throw "Set GITEA_TOKEN (and GITEA_URL) before running this. Load .\.env.local.ps1."
|
||||
}
|
||||
|
||||
$repoPath = (Resolve-Path -LiteralPath $AppPath).ProviderPath
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $repoPath ".git"))) {
|
||||
throw "Not a git repository: $repoPath"
|
||||
}
|
||||
|
||||
$me = & $api -Path "/user"
|
||||
$effectiveOwner = if ($Owner) { $Owner } else { $me.login }
|
||||
$effectiveName = if ($Name) { $Name } else { (Split-Path -Leaf $repoPath) }
|
||||
|
||||
if (-not $Branch) {
|
||||
$Branch = (& git -C $repoPath rev-parse --abbrev-ref HEAD).Trim()
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($Branch) -or $Branch -eq "HEAD") {
|
||||
throw "Could not determine current branch in $repoPath. Pass -Branch explicitly."
|
||||
}
|
||||
|
||||
Write-Host "[*] repo=$effectiveOwner/$effectiveName branch=$Branch remote=$RemoteName" -ForegroundColor Cyan
|
||||
|
||||
$repo = $null
|
||||
try {
|
||||
$repo = & $api -Path "/repos/$effectiveOwner/$effectiveName"
|
||||
} catch {
|
||||
if ($_.Exception.Message -notmatch "404") { throw }
|
||||
$repo = $null
|
||||
}
|
||||
if (-not $repo) {
|
||||
if (-not $CreateIfMissing) {
|
||||
throw "Gitea repo $effectiveOwner/$effectiveName does not exist. Re-run with -CreateIfMissing, or create it first with New-GiteaRepo.ps1."
|
||||
}
|
||||
Write-Host "[*] Creating missing repo $effectiveOwner/$effectiveName ..." -ForegroundColor Cyan
|
||||
& $newRepo -Name $effectiveName -Owner $effectiveOwner -Private:$Private -NoAutoInit | Out-Null
|
||||
$repo = & $api -Path "/repos/$effectiveOwner/$effectiveName"
|
||||
}
|
||||
|
||||
$giteaHost = if ($env:GITEA_URL) { $env:GITEA_URL.TrimEnd("/") } else { "https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org" }
|
||||
$remoteUrl = "$giteaHost/$effectiveOwner/$effectiveName.git"
|
||||
|
||||
$existingRemote = $null
|
||||
try {
|
||||
$existingRemote = & git -C $repoPath remote get-url $RemoteName 2>$null
|
||||
} catch { $existingRemote = $null }
|
||||
|
||||
if ($existingRemote) {
|
||||
if ($existingRemote.Trim() -ne $remoteUrl) {
|
||||
Write-Host "[*] Updating remote '$RemoteName' URL -> $remoteUrl" -ForegroundColor Yellow
|
||||
& git -C $repoPath remote set-url $RemoteName $remoteUrl
|
||||
} else {
|
||||
Write-Host "[*] Remote '$RemoteName' already -> $remoteUrl" -ForegroundColor DarkGray
|
||||
}
|
||||
} else {
|
||||
Write-Host "[*] Adding remote '$RemoteName' -> $remoteUrl" -ForegroundColor Cyan
|
||||
& git -C $repoPath remote add $RemoteName $remoteUrl
|
||||
}
|
||||
|
||||
if (-not $Force) {
|
||||
$answer = Read-Host "Push '$Branch' to $RemoteName ($effectiveOwner/$effectiveName)? [y/N]"
|
||||
if ($answer -notmatch '^[yY]') {
|
||||
Write-Host "Aborted (no push)." -ForegroundColor Yellow
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "[*] Pushing $Branch -> $RemoteName ..." -ForegroundColor Cyan
|
||||
$env:GIT_TERMINAL_PROMPT = "0"
|
||||
& git -C $repoPath `
|
||||
-c "http.extraHeader=Authorization: token $env:GITEA_TOKEN" `
|
||||
-c "credential.helper=" `
|
||||
push -u $RemoteName $Branch
|
||||
$pushExit = $LASTEXITCODE
|
||||
if ($pushExit -ne 0) {
|
||||
throw "git push failed (exit $pushExit). Check the token scopes (needs 'write:repository')."
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "[OK] Pushed to $($repo.html_url)" -ForegroundColor Green
|
||||
Write-Host " remote '$RemoteName' -> $remoteUrl"
|
||||
@@ -0,0 +1,68 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Gitea smoke test: config + API auth + token scopes.
|
||||
|
||||
.DESCRIPTION
|
||||
Calls /version, /user (auth), /settings/api, and lists the authenticated
|
||||
user's repos. Exits 1 on any FAIL so it composes into CI / agent startup.
|
||||
|
||||
.EXAMPLE
|
||||
.\Test-GiteaConnection.ps1
|
||||
#>
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$api = Join-Path $here "Invoke-GiteaApi.ps1"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
|
||||
Write-Host "[FAIL] GITEA_TOKEN not set. Load .\.env.local.ps1 first." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($env:GITEA_URL)) {
|
||||
Write-Host "[WARN] GITEA_URL not set; using default https://gitea-hjwh0svsoo9p5w5kj2j6b1bd.urieljareth.org" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
$failures = 0
|
||||
|
||||
# 1) Version (anonymous, but proves reachability)
|
||||
try {
|
||||
$v = & $api -Path "/version"
|
||||
Write-Host "[PASS] /version -> Gitea $($v.version)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[FAIL] /version: $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failures++
|
||||
}
|
||||
|
||||
# 2) Authenticated user (proves token validity)
|
||||
try {
|
||||
$u = & $api -Path "/user"
|
||||
Write-Host "[PASS] /user -> login='$($u.login)' is_admin=$($u.is_admin)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[FAIL] /user (token invalid or expired): $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failures++
|
||||
}
|
||||
|
||||
# 3) Token scopes (Gitea 1.20+: /users/me/tokens only lists tokens created via API; for app tokens, /user suffices)
|
||||
try {
|
||||
$settings = & $api -Path "/settings/api"
|
||||
Write-Host "[PASS] /settings/api -> max_response_items=$($settings.max_response_items)" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[WARN] /settings/api unavailable on this version: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# 4) List repos (proves read scope on /repos/search)
|
||||
try {
|
||||
$repos = & $api -Path "/repos/search?limit=50"
|
||||
$count = @($repos.data).Count
|
||||
Write-Host "[PASS] /repos/search -> $count repos visible" -ForegroundColor Green
|
||||
} catch {
|
||||
Write-Host "[FAIL] /repos/search: $($_.Exception.Message)" -ForegroundColor Red
|
||||
$failures++
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
if ($failures -gt 0) {
|
||||
Write-Host "[RESULT] $failures FAIL(s). Gitea skill NOT ready." -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[RESULT] OK. Gitea skill ready." -ForegroundColor Green
|
||||
Generated
+59
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
@@ -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 ==="
|
||||
Reference in New Issue
Block a user