deploy_skill: soporte para Coolify v4.1.2 (API /applications 404) + flujo UI Playwright

Aprendido desplegando cotizador (Next.js+FastAPI+Postgres, Docker Compose,
repo privado) en coolify.urieljareth.org (v4.1.2):

- Documenta que /applications/* devuelve 404 en esta instancia y que configurar
  apps git build-from-source solo es posible por la UI web.
- Nuevos scripts scripts/coolify-ui/: coolify-login.mjs (guarda storageState) y
  Configure-CoolifyComposeApp.mjs (build pack -> Docker Compose, Reload Compose
  File, env vars por Developer view, dominios por servicio).
- references/coolify-4.1.2-notes.md: mapa de API funcional/404, gotchas (BOM
  UTF-8 rompe el parser YAML de Coolify; Reload Compose File obligatorio; pin de
  dominios por servicio o 503; no encolar deploys concurrentes; PowerShell
  [IO.File]::ReadAllText para payloads de llaves).
- env.local.template.ps1: COOLIFY_EMAIL/COOLIFY_PASSWORD para la UI.
- SKILL.md/TOOLS.md: seccion "instance reality" y flujo UI paso a paso.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-08 00:42:12 -06:00
co-authored by Claude Opus 4.8
parent cd998ce6b0
commit e7d1c33cd5
22 changed files with 2935 additions and 0 deletions
+233
View File
@@ -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.
+246
View File
@@ -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,103 @@
# 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.
+72
View File
@@ -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
+62
View File
@@ -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
+319
View File
@@ -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
+85
View File
@@ -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
+75
View File
@@ -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
+238
View File
@@ -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(); }
+128
View File
@@ -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);