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