From 652e45ac001dbb248a8adc4f8d9ef92a2953db9f Mon Sep 17 00:00:00 2001 From: Mark Henderson Date: Thu, 30 Apr 2026 11:40:13 -0700 Subject: [PATCH] fix(mcp): inline magic env URLs in compose so service domains survive parse() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coolify's Service::parse() (called inside the deploy job) iterates ALL ServiceApplications in a multi-container template and overwrites SERVICE_URL_ / SERVICE_FQDN_ env vars based on whichever inner app's fqdn it sees first — frequently a worker still pointing at the auto-generated *.sslip.io fallback. The result: the user's custom domain is saved, the SPA loads, but its baked-in REACT_APP_SERVER_BASE_URL points at sslip.io and every API call 404s. apps_domains_set now substitutes ${SERVICE_URL_} and ${SERVICE_FQDN_} (with optional _ suffix) directly in the docker_compose_raw before calling updateCompose(), and stops calling service->parse() — so the literal URL survives any future redeploy. Solves Twenty CRM domain bug; also unlocks reliable custom domains for n8n, Plausible, Mautic, and every other Coolify service template that follows the same SERVICE_URL_X pattern. Made-with: Cursor --- app/api/mcp/route.ts | 49 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 1ebf627b..90bd1058 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -2306,9 +2306,16 @@ async function toolAppsDomainsSet(principal: Principal, params: Record `https://${d}${port ? `:${port}` : ''}`) .join(','); - // PHP code: load service, find target inner app by name (or auto-pick), - // set fqdn, save, regenerate compose, re-parse. All quoting carefully - // escaped because we shell this through ssh. + // PHP code: load service, find target inner app, save fqdn, then + // INLINE-REPLACE ${SERVICE_URL_} / ${SERVICE_FQDN_} + // interpolations in docker_compose_raw with the literal user-provided + // URL. This bypasses Coolify's parse() — which iterates ALL apps in + // a multi-container template (twenty + worker + redis + postgres) + // and overwrites SERVICE_URL_ env vars based on whichever + // ServiceApplication.fqdn it sees first, often producing the + // *.sslip.io fallback. Hardcoding the URL into the compose template + // makes the deploy survive future parse() calls (which the deploy + // job runs internally on every redeploy). const phpCode = ` $service = App\\Models\\Service::where('uuid', '${appUuid}')->first(); if (!$service) { echo 'service-not-found'; exit; } @@ -2317,8 +2324,42 @@ $target = ${inner === 'auto' ? '$apps->first(fn($a) => !str_contains(strtolower( if (!$target) { echo 'inner-app-not-found'; exit; } $target->fqdn = '${fqdnsForUrl}'; $target->save(); + +// Parse the saved fqdn into base URL + bare host so we can substitute. +$first = explode(',', $target->fqdn)[0]; +preg_match('#^(https?://)([^:/]+)(?::(\\\\d+))?#', $first, $mm); +$scheme = $mm[1] ?? 'https://'; +$host = $mm[2] ?? ''; +$urlBase = $scheme . $host; +$fqdnBase = $host; + +// Compose template uses the inner app NAME uppercased, dashes→underscores. +$svcVar = strtoupper(str_replace('-', '_', $target->name)); +$raw = $service->docker_compose_raw ?? ''; + +// Replace port-specific first so the bare match below doesn't eat the prefix. +$raw = preg_replace_callback( + '/\\\\$\\\\{SERVICE_URL_' . preg_quote($svcVar, '/') . '_(\\\\d+)\\\\}/', + fn($m) => $urlBase . ':' . $m[1], + $raw +); +$raw = preg_replace_callback( + '/\\\\$\\\\{SERVICE_FQDN_' . preg_quote($svcVar, '/') . '_(\\\\d+)\\\\}/', + fn($m) => $fqdnBase . ':' . $m[1], + $raw +); +$raw = str_replace('\\\${SERVICE_URL_' . $svcVar . '}', $urlBase, $raw); +$raw = str_replace('\\\${SERVICE_FQDN_' . $svcVar . '}', $fqdnBase, $raw); + +$service->docker_compose_raw = $raw; +$service->save(); + +// updateCompose() rewrites the rendered docker_compose output and the +// SERVICE_URL_/SERVICE_FQDN_ env vars from $target->fqdn. We deliberately +// SKIP $service->parse() because it picks the wrong inner app in +// multi-container templates and reverts env vars to sslip.io. updateCompose($target); -$service->parse(); + echo 'fqdn-saved=' . $target->fresh()->fqdn; `; const result = await runOnCoolifyHost(