fix(domains): use Compose service ports for internal routing

Detect Docker Compose service ports for domain internal ports and proxy labels, with application ports as a fallback.
This commit is contained in:
Andras Bacsai 2026-09-03 20:38:19 +02:00
parent 1d0d6b4a79
commit 986ece457d
7 changed files with 129 additions and 6 deletions

View file

@ -500,7 +500,7 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto
{
$key = $this->domainDnsStatusKey($url, $service);
$entry = $stored[$key] ?? null;
$port = $this->effectiveDomainInternalPort($url);
$port = $this->effectiveDomainInternalPort($url, $service);
$row = [
'url' => $url,
@ -532,7 +532,7 @@ protected function domainRowFromStored(string $url, ?string $service, array $sto
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
protected function effectiveDomainInternalPort(string $url): array
protected function effectiveDomainInternalPort(string $url, ?string $service = null): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $this->application->domain_port_overrides ?? [];
@ -561,6 +561,14 @@ protected function effectiveDomainInternalPort(string $url): array
];
}
$composePort = dockerComposeServicePort($this->application->docker_compose_raw, $service);
if ($composePort !== null) {
return [
'internal_port' => $composePort,
'has_port_override' => false,
];
}
$exposed = $this->application->ports_exposes_array;
$defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0
? (int) $exposed[0]

View file

@ -439,7 +439,7 @@ private function validatedDomain(array $parts, string $errorKey): ?string
private function makeRow(string $url, ?string $service, array $statuses = []): array
{
$status = $statuses[$this->statusKey($url, $service)] ?? [];
$port = $this->effectiveDomainInternalPort($url);
$port = $this->effectiveDomainInternalPort($url, $service);
return [
'url' => $url,
@ -500,7 +500,7 @@ private function openPortWarning(?int $port, string $action): void
/**
* @return array{internal_port: ?int, has_port_override: bool}
*/
private function effectiveDomainInternalPort(string $url): array
private function effectiveDomainInternalPort(string $url, ?string $service = null): array
{
$canonical = DomainPortOverrides::withoutPort($url);
$overrides = $this->preview->domain_port_overrides ?? [];
@ -529,6 +529,14 @@ private function effectiveDomainInternalPort(string $url): array
];
}
$composePort = dockerComposeServicePort($this->preview->application->docker_compose_raw, $service);
if ($composePort !== null) {
return [
'internal_port' => $composePort,
'has_port_override' => false,
];
}
$exposed = $this->preview->application->ports_exposes_array;
$defaultPort = isset($exposed[0]) && is_numeric($exposed[0]) && (int) $exposed[0] > 0
? (int) $exposed[0]

View file

@ -601,6 +601,39 @@ function fqdnLabelsForCaddy(string $network, string $uuid, Collection $domains,
return $labels->sort();
}
function firstDockerComposeServicePort(mixed $service): ?int
{
$portDefinitions = collect(data_get($service, 'expose', []))
->merge(data_get($service, 'ports', []));
foreach ($portDefinitions as $definition) {
$port = is_array($definition)
? data_get($definition, 'target')
: str((string) $definition)->before('/')->afterLast(':')->value();
if (is_numeric($port) && (int) $port >= 1 && (int) $port <= 65535) {
return (int) $port;
}
}
return null;
}
function dockerComposeServicePort(?string $compose, ?string $serviceName): ?int
{
if (blank($compose) || blank($serviceName)) {
return null;
}
try {
$services = data_get(Yaml::parse($compose), 'services', []);
} catch (Throwable) {
return null;
}
return firstDockerComposeServicePort(is_array($services) ? ($services[$serviceName] ?? null) : null);
}
function fqdnLabelsForTraefik(string $uuid, Collection $domains, bool $is_force_https_enabled = false, $onlyPort = null, ?Collection $serviceLabels = null, ?bool $is_gzip_enabled = true, ?bool $is_stripprefix_enabled = true, ?string $service_name = null, bool $generate_unique_uuid = false, ?string $image = null, string $redirect_direction = 'both', bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $escape_redirect_replacement_for_compose = true, array $domainPortOverrides = [])
{
$labels = collect([]);

View file

@ -1358,7 +1358,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
? ($previewForPorts?->domain_port_overrides ?? [])
: ($originalResource->domain_port_overrides ?? []);
$exposedPorts = $originalResource->settings->is_static ? [80] : $originalResource->ports_exposes_array;
$onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null;
$onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null);
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
}

View file

@ -3916,7 +3916,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
? ($resource->domain_port_overrides ?? [])
: ($preview?->domain_port_overrides ?? []);
$exposedPorts = $resource->settings->is_static ? [80] : $resource->ports_exposes_array;
$onlyPort = count($exposedPorts) > 0 ? $exposedPorts[0] : null;
$onlyPort = firstDockerComposeServicePort($service) ?? ($exposedPorts[0] ?? null);
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:

View file

@ -2311,6 +2311,48 @@
->assertDontSee('Custom internal port for this domain', false);
});
it('shows the detected compose service port as the inherited internal port', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'ports_exposes' => '3000',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n expose:\n - '8069'\n",
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://example.com'],
]),
'fqdn' => null,
'domain_port_overrides' => null,
]);
Livewire::test(Domains::class, ['application' => $this->application->fresh()])
->assertSet('domainRows.0.internal_port', 8069)
->assertSet('domainRows.0.has_port_override', false)
->assertSee('Internal port 8069')
->assertDontSee('Internal port 3000');
});
it('shows the detected compose service port for preview domains', function () {
$this->application->update([
'build_pack' => 'dockercompose',
'ports_exposes' => '3000',
'docker_compose_raw' => "services:\n web:\n image: nginx:alpine\n ports:\n - '18069:8069'\n",
]);
$preview = ApplicationPreview::create([
'application_id' => $this->application->id,
'pull_request_id' => 8069,
'pull_request_html_url' => 'https://github.com/coollabsio/coolify/pull/8069',
'docker_compose_domains' => json_encode([
'web' => ['domain' => 'https://preview.example.com'],
]),
]);
Livewire::test(PreviewDomains::class, ['preview' => $preview])
->assertSet('domainRows.0.internal_port', 8069)
->assertSet('domainRows.0.has_port_override', false)
->assertSee('Internal port 8069')
->assertDontSee('Internal port 3000');
});
it('keeps a legacy port-bearing url port in the edit field as an internal port override', function () {
$this->application->update([
'ports_exposes' => '3000,8080',

View file

@ -599,3 +599,35 @@ function disableExactProxyLabels(Application $application): Application
->and($labels->contains(fn (string $label): bool => str_contains($label, 'Host(`frontend.example.com`)')))
->toBeTrue();
});
test('applicationParser compose labels prefer the service exposed port over application ports_exposes', function (string $portConfiguration) {
$application = disableExactProxyLabels(Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => StandaloneDocker::class,
'build_pack' => 'dockercompose',
'ports_exposes' => '3000',
'docker_compose_raw' => <<<YAML
services:
frontend:
image: myapp/frontend:latest
{$portConfiguration}
YAML,
'fqdn' => null,
'domain_port_overrides' => null,
'docker_compose_domains' => json_encode([
'frontend' => ['domain' => 'https://frontend.example.com'],
]),
]));
$labels = collect(data_get(applicationParser($application->fresh()), 'services.frontend.labels'));
expect($labels->contains(fn (string $label): bool => str_ends_with($label, '.loadbalancer.server.port=8069')))
->toBeTrue()
->and($labels->contains(fn (string $label): bool => str_contains($label, 'reverse_proxy={{upstreams 8069}}')))
->toBeTrue();
})->with([
'expose' => " expose:\n - '8069'",
'short port syntax' => " ports:\n - '18069:8069'",
'long port syntax' => " ports:\n - target: 8069\n published: 18069",
]);