From 9a68ac32008a645825b27f3a024ec7d5669bc698 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:00:14 +0200 Subject: [PATCH 1/6] fix(docker): parse quoted and hyphenated custom run option values --- bootstrap/helpers/docker.php | 19 +++---- .../Helpers/DockerCustomCommandsTest.php | 53 +++++++++++++++++++ 2 files changed, 61 insertions(+), 11 deletions(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index c1895bd190..9fbd74b3bc 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1579,7 +1579,8 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) { $options = []; $compose_options = collect([]); - preg_match_all('/(--\w+(?:-\w+)*)(?:\s|=)?([^\s-]+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); + // Values can be double-quoted (with escapes), single-quoted, or unquoted; quoted values may contain spaces and "--" + preg_match_all('/(--\w+(?:-\w+)*)(?:\s|=)?("(?:\\\\.|[^"])*"|\'[^\']*\'|(?!--)\S+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); $list_options = collect([ '--cap-add', '--cap-drop', @@ -1616,16 +1617,6 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) $options[$option][] = $value; $options[$option] = array_unique($options[$option]); } - if ($option === '--hostname') { - // Match --hostname=value or --hostname value - $regexForParsingHostname = '/--hostname(?:=|\s+)([^\s]+)/'; - preg_match($regexForParsingHostname, $custom_docker_run_options, $hostname_matches); - $value = $hostname_matches[1] ?? null; - if ($value && ! empty(trim($value))) { - $options[$option][] = $value; - $options[$option] = array_unique($options[$option]); - } - } if ($option === '--entrypoint') { $value = null; // Match --entrypoint=value or --entrypoint value @@ -1660,6 +1651,12 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) } if (isset($match[2]) && $match[2] !== '') { $value = $match[2]; + if (strlen($value) >= 2 && in_array($value[0], ['"', "'"], true) && str_ends_with($value, $value[0])) { + $value = substr($value, 1, -1); + } + if (trim($value) === '') { + continue; + } $options[$option][] = $value; $options[$option] = array_unique($options[$option]); } else { diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index 299709ffd9..b7be084052 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -233,3 +233,56 @@ 'ip6' => ['2001:db8::1'], ]); }); + +test('ConvertSecurityOptWithHyphenatedValue', function () { + $input = '--cap-drop ALL --security-opt no-new-privileges:true --security-opt=apparmor:unconfined'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'cap_drop' => ['ALL'], + 'security_opt' => ['no-new-privileges:true', 'apparmor:unconfined'], + ]); +}); + +test('ConvertQuotedSecurityOpt', function (string $input) { + expect(convertDockerRunToCompose($input))->toBe([ + 'security_opt' => ['no-new-privileges:true'], + ]); +})->with([ + '--security-opt="no-new-privileges:true"', + "--security-opt='no-new-privileges:true'", + '--security-opt "no-new-privileges:true"', + "--security-opt 'no-new-privileges:true'", +]); + +test('ConvertQuotedValuesForListAndScalarOptions', function () { + $input = '--cap-add "NET_ADMIN" --cap-drop \'ALL\' --sysctl "net.core.somaxconn=1024" --device "/dev/snd" --dns "8.8.8.8" --ip="10.0.0.5" --shm-size "2g" --hostname \'my-host\' --security-opt "label=type:my type"'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'cap_add' => ['NET_ADMIN'], + 'cap_drop' => ['ALL'], + 'sysctls' => ['net.core.somaxconn=1024'], + 'devices' => ['/dev/snd'], + 'dns' => ['8.8.8.8'], + 'ip' => ['10.0.0.5'], + 'shm_size' => '2g', + 'hostname' => 'my-host', + 'security_opt' => ['label=type:my type'], + ]); +}); + +test('ConvertIgnoresEmptyQuotedValue', function () { + $input = '--security-opt="" --init'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'init' => true, + ]); +}); + +test('ConvertDoesNotParseOptionsInsideQuotedEntrypoint', function () { + $input = '--entrypoint "sh -c \'exec app --privileged --cap-add=SYS_ADMIN\'" --init'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'entrypoint' => "sh -c 'exec app --privileged --cap-add=SYS_ADMIN'", + 'init' => true, + ]); +}); From eb5219fb7e279810e17e329b9ed897358ed15051 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:51:40 +0200 Subject: [PATCH 2/6] fix(docker): parse boolean flags and ulimit values correctly Closes #8173 --- bootstrap/helpers/docker.php | 49 ++++++++------ .../Helpers/DockerCustomCommandsTest.php | 65 +++++++++++++++++++ 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 9fbd74b3bc..6917efc98b 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1579,8 +1579,8 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) { $options = []; $compose_options = collect([]); - // Values can be double-quoted (with escapes), single-quoted, or unquoted; quoted values may contain spaces and "--" - preg_match_all('/(--\w+(?:-\w+)*)(?:\s|=)?("(?:\\\\.|[^"])*"|\'[^\']*\'|(?!--)\S+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); + // Values can be double-quoted (with escapes), single-quoted, or unquoted; unquoted values cannot start with "-", so the next flag is not taken as a value + preg_match_all('/(--\w+(?:-\w+)*)(?:\s|=)?("(?:\\\\.|[^"])*"|\'[^\']*\'|(?!-)\S+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); $list_options = collect([ '--cap-add', '--cap-drop', @@ -1649,20 +1649,24 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) continue; } - if (isset($match[2]) && $match[2] !== '') { - $value = $match[2]; - if (strlen($value) >= 2 && in_array($value[0], ['"', "'"], true) && str_ends_with($value, $value[0])) { - $value = substr($value, 1, -1); - } - if (trim($value) === '') { - continue; - } - $options[$option][] = $value; - $options[$option] = array_unique($options[$option]); - } else { - $value = true; - $options[$option] = $value; + $value = $match[2] ?? ''; + if (strlen($value) >= 2 && $value[0] === '"' && str_ends_with($value, '"')) { + $value = preg_replace('/\\\\(["$`\\\\])/', '$1', substr($value, 1, -1)); + } elseif (strlen($value) >= 2 && $value[0] === "'" && str_ends_with($value, "'")) { + $value = substr($value, 1, -1); + } + if ($option === '--init' || $option === '--privileged') { + // Boolean flags only take a value with "=" (--privileged=false); a space-separated word is not their value + $hasInlineValue = str_starts_with(substr($match[0], strlen($option)), '='); + $options[$option] = ! ($hasInlineValue && strtolower($value) === 'false'); + + continue; + } + if (trim($value) === '') { + continue; } + $options[$option][] = $value; + $options[$option] = array_unique($options[$option]); } $options = collect($options); // Easily get mappings from https://github.com/composerize/composerize/blob/master/packages/composerize/src/mappings.js @@ -1673,7 +1677,10 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) if ($option === '--ulimit') { $ulimits = collect([]); collect($value)->map(function ($ulimit) use ($ulimits) { - $ulimit = explode('=', $ulimit); + if (! str_contains($ulimit, '=')) { + return; + } + $ulimit = explode('=', $ulimit, 2); $type = $ulimit[0]; $limits = explode(':', $ulimit[1]); if (count($limits) == 2) { @@ -1684,13 +1691,13 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) 'hard' => $hard_limit, ]); } else { - $soft_limit = $ulimit[1]; - $ulimits->put($type, [ - 'soft' => $soft_limit, - ]); + // Compose needs both soft and hard in the mapping form, so a single limit is set directly + $ulimits->put($type, $ulimit[1]); } }); - $compose_options->put($mapping[$option], $ulimits); + if ($ulimits->isNotEmpty()) { + $compose_options->put($mapping[$option], $ulimits); + } } elseif ($option === '--shm-size' || $option === '--hostname') { if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { $compose_options->put($mapping[$option], $value[0]); diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index b7be084052..98b6c2994f 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -270,6 +270,71 @@ ]); }); +test('ConvertUnescapesDoubleQuotedValue', function () { + $input = '--security-opt "label=type:\"my\" type"'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'security_opt' => ['label=type:"my" type'], + ]); +}); + +test('ConvertBooleanFlagFollowedByShortFlag', function () { + $input = '--init -e FOO=bar --privileged -v /a:/b'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'init' => true, + 'privileged' => true, + ]); +}); + +test('ConvertBooleanFlagWithInlineValue', function () { + $input = '--privileged=false --init=true'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'privileged' => false, + 'init' => true, + ]); +}); + +test('ConvertBooleanFlagIgnoresSpaceSeparatedWord', function () { + $input = '--privileged false --init "x"'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'privileged' => true, + 'init' => true, + ]); +}); + +test('ConvertUlimitWithSingleValue', function () { + $input = '--ulimit nofile=1024 --ulimit nproc=512:1024'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'ulimits' => [ + 'nofile' => '1024', + 'nproc' => [ + 'soft' => '512', + 'hard' => '1024', + ], + ], + ]); +}); + +test('ConvertIgnoresUlimitWithoutType', function () { + $input = '--ulimit 1024 --init'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'init' => true, + ]); +}); + +test('ConvertIgnoresOptionWithoutValue', function () { + $input = '--cap-add -e FOO=bar --cap-add NET_ADMIN --ulimit --dns'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'cap_add' => ['NET_ADMIN'], + ]); +}); + test('ConvertIgnoresEmptyQuotedValue', function () { $input = '--security-opt="" --init'; $output = convertDockerRunToCompose($input); From 265795eea7b1e56dd394ffb157e05f5a4679d960 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:13:00 +0200 Subject: [PATCH 3/6] fix(docker): parse whitespace and replace matching default ulimits Closes #8173 --- bootstrap/helpers/docker.php | 6 ++- .../Helpers/DockerCustomCommandsTest.php | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 6917efc98b..f4c62ec973 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1580,7 +1580,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) $options = []; $compose_options = collect([]); // Values can be double-quoted (with escapes), single-quoted, or unquoted; unquoted values cannot start with "-", so the next flag is not taken as a value - preg_match_all('/(--\w+(?:-\w+)*)(?:\s|=)?("(?:\\\\.|[^"])*"|\'[^\']*\'|(?!-)\S+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); + preg_match_all('/(--\w+(?:-\w+)*)(?:\s+|=)?("(?:\\\\.|[^"])*"|\'[^\']*\'|(?!-)\S+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); $list_options = collect([ '--cap-add', '--cap-drop', @@ -1764,6 +1764,10 @@ function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker if ($ipv6) { $docker_compose['services'][$container_name]['networks'][$network]['ipv6_address'] = $ipv6; } + // A custom ulimit replaces the default of the same type; a recursive merge would combine both into an invalid value + foreach (array_keys(data_get($docker_run_options, 'ulimits', [])) as $type) { + unset($docker_compose['services'][$container_name]['ulimits'][$type]); + } $docker_compose['services'][$container_name] = array_merge_recursive($docker_compose['services'][$container_name], $docker_run_options); return $docker_compose; diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index 98b6c2994f..4f8ccc4cff 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -351,3 +351,47 @@ 'init' => true, ]); }); + +test('ConvertValueAfterMultipleWhitespace', function () { + $input = "--cap-add NET_ADMIN --security-opt \t no-new-privileges:true --dns\t\t1.1.1.1"; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'cap_add' => ['NET_ADMIN'], + 'security_opt' => ['no-new-privileges:true'], + 'dns' => ['1.1.1.1'], + ]); +}); + +test('DatabaseCustomUlimitReplacesDefaultOfSameType', function () { + $dockerCompose = [ + 'services' => [ + 'db' => [ + 'image' => 'clickhouse/clickhouse-server', + 'ulimits' => ['nofile' => ['soft' => 262144, 'hard' => 262144]], + ], + ], + ]; + $options = convertDockerRunToCompose('--ulimit nofile=1024:2048 --ulimit nproc=512'); + $output = generateCustomDockerRunOptionsForDatabases($options, $dockerCompose, 'db', 'coolify'); + expect($output['services']['db']['ulimits'])->toBe([ + 'nofile' => ['soft' => '1024', 'hard' => '2048'], + 'nproc' => '512', + ]); +}); + +test('DatabaseDefaultUlimitIsKeptWhenNotOverridden', function () { + $dockerCompose = [ + 'services' => [ + 'db' => [ + 'image' => 'clickhouse/clickhouse-server', + 'ulimits' => ['nofile' => ['soft' => 262144, 'hard' => 262144]], + ], + ], + ]; + $options = convertDockerRunToCompose('--ulimit nproc=512'); + $output = generateCustomDockerRunOptionsForDatabases($options, $dockerCompose, 'db', 'coolify'); + expect($output['services']['db']['ulimits'])->toBe([ + 'nofile' => ['soft' => 262144, 'hard' => 262144], + 'nproc' => '512', + ]); +}); From 838668bccf1c5405ce72f69096e7f66aa18952fa Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:22:23 +0200 Subject: [PATCH 4/6] fix(docker): parse quoted option parts and GPU counts Closes #8173 --- bootstrap/helpers/docker.php | 51 +++++++++---------- .../Helpers/DockerCustomCommandsTest.php | 35 +++++++++++++ 2 files changed, 59 insertions(+), 27 deletions(-) diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f4c62ec973..ab80081fa5 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1579,8 +1579,9 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) { $options = []; $compose_options = collect([]); - // Values can be double-quoted (with escapes), single-quoted, or unquoted; unquoted values cannot start with "-", so the next flag is not taken as a value - preg_match_all('/(--\w+(?:-\w+)*)(?:\s+|=)?("(?:\\\\.|[^"])*"|\'[^\']*\'|(?!-)\S+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); + // A value is one shell word: double-quoted (with escapes), single-quoted, and unquoted parts, as in key="a b". + // It cannot start with "-", so the next flag is not taken as a value. + preg_match_all('/(--\w+(?:-\w+)*)(?:\s+|=)?((?!-)(?:"(?:\\\\.|[^"])*"|\'[^\']*\'|[^\s"\']|["\'])+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); $list_options = collect([ '--cap-add', '--cap-drop', @@ -1610,12 +1611,27 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) ]); foreach ($matches as $match) { $option = $match[1]; + // Remove the quotes like a shell: unescape \" \$ \` \\ in double quotes, keep single-quoted text as is + $value = preg_replace_callback( + '/"((?:\\\\.|[^"])*)"|\'([^\']*)\'/', + fn ($quoted) => isset($quoted[2]) ? $quoted[2] : preg_replace('/\\\\(["$`\\\\])/', '$1', $quoted[1]), + $match[2] ?? '' + ); if ($option === '--gpus') { - $regexForParsingDeviceIds = '/device=([0-9A-Za-z-,]+)/'; - preg_match($regexForParsingDeviceIds, $custom_docker_run_options, $device_matches); - $value = $device_matches[1] ?? 'all'; - $options[$option][] = $value; - $options[$option] = array_unique($options[$option]); + // all, a count (2), or device IDs ("device=0,1"; the inner quotes are part of the Docker syntax) + $gpus = trim($value, '"'); + $payload = [ + 'driver' => 'nvidia', + 'capabilities' => ['gpu'], + ]; + if (preg_match('/^device=([0-9A-Za-z-,]+)$/', $gpus, $device_matches)) { + $payload['device_ids'] = explode(',', $device_matches[1]); + } elseif (ctype_digit($gpus)) { + $payload['count'] = (int) $gpus; + } + $options[$option] = $payload; + + continue; } if ($option === '--entrypoint') { $value = null; @@ -1649,12 +1665,6 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) continue; } - $value = $match[2] ?? ''; - if (strlen($value) >= 2 && $value[0] === '"' && str_ends_with($value, '"')) { - $value = preg_replace('/\\\\(["$`\\\\])/', '$1', substr($value, 1, -1)); - } elseif (strlen($value) >= 2 && $value[0] === "'" && str_ends_with($value, "'")) { - $value = substr($value, 1, -1); - } if ($option === '--init' || $option === '--privileged') { // Boolean flags only take a value with "=" (--privileged=false); a space-separated word is not their value $hasInlineValue = str_starts_with(substr($match[0], strlen($option)), '='); @@ -1709,23 +1719,10 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) $compose_options->put($mapping[$option], $value[0]); } } elseif ($option === '--gpus') { - $payload = [ - 'driver' => 'nvidia', - 'capabilities' => ['gpu'], - ]; - if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { - if (str($value[0]) != 'all') { - if (str($value[0])->contains(',')) { - $payload['device_ids'] = str($value[0])->explode(',')->toArray(); - } else { - $payload['device_ids'] = [$value[0]]; - } - } - } $compose_options->put('deploy', [ 'resources' => [ 'reservations' => [ - 'devices' => [$payload], + 'devices' => [$value], ], ], ]); diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index 4f8ccc4cff..35da6681f2 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -395,3 +395,38 @@ 'nproc' => '512', ]); }); + +test('ConvertValueWithQuotedPart', function () { + $input = '--sysctl net.ipv4.ping_group_range="0 2147483647" --ulimit nofile="1024:2048" --security-opt label="type:svirt_apache_t"'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'sysctls' => ['net.ipv4.ping_group_range=0 2147483647'], + 'ulimits' => [ + 'nofile' => [ + 'soft' => '1024', + 'hard' => '2048', + ], + ], + 'security_opt' => ['label=type:svirt_apache_t'], + ]); +}); + +test('ConvertGpusCount', function () { + $input = '--gpus 2'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'deploy' => [ + 'resources' => [ + 'reservations' => [ + 'devices' => [ + [ + 'driver' => 'nvidia', + 'capabilities' => ['gpu'], + 'count' => 2, + ], + ], + ], + ], + ], + ]); +}); From 509156604677fbd22bd220e93b3e5ce586fcd5a9 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:40:09 +0200 Subject: [PATCH 5/6] fix(docker): parse more run options and warn on unsupported flags Closes #8173 --- app/Jobs/ApplicationDeploymentJob.php | 4 ++ bootstrap/helpers/constants.php | 23 ++++++++ bootstrap/helpers/docker.php | 55 ++++++++++++------- .../Helpers/DockerCustomCommandsTest.php | 31 +++++++++++ ...ationDeploymentCustomDockerOptionsTest.php | 21 ++++++- 5 files changed, 112 insertions(+), 22 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f22447d135..14b6c93b98 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -4291,6 +4291,10 @@ private function generate_compose_file() if ($this->pull_request_id === 0) { $custom_compose = convertDockerRunToCompose($this->application->custom_docker_run_options); + $unsupported_options = unsupportedDockerRunOptions($this->application->custom_docker_run_options); + if (count($unsupported_options) > 0) { + $this->application_deployment_queue->addLogEntry('Warning: These custom Docker options are not supported and were not applied: '.implode(', ', $unsupported_options).'. See https://coolify.io/docs/applications/builds/custom-docker-options for the supported options.', 'stderr'); + } if ((bool) $this->application->settings->is_consistent_container_name_enabled) { $docker_compose['services'][$this->application->uuid] = $docker_compose['services'][$this->container_name]; if ($this->container_name !== $this->application->uuid) { diff --git a/bootstrap/helpers/constants.php b/bootstrap/helpers/constants.php index 543901932f..6dd1155a20 100644 --- a/bootstrap/helpers/constants.php +++ b/bootstrap/helpers/constants.php @@ -23,6 +23,29 @@ 'clickhouse' => StandaloneClickhouse::class, 'sqlite' => StandaloneSqlite::class, ]; +// Custom Docker options that Coolify converts to Compose; other options conflict with settings Coolify manages +const DOCKER_RUN_OPTION_MAPPING = [ + '--add-host' => 'extra_hosts', + '--cap-add' => 'cap_add', + '--cap-drop' => 'cap_drop', + '--security-opt' => 'security_opt', + '--sysctl' => 'sysctls', + '--device' => 'devices', + '--init' => 'init', + '--ulimit' => 'ulimits', + '--privileged' => 'privileged', + '--ip' => 'ip', + '--ip6' => 'ip6', + '--shm-size' => 'shm_size', + '--dns' => 'dns', + '--gpus' => 'gpus', + '--hostname' => 'hostname', + '--entrypoint' => 'entrypoint', + '--pids-limit' => 'pids_limit', + '--read-only' => 'read_only', + '--runtime' => 'runtime', + '--tmpfs' => 'tmpfs', +]; const VALID_CRON_STRINGS = [ 'every_minute' => '* * * * *', 'hourly' => '0 * * * *', diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index ab80081fa5..f9a85aa3d2 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1580,9 +1580,10 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) $options = []; $compose_options = collect([]); // A value is one shell word: double-quoted (with escapes), single-quoted, and unquoted parts, as in key="a b". - // It cannot start with "-", so the next flag is not taken as a value. - preg_match_all('/(--\w+(?:-\w+)*)(?:\s+|=)?((?!-)(?:"(?:\\\\.|[^"])*"|\'[^\']*\'|[^\s"\']|["\'])+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); + // It cannot start with "-" (except a negative number, as in --pids-limit -1), so the next flag is not taken as a value. + preg_match_all('/(--\w+(?:-\w+)*)(?:\s+|=)?((?!-(?!\d))(?:"(?:\\\\.|[^"])*"|\'[^\']*\'|[^\s"\']|["\'])+)?/', $custom_docker_run_options, $matches, PREG_SET_ORDER); $list_options = collect([ + '--add-host', '--cap-add', '--cap-drop', '--security-opt', @@ -1591,24 +1592,9 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) '--device', '--shm-size', '--dns', + '--tmpfs', ]); - $mapping = collect([ - '--cap-add' => 'cap_add', - '--cap-drop' => 'cap_drop', - '--security-opt' => 'security_opt', - '--sysctl' => 'sysctls', - '--device' => 'devices', - '--init' => 'init', - '--ulimit' => 'ulimits', - '--privileged' => 'privileged', - '--ip' => 'ip', - '--ip6' => 'ip6', - '--shm-size' => 'shm_size', - '--dns' => 'dns', - '--gpus' => 'gpus', - '--hostname' => 'hostname', - '--entrypoint' => 'entrypoint', - ]); + $mapping = collect(DOCKER_RUN_OPTION_MAPPING); foreach ($matches as $match) { $option = $match[1]; // Remove the quotes like a shell: unescape \" \$ \` \\ in double quotes, keep single-quoted text as is @@ -1665,7 +1651,7 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) continue; } - if ($option === '--init' || $option === '--privileged') { + if (in_array($option, ['--init', '--privileged', '--read-only'], true)) { // Boolean flags only take a value with "=" (--privileged=false); a space-separated word is not their value $hasInlineValue = str_starts_with(substr($match[0], strlen($option)), '='); $options[$option] = ! ($hasInlineValue && strtolower($value) === 'false'); @@ -1708,10 +1694,14 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) if ($ulimits->isNotEmpty()) { $compose_options->put($mapping[$option], $ulimits); } - } elseif ($option === '--shm-size' || $option === '--hostname') { + } elseif (in_array($option, ['--shm-size', '--hostname', '--runtime'], true)) { if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { $compose_options->put($mapping[$option], $value[0]); } + } elseif ($option === '--pids-limit') { + if (is_numeric($value[0])) { + $compose_options->put($mapping[$option], (int) $value[0]); + } } elseif ($option === '--entrypoint') { if (! is_null($value) && is_array($value) && count($value) > 0 && ! empty(trim($value[0]))) { // Docker compose accepts entrypoint as either a string or an array @@ -1746,6 +1736,29 @@ function convertDockerRunToCompose(?string $custom_docker_run_options = null) return $compose_options->toArray(); } +/** + * Returns the options (--network, -e, ...) that convertDockerRunToCompose() does not apply. + * + * @return array + */ +function unsupportedDockerRunOptions(?string $custom_docker_run_options): array +{ + // Split into shell words, so text inside a quoted value is not taken as an option + preg_match_all('/(?:"(?:\\\\.|[^"])*"|\'[^\']*\'|[^\s"\']|["\'])+/', $custom_docker_run_options ?? '', $words); + $unsupported = []; + foreach ($words[0] as $word) { + if (! str_starts_with($word, '-') || preg_match('/^-\d/', $word)) { + continue; + } + $option = str_starts_with($word, '--') ? '--'.ltrim(strtok($word, '='), '-') : strtok($word, '='); + if (! array_key_exists($option, DOCKER_RUN_OPTION_MAPPING)) { + $unsupported[] = $option; + } + } + + return array_values(array_unique($unsupported)); +} + function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $network) { $ipv4 = data_get($docker_run_options, 'ip.0'); diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index 35da6681f2..e96f210ef3 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -430,3 +430,34 @@ ], ]); }); + +test('ConvertRuntimeHardeningOptions', function () { + $input = '--add-host host.docker.internal:host-gateway --add-host=db:10.0.0.5 --pids-limit 100 --runtime runc --read-only --tmpfs /run:rw,size=64m --tmpfs /tmp'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'extra_hosts' => ['host.docker.internal:host-gateway', 'db:10.0.0.5'], + 'pids_limit' => 100, + 'runtime' => 'runc', + 'read_only' => true, + 'tmpfs' => ['/run:rw,size=64m', '/tmp'], + ]); +}); + +test('ConvertNegativePidsLimitAndDisabledReadOnly', function () { + $input = '--pids-limit -1 --read-only=false'; + $output = convertDockerRunToCompose($input); + expect($output)->toBe([ + 'pids_limit' => -1, + 'read_only' => false, + ]); +}); + +test('UnsupportedDockerRunOptions', function () { + $input = '--network=host --name app -e FOO=bar -v /a:/b --entrypoint "sh -c \'run --verbose\'" --cap-add NET_ADMIN --pids-limit -1 ---privileged --network bridge'; + expect(unsupportedDockerRunOptions($input))->toBe(['--network', '--name', '-e', '-v']); +}); + +test('UnsupportedDockerRunOptionsIsEmptyForSupportedOptions', function () { + expect(unsupportedDockerRunOptions('--cap-drop ALL --security-opt no-new-privileges:true --tmpfs /tmp'))->toBe([]) + ->and(unsupportedDockerRunOptions(null))->toBe([]); +}); diff --git a/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php b/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php index 234f216bd2..86a694f49b 100644 --- a/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php +++ b/tests/Unit/ApplicationDeploymentCustomDockerOptionsTest.php @@ -22,7 +22,7 @@ public function __construct() {} public function execute_remote_command(...$commands): void {} } -function generateComposeServiceWithCustomDockerOptions(string $customDockerOptions): array +function generateComposeServiceWithCustomDockerOptions(string $customDockerOptions, ?Closure $expectOnQueue = null): array { $team = Team::create([ 'name' => 'Custom Docker Options Team', @@ -52,6 +52,9 @@ function generateComposeServiceWithCustomDockerOptions(string $customDockerOptio $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); $queue->status = 'queued'; $queue->shouldReceive('refresh')->once()->andReturnSelf(); + if ($expectOnQueue) { + $expectOnQueue($queue); + } $application = $application->fresh(); $job = new TestableCustomDockerOptionsDeploymentJob; @@ -96,3 +99,19 @@ function generateComposeServiceWithCustomDockerOptions(string $customDockerOptio ->toHaveKey('aliases') ->and($network['aliases'])->toContain('custom-alias'); }); + +it('warns in the deployment log about unsupported custom docker options', function () { + $service = generateComposeServiceWithCustomDockerOptions('--network=host -e FOO=bar --init', function ($queue) { + $queue->shouldReceive('addLogEntry') + ->once() + ->with('Warning: These custom Docker options are not supported and were not applied: --network, -e. See https://coolify.io/docs/applications/builds/custom-docker-options for the supported options.', 'stderr'); + }); + + expect($service)->toHaveKey('init', true); +}); + +it('does not warn when all custom docker options are supported', function () { + generateComposeServiceWithCustomDockerOptions('--init --read-only --tmpfs /tmp', function ($queue) { + $queue->shouldNotReceive('addLogEntry'); + }); +}); From 045850e488f23edd3c3f705c45bbcdcdbfdd96db Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:43:30 +0200 Subject: [PATCH 6/6] fix(deployments): mount custom seccomp profiles in the helper container Closes #8173 --- app/Jobs/ApplicationDeploymentJob.php | 6 ++-- bootstrap/helpers/docker.php | 16 ++++++++++ .../DeploymentSensitiveCommandFailureTest.php | 31 +++++++++++++++++++ .../Helpers/DockerCustomCommandsTest.php | 7 +++++ 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 14b6c93b98..80b92f7723 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2905,12 +2905,14 @@ private function prepare_builder_image(bool $firstTry = true) } $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { + // docker compose runs in this helper and reads seccomp profile files from the custom Docker options + $seccompMounts = $this->pull_request_id === 0 ? seccompProfileMountFlags($this->application->custom_docker_run_options) : ''; if ($this->dockerConfigFileExists === 'OK') { $safeNetwork = escapeshellarg($this->destination->network); - $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}"; } else { $safeNetwork = escapeshellarg($this->destination->network); - $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; + $runCommand = "docker run -d --network {$safeNetwork} --name {$this->deployment_uuid} {$env_flags} --rm {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$seccompMounts} {$helperImage}"; } } if ($firstTry) { diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index f9a85aa3d2..243b81bac2 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1759,6 +1759,22 @@ function unsupportedDockerRunOptions(?string $custom_docker_run_options): array return array_values(array_unique($unsupported)); } +/** + * Returns --mount flags that make seccomp profile files from the custom Docker options readable in the deployment + * helper container, where docker compose reads them. The path stays the same, so security_opt works unchanged. + */ +function seccompProfileMountFlags(?string $custom_docker_run_options): string +{ + $flags = []; + foreach (data_get(convertDockerRunToCompose($custom_docker_run_options), 'security_opt', []) as $security_opt) { + if (preg_match('#^seccomp[=:](/[A-Za-z0-9._/-]+)$#', $security_opt, $profile) && ! str_contains($profile[1], '..')) { + $flags[] = '--mount '.escapeshellarg("type=bind,source={$profile[1]},target={$profile[1]},readonly"); + } + } + + return implode(' ', array_unique($flags)); +} + function generateCustomDockerRunOptionsForDatabases($docker_run_options, $docker_compose, $container_name, $network) { $ipv4 = data_get($docker_run_options, 'ip.0'); diff --git a/tests/Feature/DeploymentSensitiveCommandFailureTest.php b/tests/Feature/DeploymentSensitiveCommandFailureTest.php index a879ee286e..c0c4882168 100644 --- a/tests/Feature/DeploymentSensitiveCommandFailureTest.php +++ b/tests/Feature/DeploymentSensitiveCommandFailureTest.php @@ -560,3 +560,34 @@ function linkSensitiveFailureSecretManager(Application $application): void expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe('LITERAL_REF='.escapeComposeEnvFileValue('resolved-remote-value')); }); + +it('mounts the seccomp profile from custom docker options into the helper container', function () { + [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob(); + $application->update(['custom_docker_run_options' => '--security-opt seccomp=/etc/docker/seccomp-profile.json']); + $server = readSensitiveFailureJobProperty($job, $reflection, 'server'); + setSensitiveFailureJobProperties($job, $reflection, [ + 'application' => $application->fresh(), + 'destination' => StandaloneDocker::forceCreate([ + 'name' => 'seccomp-network', + 'network' => 'seccomp-network', + 'server_id' => $server->id, + ]), + ]); + + $helperCommands = []; + Process::fake(function (PendingProcess $process) use (&$helperCommands) { + if (str_contains($process->command, 'docker run -d')) { + $helperCommands[] = $process->command; + + return Process::result(errorOutput: 'docker: Error response from daemon: invalid mount config for type "bind": bind source path does not exist: /etc/docker/seccomp-profile.json', exitCode: 125); + } + + return Process::result(output: str_contains($process->command, 'echo $HOME') ? '/root' : 'NOK'); + }); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image')); + + expect($helperCommands)->toHaveCount(1) + ->and($helperCommands[0])->toContain('type=bind,source=/etc/docker/seccomp-profile.json,target=/etc/docker/seccomp-profile.json,readonly') + ->and($exception->getMessage())->toContain('bind source path does not exist: /etc/docker/seccomp-profile.json'); +}); diff --git a/tests/Feature/Helpers/DockerCustomCommandsTest.php b/tests/Feature/Helpers/DockerCustomCommandsTest.php index e96f210ef3..7a16d15446 100644 --- a/tests/Feature/Helpers/DockerCustomCommandsTest.php +++ b/tests/Feature/Helpers/DockerCustomCommandsTest.php @@ -461,3 +461,10 @@ expect(unsupportedDockerRunOptions('--cap-drop ALL --security-opt no-new-privileges:true --tmpfs /tmp'))->toBe([]) ->and(unsupportedDockerRunOptions(null))->toBe([]); }); + +test('SeccompProfileMountFlags', function () { + $input = '--security-opt seccomp=/etc/docker/seccomp.json --security-opt "seccomp:/srv/app profile.json" --security-opt seccomp=/etc/../root/x.json --security-opt seccomp=unconfined --security-opt apparmor=unconfined'; + expect(seccompProfileMountFlags($input))->toBe("--mount 'type=bind,source=/etc/docker/seccomp.json,target=/etc/docker/seccomp.json,readonly'") + ->and(seccompProfileMountFlags('--cap-drop ALL'))->toBe('') + ->and(seccompProfileMountFlags(null))->toBe(''); +});