fix(deployments): pass port-suffixed compose service URL variables

Docker Compose applications dropped the stored SERVICE_URL_<NAME>_<PORT> and SERVICE_FQDN_<NAME>_<PORT> variables and only generated the forms without a port, so compose references to them were blank. Generate the port-suffixed forms from the current domain for runtime and build-time, production and preview deployments.

Closes #12427

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-02 14:02:19 +01:00
co-authored by Claude Opus 5.5
parent dbd211486b
commit b9ad48e104
2 changed files with 97 additions and 57 deletions
+40 -57
View File
@@ -1938,22 +1938,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id === 0) {
// Generate SERVICE_ variables first for dockercompose
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
// Env keys always use underscore-normalized names so hyphen/dot storage keys stay valid.
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = composeDomainEntryString($domain);
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn);
}
foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) {
$envs->push($key.'='.$value);
}
// Generate SERVICE_NAME for dockercompose services from processed compose
@@ -2015,21 +2001,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
} else {
// Generate SERVICE_ variables first for dockercompose preview
if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = composeDomainEntryString($domain);
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$envs->push('SERVICE_URL_'.$serviceEnvKey.'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.$serviceEnvKey.'='.$coolifyFqdn);
}
foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) {
$envs->push($key.'='.$value);
}
// Generate SERVICE_NAME for dockercompose services
@@ -2112,6 +2085,38 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
}
/**
* SERVICE_URL_* and SERVICE_FQDN_* values from the compose domains. Port-suffixed variables
* that the parser stored (SERVICE_URL_APP_3000) get the same value with the port appended.
*
* @return array<string, string>
*/
private function composeServiceUrlVariables(?string $domainsJson): array
{
$storedKeys = $this->application->environment_variables()->pluck('key');
$variables = [];
foreach (json_decode($domainsJson ?: '[]', true) ?: [] as $forServiceName => $domain) {
$parsedDomain = composeDomainEntryString($domain);
if (blank($parsedDomain)) {
continue;
}
$url = Url::fromString(str($parsedDomain)->explode(',')->first())->withPort(null);
$values = ['URL' => $url->__toString(), 'FQDN' => $url->getHost()];
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper()->value();
foreach ($values as $type => $value) {
$variables["SERVICE_{$type}_{$serviceEnvKey}"] = $value;
}
$pattern = '/^SERVICE_(URL|FQDN)_'.preg_quote($serviceEnvKey, '/').'_(\d+)$/';
foreach ($storedKeys as $key) {
if (preg_match($pattern, $key, $matches)) {
$variables[$key] = "{$values[$matches[1]]}:{$matches[2]}";
}
}
}
return $variables;
}
private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
{
$key = str($environmentVariable->key);
@@ -2333,19 +2338,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
$domains = collect(json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = composeDomainEntryString($domain);
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn);
}
foreach ($this->composeServiceUrlVariables($this->application->docker_compose_domains) as $key => $value) {
$envs_dict[$key] = escapeBashEnvValue($value);
}
} else {
// Generate SERVICE_NAME for preview deployments
@@ -2356,19 +2350,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains') ?: '[]', true) ?: []);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = composeDomainEntryString($domain);
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$envs_dict['SERVICE_URL_'.$serviceEnvKey] = escapeBashEnvValue($coolifyUrl->__toString());
$envs_dict['SERVICE_FQDN_'.$serviceEnvKey] = escapeBashEnvValue($coolifyFqdn);
}
foreach ($this->composeServiceUrlVariables(data_get($this->preview, 'docker_compose_domains')) as $key => $value) {
$envs_dict[$key] = escapeBashEnvValue($value);
}
}
}
@@ -2143,3 +2143,60 @@ YAML;
'production' => 0,
'preview' => 42,
]);
it('passes port-suffixed compose service URL variables to production and preview deployments', function (int $pullRequestId) {
$compose = <<<'YAML'
services:
docmost:
build: .
environment:
- SERVICE_URL_DOCMOST_3000
- APP_URL=$SERVICE_URL_DOCMOST_3000
YAML;
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'docker_compose' => $compose,
'docker_compose_domains' => json_encode(['docmost' => ['domain' => 'https://docs.example.com']]),
]);
foreach ([
'SERVICE_URL_DOCMOST' => 'https://docs.example.com',
'SERVICE_FQDN_DOCMOST' => 'docs.example.com',
'SERVICE_URL_DOCMOST_3000' => 'https://stale.example.com:3000',
'SERVICE_FQDN_DOCMOST_3000' => 'stale.example.com:3000',
] as $key => $value) {
createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value, 'is_runtime' => true, 'is_buildtime' => true]);
}
$overrides = ['pull_request_id' => $pullRequestId];
$expectedHost = 'docs.example.com';
if ($pullRequestId !== 0) {
$expectedHost = '7.docs.example.com';
$overrides['preview'] = ApplicationPreview::create([
'application_id' => $application->id,
'pull_request_id' => $pullRequestId,
'pull_request_html_url' => 'https://github.com/example/repo/pull/7',
'docker_compose_domains' => json_encode(['docmost' => ['domain' => "https://{$expectedHost}"]]),
]);
}
[$job, $reflection] = makeControlVarFilteringJob($application, $server, $overrides);
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)
->toContain("SERVICE_URL_DOCMOST=https://{$expectedHost}")
->toContain("SERVICE_URL_DOCMOST_3000=https://{$expectedHost}:3000")
->toContain("SERVICE_FQDN_DOCMOST_3000={$expectedHost}:3000")
->and($runtimeEnvs->implode("\n"))->not->toContain('stale.example.com');
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables')->implode("\n");
expect($buildtimeEnvs)
->toContain("SERVICE_URL_DOCMOST_3000='https://{$expectedHost}:3000'")
->toContain("SERVICE_FQDN_DOCMOST_3000='{$expectedHost}:3000'")
->not->toContain('stale.example.com');
})->with([
'production' => [0],
'preview' => [7],
]);