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]>
320 lines
13 KiB
PowerShell
320 lines
13 KiB
PowerShell
<#
|
|
.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
|