Files
Proxmox-Coolify-Manager/deploy_skill/references/coolify-4.1.2-notes.md
T

672 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
> **CORRECCIÓN (2026-08-29, §11):** ese 404 lo imponía **Cloudflare en el
> hostname público, no Coolify**. Contra el origen
> (`http://192.168.0.117:8000/api/v1`) el namespace completo responde 200 y el
> `openapi.yaml` del contenedor declara toda la superficie. Esta sección y la
> tabla se conservan como registro histórico del diagnóstico de 2026-08-07.
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 `scripts/apps/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** — `& .\scripts\apps\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, `scripts/apps/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.)
## 8. Creating git-based apps via direct DB INSERT (when UI credentials are unavailable)
> Verified 2026-07-27 deploying `AgendaMax` (Node 22 + Vite + Express + SQLite,
> Dockerfile build pack, GitHub source). This is the **third** path, alongside
> §3 (Playwright UI) and §6 (API, 404 here). Use it when you have SSH+DB access
> but NOT the Coolify web UI email/password.
### 8.1 The SSH → pct → docker exec → psql chain
```
local machine
→ ssh [email protected] -i keys\proxmox_ed25519
→ pct exec 102 -- docker exec coolify-db psql -U coolify -d coolify -c "SQL_HERE"
```
**Credentials (all local/private, no secrets leave the host):**
- Proxmox host: `[email protected]`
- SSH key: `keys\proxmox_ed25519`
- Coolify LXC: `102`
- Coolify DB container: `coolify-db` (PostgreSQL 15, user `coolify`, db `coolify`, no password — container-internal)
- Coolify app container: `coolify` (image `ghcr.io/coollabsio/coolify:4.1.2`)
- Coolify API token: in `.env.local.ps1` → `$env:COOLIFY_TOKEN`
- GitHub PAT: in `.env.local.ps1` → `$env:GITHUB_TOKEN` (scope `repo`, account `urieljarethbusiness-cpu`)
### 8.2 PowerShell quoting nightmare — the scp+sh workaround
**Problem:** Passing SQL (with single quotes, backslashes in PHP namespaces like
`App\Models\GithubApp`, or `{{pr_id}}` braces) through the chain
PowerShell → ssh → pct → docker → psql is quoting hell. Nested `'…'` inside
`"…"` inside `"…"` breaks at every level.
**Solution:** Write a `.sh` script locally → `scp` to the Proxmox host → `ssh … sh /tmp/script.sh`.
```powershell
# 1. Write the script locally with heredoc-safe content
# 2. scp it to the host
scp -i $SSH_KEY script.sh root@192.168.0.200:/tmp/script.sh
# 3. Execute remotely
ssh -i $SSH_KEY root@192.168.0.200 "sh /tmp/script.sh"
```
**Never** try to pass complex SQL inline through `ssh … "pct exec … psql … -c '…'"` from
PowerShell — the nested quoting will eat hours. Always scp a script.
### 8.3 Step-by-step: create a Dockerfile-based app from a GitHub repo
**Prerequisites:**
- The repo must be on GitHub (`urieljarethbusiness-cpu/<name>`) — Coolify's GitHub App
(source_id=3, installation_id=121211999) is already configured and can access all repos
under that account.
- Push the code first: `git push github main`.
- Get the GitHub repo numeric ID: `GET https://api.github.com/repos/<owner>/<repo>` → `.id`.
**Step 1 — Find the environment_id:**
```sql
SELECT id FROM environments WHERE uuid = '<env_uuid>';
-- e.g. for project "tools" production: returns 9
```
**Step 2 — INSERT into `applications`:** every field with a NOT NULL DEFAULT in the schema
must be set explicitly (the INSERT doesn't fire Laravel's model events, so defaults from
migrations are the DB column defaults, not Eloquent `$casts`/boot logic).
Key fields for a Dockerfile app:
```sql
INSERT INTO applications (
uuid, name, git_repository, git_branch, git_commit_sha,
build_pack, -- 'dockerfile'
dockerfile_location, -- '/Dockerfile'
ports_exposes, -- '3000' (your app's port)
health_check_path, health_check_port, health_check_host,
health_check_method, health_check_return_code, health_check_scheme,
health_check_interval, health_check_timeout, health_check_retries, health_check_start_period,
health_check_enabled, -- false (simpler; enable later via UI if needed)
limits_memory, limits_memory_swap, limits_memory_swappiness, limits_memory_reservation,
limits_cpus, limits_cpu_shares,
status, -- 'exited'
preview_url_template, -- '{{pr_id}}.{{domain}}'
fqdn, -- 'https://<name>.urieljareth.org'
repository_project_id, -- numeric GitHub repo ID (from GitHub API)
source_type, -- 'App\Models\GithubApp'
source_id, -- 3 (the GitHub App, see SELECT id FROM github_apps)
destination_type, -- 'App\Models\StandaloneDocker'
destination_id, -- 0 (the localhost Docker, see SELECT id FROM standalone_dockers)
environment_id, -- from Step 1
base_directory, -- '/'
static_image, -- 'nginx:alpine' (unused for dockerfile pack, but NOT NULL)
created_at, updated_at -- NOW()
) VALUES (...);
```
**Step 3 — INSERT into `application_settings` (CRITICAL — skip this and deploys crash):**
```
production.ERROR: Attempt to read property "disable_build_cache" on null
at ApplicationDeploymentJob.php:214
```
Coolify's deployment job reads `$application->settings->disable_build_cache` in its
constructor. Without a settings row, `settings` is null and the job dies instantly —
the deployment stays "in_progress" forever and blocks the queue (see §8.4).
```sql
INSERT INTO application_settings (
application_id, -- the id from Step 2's RETURNING
is_static, is_git_submodules_enabled, is_git_lfs_enabled,
is_auto_deploy_enabled, is_force_https_enabled, is_debug_enabled,
is_preview_deployments_enabled, is_log_drain_enabled, is_gpu_enabled,
is_swarm_only_worker_nodes, is_raw_compose_deployment_enabled,
is_build_server_enabled, is_consistent_container_name_enabled,
is_gzip_enabled, is_stripprefix_enabled,
is_container_label_escape_enabled, is_container_label_readonly_enabled,
disable_build_cache, is_spa, is_git_shallow_clone_enabled,
is_pr_deployments_public_enabled, use_build_secrets, inject_build_args_to_dockerfile,
docker_images_to_keep,
created_at, updated_at
) VALUES (
<app_id>, false, true, true, true, true, false, false, false, false,
true, false, false, false, true, true, true, true, false, false, true,
false, false, true, 2, NOW(), NOW()
);
```
**Step 4 — Trigger deploy via API (works even though app CRUD is 404):**
```powershell
. .\.env.local.ps1
$h = @{ Authorization = "Bearer $env:COOLIFY_TOKEN" }
Invoke-RestMethod "$env:COOLIFY_API_URL/deploy?uuid=<app_uuid>&force=true" -Headers $h
# Returns: { deployments: [{ deployment_uuid: "..." }] }
```
Monitor: `GET /deployments/<deployment_uuid>` → status goes `queued → in_progress → finished`.
### 8.4 Stuck deployment queue — how to unblock
When a deployment fails (e.g. missing `application_settings`), the queue row stays
`in_progress` forever and blocks ALL subsequent deploys of that app.
**Diagnose:**
```sql
SELECT id, status, created_at FROM application_deployment_queues
WHERE application_id = '<app_id>' ORDER BY id DESC LIMIT 5;
```
**Fix:**
```sql
-- Mark the stuck job as failed
UPDATE application_deployment_queues SET status = 'failed', updated_at = NOW()
WHERE id = <stuck_id>;
-- Delete ALL queue entries for the app and start clean
DELETE FROM application_deployment_queues WHERE application_id = '<app_id>';
```
Then restart the Coolify queue worker (so it picks up new jobs):
```sh
pct exec 102 -- docker exec coolify php artisan queue:restart
```
Wait 5 seconds, then trigger a fresh deploy via the API.
### 8.5 Reference: existing GitHub-source apps on this instance (verified 2026-07-27)
| id | name | repo | build_pack | source_id |
|----|------|------|------------|-----------|
| 43 | baserow | baserow/baserow | dockercompose | 3 |
| 46 | estación-de-documentos | urieljarethbusiness-cpu/Estación-de-Documentos | dockercompose | 3 |
| 47 | cotizador | urieljarethbusiness-cpu/cotizador | dockercompose | 3 |
| 48 | firecrawl | firecrawl/firecrawl | dockercompose | 3 |
| 50 | audio-a-texto | urieljarethbusiness-cpu/Audio-a-Texto | dockerfile | 3 |
| 51 | agendamax | urieljarethbusiness-cpu/agendamax | dockerfile | 3 |
GitHub App: id=3, uuid=`miakw8c0vthrtweroh9kzy1t`, app_id=3266541, installation_id=121211999.
Public GitHub source: id=0, uuid=`yyq0od5j3xkdf8twc28n6coh`.
Standalone Docker (destination): id=0, uuid=`jxlxd62k0d8owl6orgkjj1ob`, network=`coolify`.
Server: id=0, uuid=`l10mdaago0z605pga93gl6cz`, name=`localhost`.
### 8.6 Gitea repos as build source — not directly supported via this path
Coolify's GitHub App source only works with GitHub.com repos. For a Gitea-hosted repo:
- **Option A (recommended):** mirror to GitHub (`git remote add github …; git push github main`),
then create the Coolify app from the GitHub repo. This is what we did for AgendaMax —
the primary repo stays on Gitea, GitHub is just a deploy mirror.
- **Option B:** create a "Private repository (deploy key)" app via the web UI — set
`git_repository` to the Gitea SSH URL and `private_key_id` to an SSH key with Gitea access.
Gitea SSH is on port `22222` (mapped from container 22). This path needs UI credentials
and hasn't been tested on this instance.
Re-verified 2026-07-31 (`prompt-gallery-e3`): Option A still works, and the GitHub App
(source_id=3) reaches a **freshly created** private repo under `urieljarethbusiness-cpu`
with no extra configuration — the installation covers all repos on the account, so there
is nothing to click between `POST /user/repos` and the first deploy.
### 8.7 Env vars are Laravel-`encrypted` — raw SQL INSERT kills the deploy
> Verified 2026-07-31 deploying `prompt-gallery-e3` (Next.js 16 + `node:sqlite`).
> §8.3 creates the app but says nothing about env vars; this is the trap.
`environment_variables.value` carries Laravel's `encrypted` cast. Insert a **plaintext**
value with `psql` and the app row looks perfect, but every deploy dies **after** cloning
and reading the Dockerfile, with no hint about which field is at fault:
```
Deployment failed: The payload is invalid.
Error type: Illuminate\Contracts\Encryption\DecryptException
Location: /var/www/html/vendor/laravel/framework/src/Illuminate/Encryption/Encrypter.php:244
```
A correctly stored value is base64 JSON (`eyJpdiI6…`, `{iv,value,mac,tag}`), 200–300 chars
for short secrets. The `APP_KEY` never leaves the container, so **don't try to encrypt from
the outside** — create the rows through Eloquent instead:
```powershell
# write PHP locally -> scp to host -> pct push -> docker cp -> tinker
pct push 102 /tmp/envs.php /tmp/envs.php
pct exec 102 -- docker cp /tmp/envs.php coolify:/tmp/envs.php
pct exec 102 -- docker exec coolify php artisan tinker --execute='include "/tmp/envs.php";'
```
```php
$fila = new \App\Models\EnvironmentVariable();
$fila->key = 'SESSION_SECRET';
$fila->value = '…'; // se cifra al guardar
$fila->resourceable_type = \App\Models\Application::class;
$fila->resourceable_id = 53;
$fila->save(); // uuid se autogenera
```
Three gotchas found doing this:
- **DELETE then CREATE — never UPDATE a plaintext row.** Saving over an existing bad row
throws the same `DecryptException` first, because a model hook reads `value` before your
assignment is written. Wipe the rows with SQL, then create them via Eloquent.
- **The column is `is_buildtime`, not `is_build_time`** (and `is_runtime`/`is_buildtime`
are set by the model — don't touch them).
- **Two rows per key is correct.** Coolify mirrors every variable into a preview copy
(`is_preview = true`), so ids come in pairs. The pre-existing apps show the same shape;
it is not a duplicate-insert bug.
### 8.8 Persistent volumes by this path
`local_persistent_volumes` uses `resource_type` / `resource_id` — **not** the
`resourceable_*` names that `environment_variables` uses. Plain SQL is fine here (no
encrypted columns):
```sql
INSERT INTO local_persistent_volumes (name, mount_path, resource_type, resource_id, uuid, created_at, updated_at)
VALUES ('<app_uuid>-galeria-datos', '/app/data', 'App\Models\Application', <app_id>, '<uuid24>', NOW(), NOW());
```
Insert the volume **before** the first deploy. `New-CoolifyAppViaDB.ps1` triggers a deploy
as its last step, so for an app that needs env vars or a volume, do the whole set of rows
in one data-modifying-CTE statement and trigger `GET /deploy?uuid=` yourself — otherwise
the first build is guaranteed to fail and you burn ~15 min of Next.js build time.
### 8.9 Script bugs fixed 2026-07-31 (were silently breaking repo creation)
- **`" 20[04-9] "` accepted 200 and 204–209 but rejected 201/202/203** — a character-class
typo for `[0-9]`. Present in **both** `gitea_skill/scripts/Invoke-GiteaApi.ps1` and
`deploy_skill/scripts/Invoke-GitHubApi.ps1`. Every repo/hook/release creation returns
**201**, so it threw *after* successfully creating the resource — leaving a created repo
and an aborted pipeline. Both fixed to `" 20[0-9] "`.
- **`Invoke-GitHubApi.ps1` BOM bug (documented in §7.3) is now actually fixed** — the POST
body is written with `[IO.File]::WriteAllText(..., UTF8Encoding($false))`.
- **`git push` to GitHub: `Authorization: Bearer <PAT>` does NOT work.** git-over-https
wants Basic. For a headless push that leaves no token on disk:
```powershell
$basic = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("x-access-token:$env:GITHUB_TOKEN"))
git -C $repo -c "http.extraHeader=Authorization: Basic $basic" -c "credential.helper=" push -u github main
```
(`Sync-GiteaRemote.ps1`'s `token <T>` header is right for Gitea; it is not for GitHub.)
- `New-GitHubRepo.ps1` forces `auto_init = $true`, which puts a commit on the remote and
makes pushing existing history a non-fast-forward. For a mirror of a repo that already
has history, POST `/user/repos` yourself with `auto_init = $false`.
## 9. Apps with a sibling database (verified 2026-07-31, `demospa` — Next.js 15 + Prisma + MySQL 8)
Deployed `serenidad-spa` as `demospa.urieljareth.org` (app id 54) with a Coolify-managed
MySQL. New, reusable facts beyond §8:
### 9.1 `POST /databases/mysql` WORKS — the database API is not part of the 404 namespace
Unlike `/applications/*`, the `/databases/*` namespace responds. `POST /databases/mysql`
with `{server_uuid, project_uuid, environment_name, environment_uuid, destination_uuid,
name, image, mysql_root_password, mysql_database, mysql_user, mysql_password}` returns
`{uuid, internal_db_url}`. **The DB's internal hostname IS its uuid** — so
`DATABASE_URL=mysql://user:pass@<db-uuid>:3306/<db>`. Both the DB and dockerfile-pack apps
land on the external `coolify` network, so service-name DNS works with no extra wiring.
Two gotchas:
- **`instant_deploy: true` did NOT start it.** The resource was created with
`status=exited:unhealthy` and no container. `GET /databases/{uuid}/start` then answered
`400 {"message":"Database is already running."}` (the status field lies). What actually
started it: **`GET /deploy?uuid=<db-uuid>`** — the same trigger used for apps.
- **Never call `/start` and `/deploy` back to back.** Doing so recreated the container
mid-initialization and left a partial datadir; MySQL then crash-looped forever on
`--initialize specified but the data directory has files in it`. Recovery =
`docker compose down` in `/data/coolify/databases/<uuid>/`, `docker volume rm
mysql-data-<uuid>`, then one clean `up -d`. Same "don't queue deploys" rule as §4.
### 9.2 MySQL 8 init on this host takes ~7 min, and `start_period` is hardcoded to 5s
Coolify's generated DB compose sets `healthcheck.start_period: 5s`, but a first-time
MySQL 8 init here takes minutes (InnoDB init alone ~53s). Patch
`/data/coolify/databases/<uuid>/docker-compose.yml` to a longer `start_period` before the
first `up -d`.
**Do not trust `mysqladmin ping` as a readiness signal.** The MySQL entrypoint runs a
*temporary* server with `--skip-networking` while it creates the database and user, so the
socket answers (and Coolify reports `healthy`) while TCP still refuses connections. The log
even prints `ready for connections ... port: 0` for that temp server. Gate on TCP:
`mysqladmin -h127.0.0.1 -uroot -p"$MYSQL_ROOT_PASSWORD" ping`, and confirm a
`ready for connections ... port: 3306` line.
### 9.3 Prisma + MySQL 8: pin `mysql_native_password`
Add to the DB service's compose `command:`
`--default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci`. 8.0.46 only warns that the flag is deprecated. Set
it **before** the first init so the created user gets native auth (afterwards the user
already exists and the env vars are ignored — you'd need `ALTER USER`).
### 9.4 The rolling-update healthcheck window is ~2 minutes — do slow work in the background
Coolify polls the container's **Dockerfile `HEALTHCHECK`** ~6 times at 30s and aborts with
"New container is not healthy, rolling back" if it hasn't passed. An entrypoint that runs
`prisma db push` + seed *before* starting the server will lose this race on a cold DB — the
build succeeds and the deploy still fails.
Working shape: start the server in the foreground and run DB preparation in a background
subshell, so the health endpoint answers in seconds while data lands moments later.
```sh
preparar_base_de_datos() { ...db push retry loop...; ...seed...; }
preparar_base_de_datos &
exec "$@" # el proceso en segundo plano sobrevive al exec
```
Keep the Dockerfile healthcheck tight (`--start-period=10s --interval=10s`) so it turns
healthy inside Coolify's window, and give the health endpoint **no DB dependency**.
### 9.5 `inject_build_args_to_dockerfile` bakes every env var into the image
`New-CoolifyAppViaDB.ps1`'s settings row sets this `true`, so Coolify rewrites the
Dockerfile with an `ARG`/`ENV` per variable — the build log fills with
`SecretsUsedInArgOrEnv: ... (ARG "AUTH_SECRET")` and the secrets end up in image layers,
violating §2.5 of `AGENTS-coolify-apps.md`. Set it to `false` unless the app genuinely needs
build-time vars (a Next.js app only does if it reads `NEXT_PUBLIC_*`, which are inlined at
build time — grep the source before deciding).
### 9.6 Pushing to the GitHub mirror auto-triggers a deploy
The settings row also sets `is_auto_deploy_enabled = true`, and Coolify's GitHub App gets
push webhooks for the whole account — so `git push github main` queues a deploy on its own.
Expect an extra `in_progress` row in `application_deployment_queues`; clear it (§8.4) before
triggering your own, or just let the automatic one run.
### 9.7 Next.js: pages that query the DB break `docker build`
Any App Router page that hits the database without `export const dynamic = "force-dynamic"`
is prerendered during `next build`, where no DB exists. Pages reading `cookies()` are
already dynamic; public landing/catalog pages usually are not. Validate locally with a
deliberately unreachable `DATABASE_URL` — the build must still finish (Prisma logs errors
but they are non-fatal once every DB page is `ƒ`).
`sharp` needs no special handling: `npm ci` on `node:22-alpine` resolves
`@img/sharp-linuxmusl-x64` as long as the lockfile was generated with all platform variants
(it is, by default), so remote-image optimization works.
---
## 10. Correcciones verificadas 2026-08-27 (despliegue de `escudoverde-site`)
### 10.1 `/deploy` ahora exige POST, no GET
El §8.4 dice `GET /deploy?uuid=&force=true`. **Hoy responde 405 Method Not Allowed.**
Con `-Method POST` funciona y devuelve el `deployment_uuid` normalmente:
```powershell
Invoke-RestMethod "$env:COOLIFY_API_URL/deploy?uuid=<app_uuid>&force=true" -Headers $h -Method POST
```
`GET /deployments/{uuid}` sigue funcionando igual para el seguimiento.
### 10.2 El `GITHUB_TOKEN` de `.env.local.ps1` está caducado
`New-GitHubRepo.ps1` falla con `401 Bad credentials`. El token que **sí** sirve es el que
guarda el Administrador de credenciales de Windows para `github.com` (cuenta
`urieljarethbusiness-cpu`, scopes `gist, repo, workflow`). Se recupera sin exponerlo:
```bash
TOK=$(printf "protocol=https\nhost=github.com\n\n" | git credential fill | sed -n 's/^password=//p')
```
Conviene rotar el de `.env.local.ps1` o hacer que los scripts caigan a `git credential fill`.
### 10.3 Imágenes nginx sin root: el orden dentro del `RUN` importa
El builder de Coolify corre el `docker build` **sin DAC override para root**. Consecuencia
concreta con la receta habitual de nginx no-root:
- Si `nginx -t` va **después** del `chown` de `/tmp` al usuario `nginx`, el build falla con
`open() "/tmp/nginx.pid" failed (13: Permission denied)` — aunque en local funcione.
- `nginx -t` **crea** el fichero pid. Si se queda en la imagen, pertenece a root y el
contenedor no arranca al hacer `USER nginx`.
Orden que funciona en ambos lados:
```dockerfile
RUN set -eux; \
mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy; \
nginx -t -c /etc/nginx/nginx.conf; \
rm -f /tmp/nginx/nginx.pid; \
chown -R nginx:nginx /tmp/nginx /usr/share/nginx/html /var/cache/nginx; \
chmod 0777 /tmp/nginx
USER nginx
```
### 10.4 `immutable` + nombre de fichero fijo = despliegue invisible
Con `Cache-Control: public, max-age=31536000, immutable` sobre `/assets/`, Cloudflare
sirvió el CSS anterior durante todo el despliegue siguiente (`cf-cache-status: HIT`,
`Age: 1008`). El contenedor tenía la versión nueva; el usuario veía la vieja.
**Regla para cualquier app estática en esta instancia:** o los assets llevan hash en el
nombre, o el HTML los referencia con `?v=<hash del contenido>`. Sin eso, `immutable` es
una trampa: el despliegue "funciona" y no cambia nada visible.
---
## 11. Re-verificación completa 2026-08-29 (v4.3.14): el 404 de `/applications` era Cloudflare
Auditoría integral con SSH + APIs validadas. La instancia corre ahora
**`4.3.14`** (`GET /version`), actualizada desde 4.3.10. Hallazgos, en orden de
importancia:
### 11.1 La API está COMPLETA — el 404 de `/applications/*` lo impone Cloudflare, no Coolify
Mismo token de siempre, misma ruta, dos caminos:
| Llamada | Resultado |
|---|---|
| `https://coolify.urieljareth.org/api/v1/applications` (público, vía Cloudflare) | **404** |
| `http://192.168.0.117:8000/api/v1/applications` (origen, LXC 102) | **200** (JSON completo) |
| `/github-apps` | 404 vía CF · **200 vía origen** |
| `/dockerfiles`, `/sources`, `/notifications`, `/private-keys` | 404 en ambos → **no existen como rutas**; los nombres correctos están en el spec (ver 11.2) |
| `/version`, `/resources`, `/servers`, `/projects`, `/teams`, `/services`, `/databases`, `/deployments`, `/security/keys` | 200 por ambas vías |
Todo el diagnóstico de §1 ("API parcial", v4.1.2) era este mismo bloqueo de
Cloudflare, no un recorte de la API. Las secciones §3 (Playwright) y §8 (DB
INSERT) siguen siendo fallbacks válidos, pero **la vía API debería funcionar
llamando al origen** (`$env:COOLIFY_API_URL_ORIGIN`). No se ha ejercitado
end-to-end un `POST /applications/*` contra el origen todavía — verificarlo en el
próximo deploy antes de jubilar el flujo UI. El fix de raíz es corregir la regla
del edge en el dashboard de Cloudflare.
### 11.2 La superficie real (del `openapi.yaml` del propio contenedor)
Dentro del contenedor: `/var/www/html/openapi.yaml`. Rutas declaradas en
4.3.14 (extraídas el 2026-08-29):
- `/applications` + `/applications/{public, private-github-app, private-deploy-key, dockerfile, dockerimage}`
- `/databases` + por motor: `{postgresql, clickhouse, dragonfly, redis, keydb, mariadb, mysql, mongodb}`
- `/deployments`, `/deploy`, `/destinations`, `/servers` (+ `/import`, `/digitalocean`, `/hetzner`, `/vultr`)
- `/github-apps`, `/gitlab-apps`, `/security/keys`, `/s3-storages`, `/tags`
- `/notifications/{email, discord, slack, telegram, pushover, webhook}`
- `/cloud-init-scripts`, `/cloud-tokens`
- `/projects`, `/projects/{uuid}/environments`, `/resources`, `/services`
- `/team`, `/teams`, `/team/envs`, `/team/members`
- `/version`, `/health`, `/enable`, `/disable`, `/mcp/enable`, `/mcp/disable`
`GET /docs` (Scalar UI) redirige a `/login` — requiere sesión web; para el
contrato exacto, leer el YAML del contenedor:
`pct exec 102 -- docker exec coolify cat /var/www/html/openapi.yaml`.
### 11.3 POST-only confirmado como comportamiento de Coolify (no de Cloudflare)
Contra el origen, `GET /deploy?uuid=fake` → **405**
`{"message":"This endpoint has changed to a POST request."}`. Es un cambio real
de Coolify v4.2 (changelog: los endpoints de estado pasaron a POST-only), igual
que §10.1. Otros cambios relevantes de 4.2→4.3: endpoints de logs para
db/servicio/contenedor, settings de application incluidas en las respuestas,
soporte MCP (read-only) y build pack Railpack (beta, 4.3.1). Breaking: se
eliminó el endpoint deprecado de aplicación Docker Compose — usar
`POST /services`. Fuentes: coolify.io/changelog y
github.com/coollabsio/coolify/releases.
### 11.4 Estado de credenciales verificado el 2026-08-29
| Credencial | Estado |
|---|---|
| SSH `root@192.168.0.200` y `root@192.168.0.117` con `keys/proxmox_ed25519` (≡ `~/.ssh/coolify_key`, sin passphrase) | ✅ |
| API Proxmox: token `root@pam!openclaw` (único en `token.cfg`) | ✅ 200 `/version` + `/cluster/resources` — ya en `.env.local.ps1` |
| API Coolify: `COOLIFY_TOKEN` (team root) | ✅ 200 — v4.3.14 |
| Gitea: token de `urieljareth` | ✅ 200 `/api/v1/user` |
| GitHub: PAT `ghp_NFy4…` del `.env` | ❌ caducado (401) — reemplazado en `.env.local.ps1` por el token vivo del Credential Manager de Windows (`gho_…`, cuenta `urieljarethbusiness-cpu`, scopes `gist, repo, workflow`) |
| Cloudflare API | ❌ sin token — gestionar túnel desde el dashboard |
| UI Coolify: `urieljareth@gmail.com` / password habitual | ⚠️ probable (heredado de la instancia vieja `192.168.1.175`, ver `C:\Users\Uriel Jareth\coolify-agent\coolify-agent-skill.md`), sin verificar |
Inventario completo de llaves SSH de la máquina (incluidas las corruptas de
SiteGround y la de la instancia antigua): `ACCESS.md` en la raíz del repo.