fix(mcp): inline magic env URLs in compose so service domains survive parse()

Coolify's Service::parse() (called inside the deploy job) iterates ALL
ServiceApplications in a multi-container template and overwrites
SERVICE_URL_<NAME> / SERVICE_FQDN_<NAME> 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_<NAME>} and
${SERVICE_FQDN_<NAME>} (with optional _<port> 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
This commit is contained in:
2026-04-30 11:40:13 -07:00
parent bd993123c0
commit 652e45ac00

View File

@@ -2306,9 +2306,16 @@ async function toolAppsDomainsSet(principal: Principal, params: Record<string, a
const fqdnsForUrl = normalized
.map((d) => `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_<NAME>} / ${SERVICE_FQDN_<NAME>}
// 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_<NAME> 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(