From 986ece457d4e13304c30a6f81c27717ac800280b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 3 Sep 2026 20:38:19 +0200 Subject: [PATCH] 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. --- app/Livewire/Project/Application/Domains.php | 12 +++++- .../Project/Application/PreviewDomains.php | 12 +++++- bootstrap/helpers/docker.php | 33 +++++++++++++++ bootstrap/helpers/parsers.php | 2 +- bootstrap/helpers/shared.php | 2 +- tests/Feature/ApplicationDomainsTest.php | 42 +++++++++++++++++++ ...licationParserDockerComposeDomainsTest.php | 32 ++++++++++++++ 7 files changed, 129 insertions(+), 6 deletions(-) diff --git a/app/Livewire/Project/Application/Domains.php b/app/Livewire/Project/Application/Domains.php index 9f1e7fc17..d7797935f 100644 --- a/app/Livewire/Project/Application/Domains.php +++ b/app/Livewire/Project/Application/Domains.php @@ -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] diff --git a/app/Livewire/Project/Application/PreviewDomains.php b/app/Livewire/Project/Application/PreviewDomains.php index 21296978f..e6e9f5d60 100644 --- a/app/Livewire/Project/Application/PreviewDomains.php +++ b/app/Livewire/Project/Application/PreviewDomains.php @@ -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] diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f80fccafb..84232a8d7 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -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([]); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index f65b62698..590b68d16 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -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()); } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 9f18c466d..f9bf44dff 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -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: diff --git a/tests/Feature/ApplicationDomainsTest.php b/tests/Feature/ApplicationDomainsTest.php index 9d0739d1c..19dca7966 100644 --- a/tests/Feature/ApplicationDomainsTest.php +++ b/tests/Feature/ApplicationDomainsTest.php @@ -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', diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 0c40d4a86..30305ef59 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -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' => << 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", +]);