From b4c3101b291f2b6ef620bbe1c62708682f6ed26c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:08:06 +0200 Subject: [PATCH] fix(compose): reject scale and replicas greater than one (#12213) --- bootstrap/helpers/parsers.php | 22 ++++++++ tests/Feature/ComposeServiceScaleTest.php | 67 +++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/Feature/ComposeServiceScaleTest.php diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index aa9639f737..21f0d7326d 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -200,6 +200,26 @@ function forgetComposeBindStorage(Application|ServiceApplication|ServiceDatabase * * @throws Exception If the source is missing or contains shell metacharacters */ +/** + * Coolify sets container_name on every Compose service, and Docker Compose does not start more + * than one container of a service that has a fixed container_name. + * + * @throws Exception If the service sets scale or deploy.replicas to more than 1 + */ +function validateComposeServiceScale(string $serviceName, mixed $service): void +{ + foreach (['scale', 'deploy.replicas'] as $key) { + $value = data_get($service, $key); + if (is_numeric($value) && (int) $value > 1) { + throw new Exception( + "Service \"{$serviceName}\" sets {$key} to {$value}. Coolify sets a container_name for each service, ". + 'and Docker Compose cannot run more than one container with the same container_name. '. + "Remove {$key}, or define each instance as a separate service." + ); + } + } +} + function validateComposeContentVolumeSource(array $volume): void { if (! array_key_exists('content', $volume) || ($volume['type'] ?? null) !== 'bind') { @@ -1139,6 +1159,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ' Service names must not contain shell metacharacters.' ); } + validateComposeServiceScale((string) $serviceName, $service); $magicEnvironments = collect([]); $image = data_get_str($service, 'image'); @@ -2379,6 +2400,7 @@ function serviceParser(Service $resource): Collection ' Service names must not contain shell metacharacters.' ); } + validateComposeServiceScale((string) $serviceName, $service); $image = data_get_str($service, 'image'); diff --git a/tests/Feature/ComposeServiceScaleTest.php b/tests/Feature/ComposeServiceScaleTest.php new file mode 100644 index 0000000000..ffa582e232 --- /dev/null +++ b/tests/Feature/ComposeServiceScaleTest.php @@ -0,0 +1,67 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); +}); + +function scaleTestApplication(string $compose): Application +{ + return Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ])->fresh(); +} + +function scaleTestService(string $compose): Service +{ + return Service::factory()->create([ + 'docker_compose_raw' => $compose, + 'environment_id' => test()->environment->id, + 'server_id' => test()->destination->server_id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ]); +} + +it('rejects a compose application service with more than one replica', function (string $compose, string $setting) { + expect(fn () => applicationParser(scaleTestApplication($compose))) + ->toThrow(Exception::class, "Service \"celery\" sets {$setting} to 2. Coolify sets a container_name for each service"); +})->with([ + 'scale' => ["services:\n celery:\n image: alpine\n scale: 2\n", 'scale'], + 'deploy.replicas' => ["services:\n celery:\n image: alpine\n deploy:\n replicas: 2\n", 'deploy.replicas'], +]); + +it('rejects a service stack service with more than one replica', function () { + expect(fn () => serviceParser(scaleTestService("services:\n celery:\n image: alpine\n scale: '2'\n"))) + ->toThrow(Exception::class, 'Service "celery" sets scale to 2.'); +}); + +it('accepts one replica and a scale set from a variable', function () { + $compose = applicationParser(scaleTestApplication( + "services:\n web:\n image: alpine\n scale: 1\n worker:\n image: alpine\n deploy:\n replicas: \${REPLICAS:-1}\n" + )); + + expect(data_get($compose, 'services.web.scale'))->toBe(1) + ->and(data_get($compose, 'services.worker.container_name'))->not->toBeEmpty(); +});