<# .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