replace('.', '-') ->replace('_', '-') ->replaceMatches('/[^a-zA-Z0-9-]+/', '-') ->replaceMatches('/-+/', '-') ->trim('-') ->toString(); if ($normalized === '') { $normalized = 'service'; } return $normalized.'-'.traefikServiceNameHash($serviceName); } /** * Key that Sentinel uses to group Caddy access-log lines. It is the same key that Sentinel gets * from the Traefik router name (`https-{i}-{KEY}@docker`), so both proxies report a resource the same way. * Only [A-Za-z0-9-] can occur, so the value is safe in a Docker label and in a Caddyfile. */ function caddyTrafficAppKey(string $uuid, ?string $serviceName = null): string { // Same truthiness check as fqdnLabelsForTraefik(), so the keys stay equal. $key = $serviceName ? $uuid.'-'.traefikSafeServiceNameSegment($serviceName) : $uuid; return (string) preg_replace('/[^A-Za-z0-9-]+/', '-', $key); } /** * UUID of the resource that owns a container. * * New containers carry `coolify.{type}Uuid`. Containers created before that label carry a * numeric `coolify.{type}Id` instead. Ids change when a server moves to another instance, so * for those containers the owner is read from the compose project (Swarm: stack namespace), * which is the resource UUID for deployments since mid-2024. * * @param 'application'|'service'|'database' $type */ function containerOwnerUuid(Collection|array|string $labels, string $type): ?string { $labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels); $uuid = $labels->get("coolify.{$type}Uuid"); if (filled($uuid)) { return (string) $uuid; } if (! $labels->has("coolify.{$type}Id")) { return null; } $legacyUuid = $labels->get('com.docker.compose.project') ?? $labels->get('com.docker.stack.namespace'); return filled($legacyUuid) ? (string) $legacyUuid : null; } /** * Resource that owns a container, from the given candidates, by UUID label or compose project / * stack namespace (see containerOwnerUuid()). Some older application containers have another * compose project: the deployment directory (before mid-2024, or a custom Compose start command * without --project-name). Their container name (`coolify.name`, for Dockerfile-style builds also * the compose service) contains the application UUID as a dash-separated part, for example * `{uuid}-104512123456` or `{service}-{uuid}-104512123456`. The numeric id label is never used: * another Coolify instance that manages the same server can have a resource with the same id. * * @template TResource of \Illuminate\Database\Eloquent\Model * * @param Collection $resources * @param 'application'|'service'|'database' $type * @return TResource|null */ function resolveContainerOwner(Collection $resources, Collection|array|string $labels, string $type): mixed { $labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels); $uuid = containerOwnerUuid($labels, $type); $owner = $uuid ? $resources->firstWhere('uuid', $uuid) : null; if ($owner || $type !== 'application' || filled($labels->get('coolify.applicationUuid')) || ! $labels->has('coolify.applicationId')) { return $owner; } $names = [(string) $labels->get('com.docker.compose.service'), (string) $labels->get('coolify.name')]; return $resources->first(fn ($resource) => collect($names)->contains( fn (string $name) => filled($resource->uuid) && preg_match('/(?:^|-)'.preg_quote($resource->uuid, '/').'(?:-|$)/', $name) === 1 )); } /** * Id of the application that owns a container, also for applications not in $applications * (for example the parent of a preview). Same order as resolveContainerOwner(). * * @param array|null $applicationIdsByUuid Owners outside $applications, preloaded with * containerApplicationIdsByUuid(); null queries per call. */ function resolveContainerApplicationId(Collection $applications, Collection|array|string $labels, ?array $applicationIdsByUuid = null): ?int { $labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels); $owner = resolveContainerOwner($applications, $labels, 'application'); if ($owner) { return (int) $owner->id; } $uuid = containerOwnerUuid($labels, 'application'); if (! $uuid) { return null; } if ($applicationIdsByUuid !== null) { return $applicationIdsByUuid[$uuid] ?? null; } $id = Application::withTrashed()->where('uuid', $uuid)->value('id'); return $id !== null ? (int) $id : null; } /** * Ids of the applications (including trashed ones) that own the given containers but are not in * $applications, keyed by owner UUID, in one query. Pass the result to resolveContainerApplicationId() * so a batch of containers does not query once per container. * * @param iterable $containerLabels * @return array */ function containerApplicationIdsByUuid(Collection $applications, iterable $containerLabels): array { $uuids = []; foreach ($containerLabels as $labels) { $uuid = containerOwnerUuid($labels, 'application'); if ($uuid) { $uuids[$uuid] = true; } } foreach ($applications->pluck('uuid')->filter() as $knownUuid) { unset($uuids[$knownUuid]); } if ($uuids === []) { return []; } return Application::withTrashed() ->whereIn('uuid', array_keys($uuids)) ->pluck('id', 'uuid') ->map(fn ($id) => (int) $id) ->all(); } /** * Whether a container belongs to a resource of the given type (by UUID or older numeric id label). * * @param 'application'|'service'|'database' $type */ function isContainerOfType(Collection|array|string $labels, string $type): bool { $labels = is_string($labels) ? format_docker_labels_to_json($labels) : collect($labels); return filled($labels->get("coolify.{$type}Uuid")) || filled($labels->get("coolify.{$type}Id")); } /** * Service, sub type, and service application/database that own a service container. * * The part is matched by `coolify.service.subUuid`; containers from before that label are matched * by their compose service key (the part name), then by the older numeric `coolify.service.subId`. * * @param Collection $services * @return array{0: ?Service, 1: ?string, 2: ServiceApplication|ServiceDatabase|null} */ function resolveServiceContainerOwner(Collection $services, Collection|array $flatLabels): array { $labels = collect($flatLabels); $service = resolveContainerOwner($services, $labels, 'service'); if (! $service) { return [null, null, null]; } $subType = $labels->get('coolify.service.subType'); $parts = $subType === 'database' ? $service->databases : $service->applications; $subUuid = $labels->get('coolify.service.subUuid'); if (filled($subUuid)) { return [$service, $subType, $parts->firstWhere('uuid', $subUuid)]; } $legacySubId = $labels->get('coolify.service.subId'); $part = $parts->firstWhere('name', $labels->get('com.docker.compose.service')) ?? (is_numeric($legacySubId) ? $parts->first(fn ($part) => (int) $part->id === (int) $legacySubId) : null); return [$service, $subType, $part]; } /** * Remote commands that list, as JSON lines, the containers owned by a resource (see containerOwnerUuid()). * * @param 'application'|'service'|'database' $type * @param list $extraFilters Additional `--filter` values, e.g. `label=coolify.pullRequestId=0`. * @param list|null $legacyExtraFilters Filters for containers without the UUID label; defaults to $extraFilters. * @return list */ function dockerPsByOwnerCommands(string $type, string $uuid, array $extraFilters = [], string $format = '{{json .}}', bool $all = true, ?array $legacyExtraFilters = null): array { $filters = fn (array $values) => collect($values)->map(fn (string $filter) => '--filter '.escapeshellarg($filter))->implode(' '); $base = 'docker ps'.($all ? ' -a' : ''); $formatArgument = '--format '.escapeshellarg($format); $legacyFilters = $legacyExtraFilters ?? $extraFilters; $commands = [ $base.' '.$filters(["label=coolify.{$type}Uuid={$uuid}", ...$extraFilters]).' '.$formatArgument, $base.' '.$filters(["label=coolify.{$type}Id", "label=com.docker.compose.project={$uuid}", ...$legacyFilters]).' '.$formatArgument, ]; if ($type === 'application') { // Compose project is the deployment directory (see resolveContainerOwner()): the container name contains the UUID. $commands[] = $base.' '.$filters(['label=coolify.applicationId', "name={$uuid}", ...$legacyFilters]).' '.$formatArgument; } return $commands; } /** * Run dockerPsByOwnerCommands() for a resource and return each container once. * * @param 'application'|'service'|'database' $type */ function containersOwnedBy(Server $server, string $type, Model $resource): Collection { $output = instant_remote_process(dockerPsByOwnerCommands($type, $resource->uuid), $server); return format_docker_command_output_to_json($output)->filter()->unique('ID')->values(); } /** * One shell line that sets $container_ids to the ids of the containers owned by a resource. * * @param 'application'|'service'|'database' $type * @param list $extraFilters * @param list|null $legacyExtraFilters */ function containerIdsByOwnerScript(string $type, string $uuid, array $extraFilters = [], bool $all = true, ?array $legacyExtraFilters = null): string { $commands = dockerPsByOwnerCommands($type, $uuid, $extraFilters, '{{.ID}}', $all, $legacyExtraFilters); return 'container_ids=$({ '.implode('; ', $commands).'; } | sort -u)'; } function getCurrentApplicationContainerStatus(Server $server, Application $application, ?int $pullRequestId = null, ?bool $includePullrequests = false): Collection { $containers = collect([]); if (! $server->isSwarm()) { $containers = containersOwnedBy($server, 'application', $application); $containers = $containers->map(function ($container) use ($pullRequestId, $includePullrequests) { $labels = data_get($container, 'Labels'); $containerName = data_get($container, 'Names'); $hasPrLabel = str($labels)->contains('coolify.pullRequestId='); $prLabelValue = null; if ($hasPrLabel) { preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches); $prLabelValue = $matches[1] ?? null; } // Treat pullRequestId=0 or missing label as base deployment (convention: 0 = no PR) $isBaseDeploy = ! $hasPrLabel || (int) $prLabelValue === 0; // If we're looking for a specific PR and this is a base deployment, exclude it if ($pullRequestId !== null && $pullRequestId !== 0 && $isBaseDeploy) { return null; } // If this is a base deployment, include it when not filtering for PRs if ($isBaseDeploy) { return $container; } if ($includePullrequests) { return $container; } if ($pullRequestId !== null && $pullRequestId !== 0 && (int) $prLabelValue === $pullRequestId) { return $container; } return null; }); $filtered = $containers->filter(); return $filtered; } return $containers; } function getCurrentServiceContainerStatus(Server $server, Service $service): Collection { $containers = collect([]); if (! $server->isSwarm()) { return containersOwnedBy($server, 'service', $service); } return $containers; } function getCurrentDatabaseContainerStatus(Server $server, Model $database): Collection { $containers = collect([]); if (! $server->isSwarm()) { return containersOwnedBy($server, 'database', $database); } return $containers; } function getCurrentServiceSubContainerStatus(Server $server, Service $service, string $name): Collection { return filterServiceSubContainersByName(getCurrentServiceContainerStatus($server, $service), $name); } function filterServiceSubContainersByName(Collection $containers, string $name): Collection { return $containers->filter(function ($container) use ($name) { $labels = data_get($container, 'Labels', []); if (is_string($labels)) { $labels = format_docker_labels_to_json($labels); } return collect($labels)->get('coolify.name') === $name; })->values(); } function format_docker_command_output_to_json($rawOutput): Collection { $outputLines = explode(PHP_EOL, $rawOutput); if (count($outputLines) === 1) { $outputLines = collect($outputLines[0]); } else { $outputLines = collect($outputLines); } try { return $outputLines ->reject(fn ($line) => empty($line)) ->map(fn ($outputLine) => json_decode($outputLine, true, flags: JSON_THROW_ON_ERROR)); } catch (Throwable) { return collect([]); } } function format_docker_labels_to_json(string|array $rawOutput): Collection { if (is_array($rawOutput)) { return collect($rawOutput); } $outputLines = explode(PHP_EOL, $rawOutput); return collect($outputLines) ->reject(fn ($line) => empty($line)) ->map(function ($outputLine) { $outputArray = explode(',', $outputLine); return collect($outputArray) ->mapWithKeys(function ($outputLine) { $label = explode('=', $outputLine, 2); if (count($label) !== 2) { return []; } return [$label[0] => $label[1]]; }); })[0]; } function format_docker_envs_to_json($rawOutput) { try { $outputLines = json_decode($rawOutput, true, flags: JSON_THROW_ON_ERROR); return collect(data_get($outputLines[0], 'Config.Env', []))->mapWithKeys(function ($env) { $env = explode('=', $env, 2); return [$env[0] => $env[1]]; }); } catch (Throwable) { return collect([]); } } function checkMinimumDockerEngineVersion($dockerVersion) { $majorDockerVersion = (int) str($dockerVersion)->before('.')->value(); $requiredDockerVersion = (int) str(config('constants.docker.minimum_required_version'))->before('.')->value(); if ($majorDockerVersion < $requiredDockerVersion) { $dockerVersion = null; } return $dockerVersion; } function parseDockerEngineVersion(?string $rawVersion): ?string { if ($rawVersion === null || trim($rawVersion) === '') { return null; } if (preg_match('/\d+\.\d+(?:\.\d+)?/', $rawVersion, $matches) !== 1) { return null; } $parts = explode('.', $matches[0]); return sprintf('%d.%d.%d', (int) $parts[0], (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0)); } function dockerEngineVersionFromJson(?string $raw): ?string { if ($raw === null || trim($raw) === '') { return null; } $decoded = json_decode($raw, true); if (! is_array($decoded)) { return null; } $version = $decoded['Server']['Version'] ?? null; return is_string($version) ? parseDockerEngineVersion($version) : null; } function dockerStopTimeoutOption(?string $dockerVersion): string { $normalized = parseDockerEngineVersion($dockerVersion); if ($normalized !== null && version_compare($normalized, '28.0.0', '>=')) { return '--timeout'; } return '--time'; } function dockerStopCommand(int $timeout, string $containers, Server|string|null $dockerVersion = null): string { $version = $dockerVersion instanceof Server ? $dockerVersion->dockerVersion() : $dockerVersion; $option = dockerStopTimeoutOption($version); $flag = $option === '--timeout' ? "--timeout={$timeout}" : "--time={$timeout}"; $command = "docker stop {$flag} {$containers}"; if (app()->bound('config') && isDev()) { Log::info('docker stop command', [ 'command' => $command, 'docker_version' => $version, ]); } return $command; } function dockerRemoveCommandWithTimeout(string $container, int $timeout = 60, int $killAfter = 10): string { $container = escapeShellValue($container); $script = "if command -v timeout >/dev/null 2>&1; then output=\$(timeout -k {$killAfter}s {$timeout}s docker rm -f {$container} 2>&1); exit_code=\$?; else output=''; exit_code=124; fi; if [ \"\$exit_code\" -eq 124 ]; then echo '__COOLIFY_CONTAINER_REMOVE_TIMEOUT__'; elif [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -q 'No such container:'; then exit 0; elif [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; return 'bash -c '.escapeShellValue($script); } function dockerRemoveCommand(string $container): string { $command = 'docker rm -f '.escapeShellValue($container); return dockerCommandIgnoringError($command, 'No such container:'); } function dockerNetworkRemoveCommand(string $network): string { $command = 'docker network rm '.escapeShellValue($network); return dockerCommandIgnoringError($command, 'network .* not found'); } function dockerCommandIgnoringError(string $command, string $ignoredError): string { $script = "output=\$({$command} 2>&1); exit_code=\$?; if [ \"\$exit_code\" -ne 0 ] && printf '%s' \"\$output\" | grep -Eq ".escapeShellValue($ignoredError)."; then exit 0; fi; if [ \"\$exit_code\" -ne 0 ]; then printf '%s\\n' \"\$output\" >&2; else printf '%s\\n' \"\$output\"; fi; exit \$exit_code"; return 'bash -c '.escapeShellValue($script); } function escapeShellValue(string $value): string { return "'".str_replace("'", "'\\''", $value)."'"; } /** * Prefix for a Docker command that pulls on the server itself, outside the helper container. It makes the CLI use * the SSH user's Docker config, where the registry logins are and which the helper container mounts. On a non-root * server the command runs with sudo, which resets HOME to /root, so without it a private image pull is anonymous. * The SSH user's shell expands $HOME before sudo runs. Without a config of the SSH user, DOCKER_CONFIG is empty and * the CLI keeps /root/.docker, so a login made with `sudo docker login` still works. DOCKER_CONTEXT=default keeps the * pull on the host daemon if that config selects another context, because the next commands use the host daemon. */ function serverDockerConfigPrefix(Server $server): string { // No && or pipe: with them, the sudo parser can wrap the line in `sudo bash -c`, where $HOME is /root. return $server->isNonRoot() ? 'DOCKER_CONTEXT=default DOCKER_CONFIG="$(find "$HOME/.docker/config.json" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)" ' : ''; } function executeInDocker(string $containerId, string $command) { $escapedCommand = str_replace("'", "'\\''", $command); return "docker exec {$containerId} bash -c '{$escapedCommand}'"; } function buildContainerStatusCommand(Server $server, string $container_id): string { if ($server->isSwarm()) { return 'docker service ls --filter '.escapeshellarg("name={$container_id}")." --format '{{json .}}' "; } return "docker inspect --format '{{json .}}' ".escapeshellarg($container_id); } function getContainerStatus(Server $server, string $container_id, bool $all_data = false, bool $throwError = false) { $container = instant_remote_process([buildContainerStatusCommand($server, $container_id)], $server, $throwError); if (! $container) { return 'exited'; } $container = format_docker_command_output_to_json($container); if ($container->isEmpty()) { return 'exited'; } if ($all_data) { return $container[0]; } if ($server->isSwarm()) { $replicas = data_get($container[0], 'Replicas'); $replicas = explode('/', $replicas); $active = (int) $replicas[0]; $total = (int) $replicas[1]; if ($active === $total) { return 'running'; } else { return 'starting'; } } else { return data_get($container[0], 'State.Status', 'exited'); } } function generateApplicationContainerName(Application $application, $pull_request_id = 0) { // TODO: refactor generateApplicationContainerName, we do not need $application and $pull_request_id $consistent_container_name = $application->settings->is_consistent_container_name_enabled; $name = $consistent_container_name ? ($application->settings->custom_internal_name ?: $application->uuid) : $application->uuid; $now = now()->format('Ymd\THis'); if ($pull_request_id !== 0 && $pull_request_id !== null) { return $name.'-pr-'.$pull_request_id; } else { if ($consistent_container_name) { return $name; } return ($application->settings->custom_container_name_prefix ?: $application->uuid).'-'.$now; } } /** * Generated (rolling update) container names end with the timestamp from generateApplicationContainerName(). * Drop the legacy pattern once containers created before the ISO 8601 suffix are gone. */ function isGeneratedContainerName(string $containerName): bool { $isoTimestampSuffix = '/-\d{8}T\d{6}$/'; $legacyTimestampSuffix = '/-\d{12}$/'; return preg_match($isoTimestampSuffix, $containerName) === 1 || preg_match($legacyTimestampSuffix, $containerName) === 1; } function get_port_from_dockerfile($dockerfile): ?int { $dockerfile_array = explode("\n", $dockerfile); $found_exposed_port = null; foreach ($dockerfile_array as $line) { $line_str = str($line)->trim(); if ($line_str->startsWith('EXPOSE')) { $found_exposed_port = $line_str->replace('EXPOSE', '')->trim(); break; } } if ($found_exposed_port) { return (int) $found_exposed_port->value(); } return null; } function defaultDatabaseLabels($database) { $labels = collect([]); $labels->push('coolify.managed=true'); $labels->push('coolify.type=database'); $labels->push('coolify.databaseUuid='.$database->uuid); $labels->push('coolify.resourceName='.Str::slug($database->name)); $labels->push('coolify.serviceName='.Str::slug($database->name)); $labels->push('coolify.projectName='.Str::slug($database->project()->name)); $labels->push('coolify.environmentName='.Str::slug($database->environment->name)); $labels->push('coolify.database.subType='.$database->type()); return $labels; } function defaultLabels(string $uuid, $name, string $projectName, string $resourceName, string $environment, $pull_request_id = 0, string $type = 'application', $subType = null, ?string $subUuid = null, $subName = null) { $labels = collect([]); $labels->push('coolify.managed=true'); $labels->push('coolify.version='.config('constants.coolify.version')); $labels->push('coolify.'.$type.'Uuid='.$uuid); $labels->push("coolify.type=$type"); $labels->push('coolify.name='.Str::slug($name)); $labels->push('coolify.resourceName='.Str::slug($resourceName)); $labels->push('coolify.projectName='.Str::slug($projectName)); $labels->push('coolify.serviceName='.Str::slug($subName ?? $resourceName)); $labels->push('coolify.environmentName='.Str::slug($environment)); $labels->push('coolify.pullRequestId='.$pull_request_id); if ($type === 'service') { $subUuid && $labels->push('coolify.service.subUuid='.$subUuid); $subType && $labels->push('coolify.service.subType='.$subType); $subName && $labels->push('coolify.service.subName='.Str::slug($subName)); } return $labels; } function generateServiceSpecificFqdns(ServiceApplication|Application $resource) { if ($resource->getMorphClass() === ServiceApplication::class) { $uuid = data_get($resource, 'uuid'); $server = data_get($resource, 'service.server'); $environment_variables = data_get($resource, 'service.environment_variables'); $type = $resource->serviceType(); } elseif ($resource->getMorphClass() === Application::class) { $uuid = data_get($resource, 'uuid'); $server = data_get($resource, 'destination.server'); $environment_variables = data_get($resource, 'environment_variables'); $type = $resource->serviceType(); } if (is_null($server) || is_null($type)) { return collect([]); } $variables = collect($environment_variables); $payload = collect([]); switch ($type) { case $type?->contains(['minio', 'pgsty/silo']): $MINIO_BROWSER_REDIRECT_URL = $variables->where('key', 'MINIO_BROWSER_REDIRECT_URL')->first(); $MINIO_SERVER_URL = $variables->where('key', 'MINIO_SERVER_URL')->first(); if (is_null($MINIO_BROWSER_REDIRECT_URL) || is_null($MINIO_SERVER_URL)) { return collect([]); } if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) { $MINIO_BROWSER_REDIRECT_URL->update([ 'value' => generateUrl(server: $server, random: 'console-'.$uuid, forceHttps: true), ]); } if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) { $MINIO_SERVER_URL->update([ 'value' => generateUrl(server: $server, random: 'minio-'.$uuid, forceHttps: true), ]); } $payload = collect([ $MINIO_BROWSER_REDIRECT_URL->value.':9001', $MINIO_SERVER_URL->value.':9000', ]); break; case $type?->contains('logto'): $LOGTO_ENDPOINT = $variables->where('key', 'LOGTO_ENDPOINT')->first(); $LOGTO_ADMIN_ENDPOINT = $variables->where('key', 'LOGTO_ADMIN_ENDPOINT')->first(); if (is_null($LOGTO_ENDPOINT) || is_null($LOGTO_ADMIN_ENDPOINT)) { return collect([]); } if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) { $LOGTO_ENDPOINT->update([ 'value' => generateUrl(server: $server, random: 'logto-'.$uuid), ]); } if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) { $LOGTO_ADMIN_ENDPOINT->update([ 'value' => generateUrl(server: $server, random: 'logto-admin-'.$uuid), ]); } $payload = collect([ $LOGTO_ENDPOINT->value.':3001', $LOGTO_ADMIN_ENDPOINT->value.':3002', ]); break; case $type?->contains('garage'): $GARAGE_S3_API_URL = $variables->where('key', 'GARAGE_S3_API_URL')->first(); $GARAGE_WEB_URL = $variables->where('key', 'GARAGE_WEB_URL')->first(); $GARAGE_ADMIN_URL = $variables->where('key', 'GARAGE_ADMIN_URL')->first(); if (is_null($GARAGE_S3_API_URL) || is_null($GARAGE_WEB_URL) || is_null($GARAGE_ADMIN_URL)) { return collect([]); } if (str($GARAGE_S3_API_URL->value ?? '')->isEmpty()) { $GARAGE_S3_API_URL->update([ 'value' => generateUrl(server: $server, random: 's3-'.$uuid, forceHttps: true), ]); } if (str($GARAGE_WEB_URL->value ?? '')->isEmpty()) { $GARAGE_WEB_URL->update([ 'value' => generateUrl(server: $server, random: 'web-'.$uuid, forceHttps: true), ]); } if (str($GARAGE_ADMIN_URL->value ?? '')->isEmpty()) { $GARAGE_ADMIN_URL->update([ 'value' => generateUrl(server: $server, random: 'admin-'.$uuid, forceHttps: true), ]); } $payload = collect([ $GARAGE_S3_API_URL->value.':3900', $GARAGE_WEB_URL->value.':3902', $GARAGE_ADMIN_URL->value.':3903', ]); break; } return $payload; } /** * @param Collection|null $noindex_domains */ function isNoindexDomain(string $domain, ?Collection $noindex_domains): bool { if (blank($noindex_domains)) { return false; } // Flags are stored without a port, so compare portless: https://app.example.com:3000 matches https://app.example.com. $normalize = fn (string $url) => DomainPortOverrides::withoutPort(ValidationPatterns::normalizeApplicationDomainUrl($url)); return $noindex_domains ->map($normalize) ->contains($normalize($domain)); } /** * Caddy site address of a domain. Maintenance blocks in the base Caddyfile must use the same * address as the labels, so caddy-docker-proxy merges them into one site block. */ function caddySiteAddress(string $scheme, string $host, bool $isForceHttpsEnabled): string { if ($scheme === 'https' && ! $isForceHttpsEnabled) { return "http://{$host}, https://{$host}"; } return "{$scheme}://{$host}"; } function fqdnLabelsForCaddy(string $network, 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, ?string $image = null, string $redirect_direction = 'both', ?string $predefinedPort = null, bool $is_http_basic_auth_enabled = false, ?string $http_basic_auth_username = null, ?string $http_basic_auth_password = null, ?Collection $noindex_domains = null, bool $is_traffic_analytics_enabled = false, array $domainPortOverrides = [], bool $supports_log_append = false, bool $supports_basic_auth_directive = false, ?int $http_basic_auth_bcrypt_cost = null, array $http_basic_auth_argon2id_options = [], bool $is_permanent_redirect_enabled = false) { $labels = collect([]); if ($serviceLabels) { $labels->push("caddy_ingress_network={$uuid}"); } else { $labels->push("caddy_ingress_network={$network}"); } $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; if ($is_http_basic_auth_enabled) { $hashedPassword = $http_basic_auth_argon2id_options ? password_hash($http_basic_auth_password, PASSWORD_ARGON2ID, $http_basic_auth_argon2id_options) : password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]); } $trafficAppKey = caddyTrafficAppKey($uuid, $service_name); foreach ($domains as $loop => $domain) { $url = Url::fromString($domain); $host = $url->getHost(); $path = $url->getPath(); $host_without_www = str($host)->replace('www.', ''); $schema = $url->getScheme(); $siteAddress = caddySiteAddress($schema, $host, $is_force_https_enabled); $portlessDomain = ServiceApplication::withoutPort($domain); $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); $handle = 'handle_path'; if (! $is_stripprefix_enabled) { $handle = 'handle'; } if (is_null($port) && ! is_null($onlyPort)) { $port = $onlyPort; } if (is_null($port) && $predefinedPort) { $port = $predefinedPort; } $labels->push("caddy_{$loop}={$siteAddress}"); if (isNoindexDomain($domain, $noindex_domains)) { // Caddy's header directive takes either inline arguments or a block, // never both, so -Server has to move into the block alongside it. $labels->push("caddy_{$loop}.header.0_-Server="); $labels->push("caddy_{$loop}.header.1_X-Robots-Tag=\"noindex, nofollow\""); } else { $labels->push("caddy_{$loop}.header=-Server"); } $labels->push("caddy_{$loop}.try_files={path} /index.html /index.php"); if ($port) { $labels->push("caddy_{$loop}.{$handle}.{$loop}_reverse_proxy={{upstreams $port}}"); } else { $labels->push("caddy_{$loop}.{$handle}.{$loop}_reverse_proxy={{upstreams}}"); } $labels->push("caddy_{$loop}.{$handle}={$path}*"); if ($is_gzip_enabled) { $labels->push("caddy_{$loop}.encode=zstd gzip"); } $redirect_schema = $is_force_https_enabled ? $schema : '{scheme}'; $redirect_code = $is_permanent_redirect_enabled ? ' permanent' : ''; if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) { $labels->push("caddy_{$loop}.redir={$redirect_schema}://www.{$host}{uri}{$redirect_code}"); } if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) { $labels->push("caddy_{$loop}.redir={$redirect_schema}://{$host_without_www}{uri}{$redirect_code}"); } if ($is_http_basic_auth_enabled) { // Caddy 2.8 renamed basicauth to basic_auth; see Server::caddySupportsBasicAuthDirective(). $basicAuthDirective = $supports_basic_auth_directive ? 'basic_auth' : 'basicauth'; if ($http_basic_auth_argon2id_options) { $labels->push("caddy_{$loop}.{$basicAuthDirective}=argon2id"); } $labels->push("caddy_{$loop}.{$basicAuthDirective}.{$http_basic_auth_username}=\"{$hashedPassword}\""); } if ($is_traffic_analytics_enabled) { $labels->push("caddy_{$loop}.log.output=file /traffic/access.log"); // Explicit lumberjack roll options so the access log doesn't grow unbounded // (Caddy's defaults are undocumented). caddy-docker-proxy renders these dotted // keys as a nested block: output file /traffic/access.log { roll_size 20MiB; roll_keep 5; roll_keep_for 168h }. // Rotation is rename-based, which is safe for Sentinel's tailer (it reopens on inode change). $labels->push("caddy_{$loop}.log.output.roll_size=20MiB"); $labels->push("caddy_{$loop}.log.output.roll_keep=5"); $labels->push("caddy_{$loop}.log.output.roll_keep_for=168h"); $labels->push("caddy_{$loop}.log.format=json"); // Only Caddy 2.8+ knows log_append; see Server::caddySupportsLogAppend(). if ($supports_log_append) { $labels->push("caddy_{$loop}.log_append=coolify_app_id {$trafficAppKey}"); } } } return $labels->sort(); } function firstDockerComposeServicePort(mixed $service): ?int { $portDefinitions = collect(data_get($service, 'expose', [])) ->merge(data_get($service, 'ports', [])); foreach ($portDefinitions as $definition) { $protocol = is_array($definition) ? data_get($definition, 'protocol', 'tcp') : (str_contains((string) $definition, '/') ? str((string) $definition)->afterLast('/')->value() : 'tcp'); if ($protocol !== 'tcp') { continue; } $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 { return dockerComposeServicePorts($compose, $serviceName)[0] ?? null; } function dockerComposeServicePorts(?string $compose, ?string $serviceName): array { if (blank($compose) || blank($serviceName)) { return []; } try { $services = data_get(parseDockerComposeYaml($compose), 'services', []); } catch (Throwable) { return []; } $service = is_array($services) ? ($services[$serviceName] ?? []) : []; return collect(data_get($service, 'expose', [])) ->merge(data_get($service, 'ports', [])) ->map(fn ($definition) => firstDockerComposeServicePort(['expose' => [$definition]])) ->filter(fn ($port) => $port !== null) ->unique()->values()->all(); } /** * Traefik matches an IPv6 host without brackets: Host(`[2a01:4f8::1]`) never matches a request, * Host(`2a01:4f8::1`) does. Labels that were saved with brackets get them removed here. */ function traefikHostRulesWithoutIpv6Brackets(Collection $labels): Collection { return $labels->map(fn ($label) => is_string($label) ? preg_replace_callback( '/Host\(`\[([0-9A-Fa-f:.]+)\]`\)/', fn (array $matches): string => filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false ? "Host(`{$matches[1]}`)" : $matches[0], $label, ) : $label); } 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 = [], ?string $certificate_resolver = 'letsencrypt', ?int $http_basic_auth_bcrypt_cost = null, bool $is_permanent_redirect_enabled = false) { $labels = collect([]); $labels->push('traefik.enable=true'); if ($is_gzip_enabled) { // Not named `gzip`: containers that are not redeployed still declare the old `gzip` // definition, and Traefik drops a middleware with conflicting definitions (#5076). $labels->push('traefik.http.middlewares.gzip-sse.compress.excludedcontenttypes=text/event-stream'); } $labels->push('traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https'); // Every container declares the shared middleware, so a permanent variant needs its own name. $redirect_to_https_name = 'redirect-to-https'; if ($is_permanent_redirect_enabled) { $redirect_to_https_name = 'redirect-to-https-permanent'; $labels->push('traefik.http.middlewares.redirect-to-https-permanent.redirectscheme.scheme=https'); $labels->push('traefik.http.middlewares.redirect-to-https-permanent.redirectscheme.permanent=true'); } $redirect_permanent = $is_permanent_redirect_enabled ? 'true' : 'false'; $is_http_basic_auth_enabled = $is_http_basic_auth_enabled && $http_basic_auth_username !== null && $http_basic_auth_password !== null; $http_basic_auth_label = "http-basic-auth-{$uuid}"; if ($is_http_basic_auth_enabled) { $hashedPassword = password_hash($http_basic_auth_password, PASSWORD_BCRYPT, ['cost' => $http_basic_auth_bcrypt_cost ?? 10]); } if ($is_http_basic_auth_enabled) { $labels->push("traefik.http.middlewares.{$http_basic_auth_label}.basicauth.users={$http_basic_auth_username}:{$hashedPassword}"); } $middlewares_from_labels = collect([]); if ($serviceLabels) { $middlewares_from_labels = $serviceLabels->map(function ($item) { // Handle array values from YAML parsing (e.g., "traefik.enable: true" becomes an array) if (is_array($item)) { // Convert array to string format "key=value" $key = collect($item)->keys()->first(); $value = collect($item)->values()->first(); $item = "$key=$value"; } if (! is_string($item)) { return null; } if (preg_match('/traefik\.http\.middlewares\.(.*?)(\.|$)/', $item, $matches)) { return $matches[1]; } if (preg_match('/coolify\.traefik\.middlewares=(.*)/', $item, $matches)) { return explode(',', $matches[1]); } return null; })->flatten() ->filter() ->unique(); } foreach ($domains as $loop => $domain) { try { if ($generate_unique_uuid) { $uuid = new_public_id(); } $url = Url::fromString($domain); $host = $url->getHost(); // Traefik matches an IPv6 Host header without brackets: Host(`[2a01:4f8::1]`) never matches. $traefikHost = trim($host, '[]'); $path = $url->getPath(); $schema = $url->getScheme(); $portlessDomain = ServiceApplication::withoutPort($domain); $port = $url->getPort() ?? ($domainPortOverrides[$portlessDomain] ?? null); if (is_null($port) && ! is_null($onlyPort)) { $port = $onlyPort; } $http_label = "http-{$loop}-{$uuid}"; $https_label = "https-{$loop}-{$uuid}"; if ($service_name) { // Dots in service names split Traefik label paths; hash avoids api.test/api-test collisions. $safeServiceName = traefikSafeServiceNameSegment($service_name); $http_label = "http-{$loop}-{$uuid}-{$safeServiceName}"; $https_label = "https-{$loop}-{$uuid}-{$safeServiceName}"; } if (str($image)->contains('ghost')) { $labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.regex=^{$path}/(.*)"); $labels->push("traefik.http.middlewares.redir-ghost-{$uuid}.redirectregex.replacement=/$1"); $labels->push("caddy_{$loop}.handle_path.{$loop}_redir-ghost-{$uuid}.handler=rewrite"); $labels->push("caddy_{$loop}.handle_path.{$loop}_redir-ghost-{$uuid}.rewrite.regexp=^{$path}/(.*)"); $labels->push("caddy_{$loop}.handle_path.{$loop}_redir-ghost-{$uuid}.rewrite.replacement=/$1"); } $noindex_name = "{$loop}-{$uuid}-noindex"; $is_noindex = isNoindexDomain($domain, $noindex_domains); if ($is_noindex) { $labels->push("traefik.http.middlewares.{$noindex_name}.headers.customresponseheaders.X-Robots-Tag=noindex, nofollow"); } $to_www_name = "{$loop}-{$uuid}-to-www"; $to_non_www_name = "{$loop}-{$uuid}-to-non-www"; $redirect_capture_prefix = $escape_redirect_replacement_for_compose ? '$$' : '$'; $redirect_to_non_www = [ "traefik.http.middlewares.{$to_non_www_name}.redirectregex.regex=^(http|https)://www\.(.+)", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://{$redirect_capture_prefix}{2}", "traefik.http.middlewares.{$to_non_www_name}.redirectregex.permanent={$redirect_permanent}", ]; $redirect_to_www = [ "traefik.http.middlewares.{$to_www_name}.redirectregex.regex=^(http|https)://(?:www\.)?(.+)", "traefik.http.middlewares.{$to_www_name}.redirectregex.replacement={$redirect_capture_prefix}{1}://www.{$redirect_capture_prefix}{2}", "traefik.http.middlewares.{$to_www_name}.redirectregex.permanent={$redirect_permanent}", ]; if ($schema === 'https') { // Set labels for https $labels->push("traefik.http.routers.{$https_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$https_label}.entryPoints=https"); if ($port) { $labels->push("traefik.http.routers.{$https_label}.service={$https_label}"); $labels->push("traefik.http.services.{$https_label}.loadbalancer.server.port=$port"); } if ($path !== '/') { // Middleware handling $middlewares = collect([]); if ($is_stripprefix_enabled && ! str($image)->contains('ghost')) { $labels->push("traefik.http.middlewares.{$https_label}-stripprefix.stripprefix.prefixes={$path}"); $middlewares->push("{$https_label}-stripprefix"); } if ($is_gzip_enabled) { $middlewares->push('gzip-sse'); } if (str($image)->contains('ghost')) { $middlewares->push("redir-ghost-{$uuid}"); } if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_non_www); $middlewares->push($to_non_www_name); } if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_www); $middlewares->push($to_www_name); } if ($is_http_basic_auth_enabled) { $middlewares->push($http_basic_auth_label); } if ($is_noindex) { $middlewares->push($noindex_name); } $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { $middlewares->push($middleware_name); }); if ($middlewares->isNotEmpty()) { $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}"); } } else { $middlewares = collect([]); if ($is_gzip_enabled) { $middlewares->push('gzip-sse'); } if (str($image)->contains('ghost')) { $middlewares->push("redir-ghost-{$uuid}"); } if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_non_www); $middlewares->push($to_non_www_name); } if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_www); $middlewares->push($to_www_name); } if ($is_http_basic_auth_enabled) { $middlewares->push($http_basic_auth_label); } if ($is_noindex) { $middlewares->push($noindex_name); } $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { $middlewares->push($middleware_name); }); if ($middlewares->isNotEmpty()) { $labels->push("traefik.http.routers.{$https_label}.middlewares={$middlewares->join(',')}"); } } $labels->push("traefik.http.routers.{$https_label}.tls=true"); if (filled($certificate_resolver)) { $labels->push("traefik.http.routers.{$https_label}.tls.certresolver={$certificate_resolver}"); } // Set labels for http (redirect to https) $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$http_label}.entryPoints=http"); if ($port) { $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); $labels->push("traefik.http.routers.{$http_label}.service={$http_label}"); } if ($is_force_https_enabled) { $httpMiddlewares = collect([]); if ($is_noindex) { $httpMiddlewares->push($noindex_name); } $httpMiddlewares->push($redirect_to_https_name); } else { $httpMiddlewares = $middlewares; } if ($httpMiddlewares->isNotEmpty()) { $labels->push("traefik.http.routers.{$http_label}.middlewares={$httpMiddlewares->join(',')}"); } } else { // Set labels for http $labels->push("traefik.http.routers.{$http_label}.rule=Host(`{$traefikHost}`) && PathPrefix(`{$path}`)"); $labels->push("traefik.http.routers.{$http_label}.entryPoints=http"); if ($port) { $labels->push("traefik.http.services.{$http_label}.loadbalancer.server.port=$port"); $labels->push("traefik.http.routers.{$http_label}.service={$http_label}"); } if ($path !== '/') { $middlewares = collect([]); if ($is_stripprefix_enabled && ! str($image)->contains('ghost')) { $labels->push("traefik.http.middlewares.{$http_label}-stripprefix.stripprefix.prefixes={$path}"); $middlewares->push("{$http_label}-stripprefix"); } if ($is_gzip_enabled) { $middlewares->push('gzip-sse'); } if (str($image)->contains('ghost')) { $middlewares->push("redir-ghost-{$uuid}"); } if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_non_www); $middlewares->push($to_non_www_name); } if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_www); $middlewares->push($to_www_name); } if ($is_http_basic_auth_enabled) { $middlewares->push($http_basic_auth_label); } if ($is_noindex) { $middlewares->push($noindex_name); } $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { $middlewares->push($middleware_name); }); if ($middlewares->isNotEmpty()) { $middlewares = $middlewares->join(','); $labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares}"); } } else { $middlewares = collect([]); if ($is_gzip_enabled) { $middlewares->push('gzip-sse'); } if (str($image)->contains('ghost')) { $middlewares->push("redir-ghost-{$uuid}"); } if ($redirect_direction === 'non-www' && str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_non_www); $middlewares->push($to_non_www_name); } if ($redirect_direction === 'www' && ! str($host)->startsWith('www.')) { $labels = $labels->merge($redirect_to_www); $middlewares->push($to_www_name); } if ($is_http_basic_auth_enabled) { $middlewares->push($http_basic_auth_label); } if ($is_noindex) { $middlewares->push($noindex_name); } $middlewares_from_labels->each(function ($middleware_name) use ($middlewares) { $middlewares->push($middleware_name); }); if ($middlewares->isNotEmpty()) { $middlewares = $middlewares->join(','); $labels->push("traefik.http.routers.{$http_label}.middlewares={$middlewares}"); } } } } catch (Throwable) { continue; } } return $labels->sort(); } function generateLabelsApplication(Application $application, ?ApplicationPreview $preview = null): array { $ports = $application->settings->is_static ? [80] : $application->ports_exposes_array; $onlyPort = null; if (count($ports) > 0) { $onlyPort = $ports[0]; } $pull_request_id = data_get($preview, 'pull_request_id', 0); $appUuid = $application->uuid; if ($pull_request_id !== 0) { $appUuid = $appUuid.'-pr-'.$pull_request_id; } $labels = collect([]); $httpBasicAuthArgon2idOptions = $application->usesArgon2idBasicAuth() ? [ 'memory_cost' => $application->http_basic_auth_argon2id_memory_cost, 'time_cost' => $application->http_basic_auth_argon2id_time_cost, ] : []; if ($pull_request_id === 0) { if ($application->fqdn) { $domains = str(data_get($application, 'fqdn'))->explode(','); $noindexDomains = $application->noindexDomains(); $shouldGenerateLabelsExactly = $application->destination->server->settings->generate_exact_labels; if ($shouldGenerateLabelsExactly) { switch ($application->destination->server->proxyType()) { case ProxyTypes::TRAEFIK->value: $labels = $labels->merge(fqdnLabelsForTraefik( uuid: $appUuid, domains: $domains, onlyPort: $onlyPort, is_force_https_enabled: $application->isForceHttpsEnabled(), is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(), is_gzip_enabled: $application->isGzipEnabled(), is_stripprefix_enabled: $application->isStripprefixEnabled(), redirect_direction: $application->redirect, is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, domainPortOverrides: $application->domain_port_overrides ?? [], certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); break; case ProxyTypes::CADDY->value: $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, uuid: $appUuid, domains: $domains, onlyPort: $onlyPort, is_force_https_enabled: $application->isForceHttpsEnabled(), is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(), is_gzip_enabled: $application->isGzipEnabled(), is_stripprefix_enabled: $application->isStripprefixEnabled(), redirect_direction: $application->redirect, is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(), domainPortOverrides: $application->domain_port_overrides ?? [], )); break; } } else { $labels = $labels->merge(fqdnLabelsForTraefik( uuid: $appUuid, domains: $domains, onlyPort: $onlyPort, is_force_https_enabled: $application->isForceHttpsEnabled(), is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(), is_gzip_enabled: $application->isGzipEnabled(), is_stripprefix_enabled: $application->isStripprefixEnabled(), redirect_direction: $application->redirect, is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $application->domain_port_overrides ?? [], certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, uuid: $appUuid, domains: $domains, onlyPort: $onlyPort, is_force_https_enabled: $application->isForceHttpsEnabled(), is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(), is_gzip_enabled: $application->isGzipEnabled(), is_stripprefix_enabled: $application->isStripprefixEnabled(), redirect_direction: $application->redirect, is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(), domainPortOverrides: $application->domain_port_overrides ?? [], )); } } } else { if (data_get($preview, 'fqdn')) { $domains = str(data_get($preview, 'fqdn'))->explode(','); } else { $domains = collect([]); } // Previews are ephemeral: every domain is noindex. $noindexDomains = $domains; $shouldGenerateLabelsExactly = $application->destination->server->settings->generate_exact_labels; if ($shouldGenerateLabelsExactly) { switch ($application->destination->server->proxyType()) { case ProxyTypes::TRAEFIK->value: $labels = $labels->merge(fqdnLabelsForTraefik( uuid: $appUuid, domains: $domains, onlyPort: $onlyPort, is_force_https_enabled: $application->isForceHttpsEnabled(), is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(), is_gzip_enabled: $application->isGzipEnabled(), is_stripprefix_enabled: $application->isStripprefixEnabled(), is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); break; case ProxyTypes::CADDY->value: $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, uuid: $appUuid, domains: $domains, onlyPort: $onlyPort, is_force_https_enabled: $application->isForceHttpsEnabled(), is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(), is_gzip_enabled: $application->isGzipEnabled(), is_stripprefix_enabled: $application->isStripprefixEnabled(), is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(), domainPortOverrides: $preview->domain_port_overrides ?? [], )); break; } } else { $labels = $labels->merge(fqdnLabelsForTraefik( uuid: $appUuid, domains: $domains, onlyPort: $onlyPort, is_force_https_enabled: $application->isForceHttpsEnabled(), is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(), is_gzip_enabled: $application->isGzipEnabled(), is_stripprefix_enabled: $application->isStripprefixEnabled(), is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, noindex_domains: $noindexDomains, escape_redirect_replacement_for_compose: false, domainPortOverrides: $preview->domain_port_overrides ?? [], certificate_resolver: $application->destination->server->traefikCertificateResolver($application->settings?->traefik_certificate_resolver), )); $labels = $labels->merge(fqdnLabelsForCaddy( network: $application->destination->network, uuid: $appUuid, domains: $domains, onlyPort: $onlyPort, is_force_https_enabled: $application->isForceHttpsEnabled(), is_permanent_redirect_enabled: $application->isPermanentRedirectEnabled(), is_gzip_enabled: $application->isGzipEnabled(), is_stripprefix_enabled: $application->isStripprefixEnabled(), is_http_basic_auth_enabled: $application->is_http_basic_auth_enabled, http_basic_auth_username: $application->http_basic_auth_username, http_basic_auth_password: $application->http_basic_auth_password, http_basic_auth_bcrypt_cost: $application->http_basic_auth_bcrypt_cost, http_basic_auth_argon2id_options: $httpBasicAuthArgon2idOptions, noindex_domains: $noindexDomains, is_traffic_analytics_enabled: $application->destination->server->isTrafficAnalyticsEnabled(), supports_log_append: $application->destination->server->caddySupportsLogAppend(), supports_basic_auth_directive: $application->destination->server->caddySupportsBasicAuthDirective(), domainPortOverrides: $preview->domain_port_overrides ?? [], )); } } return $labels->all(); } function isDatabaseImage(?string $image = null, ?array $serviceConfig = null) { if (is_null($image)) { return false; } $image = str($image); if ($image->contains(':')) { $image = str($image); } else { $image = str($image)->append(':latest'); } $imageName = $image->before(':'); // Extract base image name (ignore registry prefix) // Examples: // docker.io/library/postgres -> postgres // ghcr.io/postgrest/postgrest -> postgrest // postgres -> postgres // postgrest/postgrest -> postgrest $baseImageName = $imageName; if (str($imageName)->contains('/')) { $baseImageName = str($imageName)->afterLast('/'); } // Check if base image name exactly matches a known database image $isKnownDatabase = false; foreach (DATABASE_DOCKER_IMAGES as $database_docker_image) { // Extract base name from database pattern for comparison $databaseBaseName = str($database_docker_image)->contains('/') ? str($database_docker_image)->afterLast('/') : $database_docker_image; if ($baseImageName == $databaseBaseName) { $isKnownDatabase = true; break; } } // If no database pattern found, it's definitely not a database if (! $isKnownDatabase) { return false; } // If we have service configuration, use additional context to make better decisions if (! is_null($serviceConfig)) { return isDatabaseImageWithContext($imageName, $serviceConfig); } // Fallback to original behavior for backward compatibility return $isKnownDatabase; } function isDatabaseImageWithContext(string $imageName, array $serviceConfig): bool { // Known application images that contain database names but are not databases $knownApplicationPatterns = [ // SuperTokens authentication 'supertokens/supertokens-mysql', 'supertokens/supertokens-postgresql', 'supertokens/supertokens-mongodb', 'registry.supertokens.io/supertokens/supertokens-mysql', 'registry.supertokens.io/supertokens/supertokens-postgresql', 'registry.supertokens.io/supertokens/supertokens-mongodb', 'registry.supertokens.io/supertokens', // Analytics and BI tools 'metabase/metabase', // Uses databases but is not a database 'amancevice/superset', // Uses databases but is not a database 'nocodb/nocodb', // Uses databases but is not a database 'ghcr.io/umami-software/umami', // Web analytics with postgresql variant // Secret management 'infisical/infisical', // Secret management with postgres variant // Development tools 'postgrest/postgrest', // REST API for PostgreSQL 'supabase/postgres-meta', // PostgreSQL metadata API 'bluewaveuptime/uptime_redis', // Uptime monitoring with Redis ]; foreach ($knownApplicationPatterns as $pattern) { if (str($imageName)->contains($pattern)) { return false; } } // Check for database-like ports (common database ports indicate it's likely a database) $databasePorts = ['3306', '5432', '27017', '6379', '8086', '9200', '7687', '8123']; $ports = data_get($serviceConfig, 'ports', []); $hasStandardDbPort = false; if (is_array($ports)) { foreach ($ports as $port) { $portStr = is_string($port) ? $port : (string) $port; foreach ($databasePorts as $dbPort) { if (str($portStr)->contains($dbPort)) { $hasStandardDbPort = true; break 2; } } } } // Check environment variables for database-specific patterns $environment = data_get($serviceConfig, 'environment', []); $hasDbEnvVars = false; $hasAppEnvVars = false; if (is_array($environment)) { foreach ($environment as $env) { $envStr = is_string($env) ? $env : (string) $env; $envUpper = strtoupper($envStr); // Database-specific environment variables if (str($envUpper)->contains(['MYSQL_ROOT_PASSWORD', 'POSTGRES_PASSWORD', 'MONGO_INITDB_ROOT_PASSWORD', 'REDIS_PASSWORD'])) { $hasDbEnvVars = true; } // Application-specific environment variables if (str($envUpper)->contains(['SERVICE_FQDN', 'API_KEYS', 'APP_', 'APPLICATION_'])) { $hasAppEnvVars = true; } } } // Check healthcheck patterns $healthcheck = data_get($serviceConfig, 'healthcheck.test', []); $hasDbHealthcheck = false; $hasAppHealthcheck = false; if (is_array($healthcheck)) { $healthcheckStr = implode(' ', $healthcheck); } else { $healthcheckStr = is_string($healthcheck) ? $healthcheck : ''; } if (! empty($healthcheckStr)) { $healthcheckUpper = strtoupper($healthcheckStr); // Database-specific healthcheck patterns if (str($healthcheckUpper)->contains(['PG_ISREADY', 'MYSQLADMIN PING', 'MONGO', 'REDIS-CLI PING'])) { $hasDbHealthcheck = true; } // Application-specific healthcheck patterns (HTTP endpoints) if (str($healthcheckUpper)->contains(['CURL', 'WGET', 'HTTP://', 'HTTPS://', '/HEALTH', '/API/', '/HELLO'])) { $hasAppHealthcheck = true; } } // Check if service depends on other database services $dependsOn = data_get($serviceConfig, 'depends_on', []); $dependsOnDatabases = false; if (is_array($dependsOn)) { foreach ($dependsOn as $serviceName => $config) { $serviceNameStr = is_string($serviceName) ? $serviceName : (string) $serviceName; if (str($serviceNameStr)->contains(['mysql', 'postgres', 'mongo', 'redis', 'mariadb'])) { $dependsOnDatabases = true; break; } } } // Decision logic: // 1. If it has app-specific patterns and depends on databases, it's likely an application if ($hasAppEnvVars && $dependsOnDatabases) { return false; } // 2. If it has HTTP healthchecks, it's likely an application if ($hasAppHealthcheck) { return false; } // 3. If it has standard database ports AND database healthchecks, it's likely a database if ($hasStandardDbPort && $hasDbHealthcheck) { return true; } // 4. If it has database environment variables, it's likely a database if ($hasDbEnvVars) { return true; } // 5. Default: if it depends on databases but doesn't have database characteristics, it's an application if ($dependsOnDatabases) { return false; } // 6. Fallback: assume it's a database if we can't determine otherwise return true; } 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 "-" (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', '--sysctl', '--ulimit', '--device', '--shm-size', '--dns', '--tmpfs', ]); $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 $value = preg_replace_callback( '/"((?:\\\\.|[^"])*)"|\'([^\']*)\'/', fn ($quoted) => isset($quoted[2]) ? $quoted[2] : preg_replace('/\\\\(["$`\\\\])/', '$1', $quoted[1]), $match[2] ?? '' ); if ($option === '--gpus') { // 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; // Match --entrypoint=value or --entrypoint value // Handle quoted strings with escaped quotes: --entrypoint "python -c \"print('hi')\"" // Pattern matches: double-quoted (with escapes), single-quoted (with escapes), or unquoted values if (preg_match( '/--entrypoint(?:=|\s+)(?"(?:\\\\.|[^"])*"|\'(?:\\\\.|[^\'])*\'|[^\s]+)/', $custom_docker_run_options, $entrypoint_matches )) { $rawValue = $entrypoint_matches['raw']; // Handle double-quoted strings: strip quotes and unescape special characters if (str_starts_with($rawValue, '"') && str_ends_with($rawValue, '"')) { $inner = substr($rawValue, 1, -1); // Unescape backslash sequences: \" \$ \` \\ $value = preg_replace('/\\\\(["$`\\\\])/', '$1', $inner); } elseif (str_starts_with($rawValue, "'") && str_ends_with($rawValue, "'")) { // Handle single-quoted strings: just strip quotes (no unescaping per shell rules) $value = substr($rawValue, 1, -1); } else { // Handle unquoted values $value = $rawValue; } } if ($value && trim($value) !== '') { $options[$option][] = $value; $options[$option] = array_values(array_unique($options[$option])); } continue; } 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'); 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 foreach ($options as $option => $value) { if (! data_get($mapping, $option)) { continue; } if ($option === '--ulimit') { $ulimits = collect([]); collect($value)->map(function ($ulimit) use ($ulimits) { if (! str_contains($ulimit, '=')) { return; } $ulimit = explode('=', $ulimit, 2); $type = $ulimit[0]; $limits = explode(':', $ulimit[1]); if (count($limits) == 2) { $soft_limit = $limits[0]; $hard_limit = $limits[1]; $ulimits->put($type, [ 'soft' => $soft_limit, 'hard' => $hard_limit, ]); } else { // Compose needs both soft and hard in the mapping form, so a single limit is set directly $ulimits->put($type, $ulimit[1]); } }); if ($ulimits->isNotEmpty()) { $compose_options->put($mapping[$option], $ulimits); } } 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 // Keep it as a string for simplicity - docker compose will handle it $compose_options->put($mapping[$option], $value[0]); } } elseif ($option === '--gpus') { $compose_options->put('deploy', [ 'resources' => [ 'reservations' => [ 'devices' => [$value], ], ], ]); } else { if ($list_options->contains($option)) { if ($compose_options->has($mapping[$option])) { $compose_options->put($mapping[$option], $options->get($mapping[$option]).','.$value); } else { $compose_options->put($mapping[$option], $value); } continue; } else { $compose_options->put($mapping[$option], $value); continue; } } } // Short interactive/TTY flags (-i, -t, -it, -ti) and their long forms are not matched by // the long-option regex above. Map them to compose so a single-image app can keep STDIN // open (docker -i) and/or allocate a TTY (docker -t) — required for `docker attach` to an // interactive main process, e.g. game-server consoles. // Keep quoted option values together so flags inside an entrypoint are not options. preg_match_all('/(?:[^\\s\'"\\\\]+|\\\\.|"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\')+/', (string) $custom_docker_run_options, $tokens); foreach ($tokens[0] as $token) { if ($token === '--interactive') { $compose_options->put('stdin_open', true); } elseif ($token === '--tty') { $compose_options->put('tty', true); } elseif (preg_match('/^-[dit]+$/', $token)) { if (str_contains($token, 'i')) { $compose_options->put('stdin_open', true); } if (str_contains($token, 't')) { $compose_options->put('tty', true); } } } 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; } // convertDockerRunToCompose() maps these outside DOCKER_RUN_OPTION_MAPPING if (preg_match('/^(-[dit]+|--interactive|--tty)$/', $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)); } /** * 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'); $ipv6 = data_get($docker_run_options, 'ip6.0'); data_forget($docker_run_options, 'ip'); data_forget($docker_run_options, 'ip6'); if ($ipv4 || $ipv6) { data_forget($docker_compose['services'][$container_name], 'networks'); } if ($ipv4) { $docker_compose['services'][$container_name]['networks'][$network]['ipv4_address'] = $ipv4; } 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; } /** * Remove Coolify's custom Docker Compose fields from parsed YAML array * * Coolify extends Docker Compose with custom fields that are processed during * parsing and deployment but must be removed before sending to Docker. * * Custom fields: * - exclude_from_hc (service-level): Exclude service from health check monitoring * - content (volume-level): Auto-create file with specified content during init * - isDirectory / is_directory (volume-level): Mark bind mount as directory * * @param array $yamlCompose Parsed Docker Compose array * @return array Cleaned Docker Compose array with custom fields removed */ function stripCoolifyCustomFields(array $yamlCompose): array { foreach ($yamlCompose['services'] ?? [] as $serviceName => $service) { // Remove service-level custom fields unset($yamlCompose['services'][$serviceName]['exclude_from_hc']); // Remove volume-level custom fields (only for long syntax - arrays) if (isset($service['volumes'])) { foreach ($service['volumes'] as $volumeName => $volume) { // Skip if volume is string (short syntax like 'db-data:/var/lib/postgresql/data') if (! is_array($volume)) { continue; } unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['content']); unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['isDirectory']); unset($yamlCompose['services'][$serviceName]['volumes'][$volumeName]['is_directory']); } } } return $yamlCompose; } function validateComposeFile(string $compose, int $server_id): string|Throwable { $uuid = Str::random(18); $server = Server::ownedByCurrentTeam()->find($server_id); try { if (! $server) { throw new Exception('Server not found'); } $yaml_compose = parseDockerComposeYaml($compose); // Remove Coolify's custom fields before Docker validation $yaml_compose = stripCoolifyCustomFields($yaml_compose); $base64_compose = base64_encode(Yaml::dump($yaml_compose)); instant_remote_process([ "echo {$base64_compose} | base64 -d | tee /tmp/{$uuid}.yml > /dev/null", "chmod 600 /tmp/{$uuid}.yml", "docker compose -f /tmp/{$uuid}.yml config --no-interpolate --no-path-resolution -q", "rm /tmp/{$uuid}.yml", ], $server); return 'OK'; } catch (Throwable $e) { return $e->getMessage(); } finally { if (filled($server)) { instant_remote_process([ "rm /tmp/{$uuid}.yml", ], $server, throwError: false); } } } function normalizeLogLines(mixed $lines, int $default = 100, int $max = 10000): int|string { if ($lines === 'all') { return 'all'; } $lines = filter_var($lines, FILTER_VALIDATE_INT); if ($lines === -1) { return 'all'; } if ($lines === false || $lines < -1) { return $default; } return min($lines, $max); } function parseLogTimestampFlag(mixed $showTimestamps): bool { return filter_var($showTimestamps, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false; } function buildContainerLogsCommand(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string { $command = "docker logs -n {$lines}"; if ($server->isSwarm()) { $command = "docker service logs -n {$lines}"; } if ($showTimestamps) { $command .= ' --timestamps'; } return "{$command} ".escapeshellarg($container_id).' 2>&1'; } function getContainerLogs(Server $server, string $container_id, int|string $lines = 100, bool $showTimestamps = false): string { $output = instant_remote_process([buildContainerLogsCommand($server, $container_id, $lines, $showTimestamps)], $server); $output = removeAnsiColors($output); return $output; } function escapeEnvVariables($value) { $search = ['\\', "\r", "\t", "\x0", '"', "'"]; $replace = ['\\\\', '\\r', '\\t', '\\0', '\"', "\'"]; return str_replace($search, $replace, $value); } function escapeDollarSign($value) { $search = ['$']; $replace = ['$$']; return str_replace($search, $replace, $value); } /** * Escape a value for use in a bash .env file that will be sourced with 'source' command * Wraps the value in single quotes and escapes any single quotes within the value * * @param string|null $value The value to escape * @return string The escaped value wrapped in single quotes */ function escapeBashEnvValue(?string $value): string { // Handle null or empty values if ($value === null || $value === '') { return "''"; } // Replace single quotes with '\'' (end quote, escaped quote, start quote) // This is the standard way to escape single quotes in bash single-quoted strings $escaped = str_replace("'", "'\\''", $value); // Wrap in single quotes return "'{$escaped}'"; } function escapeComposeEnvFileValue(?string $value, bool $allowInterpolation = false): string { $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value ?? ''); if (! $allowInterpolation) { $escaped = str_replace('$', '\\$', $escaped); } return '"'.$escaped.'"'; } /** * Escape a value for bash double-quoted strings (allows $VAR expansion) * * This function wraps values in double quotes while escaping special characters, * but preserves valid bash variable references like $VAR and ${VAR}. * * @param string|null $value The value to escape * @return string The escaped value wrapped in double quotes */ function escapeBashDoubleQuoted(?string $value): string { // Handle null or empty values if ($value === null || $value === '') { return '""'; } // Step 1: Escape backslashes first (must be done before other escaping) $escaped = str_replace('\\', '\\\\', $value); // Step 2: Escape double quotes $escaped = str_replace('"', '\\"', $escaped); // Step 3: Escape backticks (command substitution) $escaped = str_replace('`', '\\`', $escaped); // Step 4: Escape invalid $ patterns while preserving valid variable references // Valid patterns to keep: // - $VAR_NAME (alphanumeric + underscore, starting with letter or _) // - ${VAR_NAME} (brace expansion) // - $0-$9 (positional parameters) // Invalid patterns to escape: $&, $#, $$, $*, $@, $!, $(, etc. // Match $ followed by anything that's NOT a valid variable start // Valid variable starts: letter, underscore, digit (for $0-$9), or open brace $escaped = preg_replace( '/\$(?![a-zA-Z_0-9{])/', '\\\$', $escaped ); // Preserve pre-escaped dollars inside double quotes: turn \\$ back into \$ // (keeps tests like "path\\to\\file" intact while restoring \$ semantics) $escaped = preg_replace('/\\\\(?=\$)/', '\\\\', $escaped); // Wrap in double quotes return "\"{$escaped}\""; } /** * Generate Docker build arguments from environment variables collection * Returns only keys (no values) since values are sourced from environment via export * * @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' * @return Collection Collection of formatted --build-arg strings (keys only) */ function generateDockerBuildArgs($variables): Collection { $variables = collect($variables); return $variables->map(function ($var) { $key = is_array($var) ? data_get($var, 'key') : $var->key; if (! ValidationPatterns::isValidEnvironmentVariableKey((string) $key)) { throw new InvalidArgumentException('Invalid environment variable key.'); } // Only return the key - Docker will get the value from the environment return '--build-arg '.escapeshellarg((string) $key); }); } /** * Generate Docker environment flags from environment variables collection * * @param Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' * @return string Space-separated environment flags */ function generateDockerEnvFlags($variables): string { $variables = collect($variables); return $variables ->map(function ($var) { $key = is_array($var) ? data_get($var, 'key') : $var->key; $value = is_array($var) ? data_get($var, 'value') : $var->value; if (! ValidationPatterns::isValidEnvironmentVariableKey((string) $key)) { throw new InvalidArgumentException('Invalid environment variable key.'); } $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); if ($isMultiline) { $value = trim($value, "'"); } return '-e '.escapeshellarg("{$key}={$value}"); }) ->implode(' '); } /** * Auto-inject -f and --env-file flags into a docker compose command if not already present * * @param string $command The docker compose command to modify * @param string $composeFilePath The path to the compose file * @param string $envFilePath The path to the .env file * @return string The modified command with injected flags * * @example * Input: "docker compose build" * Output: "docker compose -f ./docker-compose.yml --env-file .env build" */ function injectDockerComposeFlags(string $command, string $composeFilePath, string $envFilePath): string { $dockerComposeReplacement = 'docker compose'; // Add -f flag if not present (checks for both -f and --file with various formats) // Detects: -f path, -f=path, -fpath (concatenated with path chars: . / ~), --file path, --file=path // Note: Uses [.~/]|$ instead of \S to prevent false positives with flags like -foo, -from, -feature if (! preg_match('/(?:^|\s)(?:-f(?:[=\s]|[.\/~]|$)|--file(?:=|\s))/', $command)) { $dockerComposeReplacement .= " -f {$composeFilePath}"; } // Add --env-file flag if not present (checks for --env-file with various formats) // Detects: --env-file path, --env-file=path with any whitespace if (! preg_match('/(?:^|\s)--env-file(?:=|\s)/', $command)) { $dockerComposeReplacement .= " --env-file {$envFilePath}"; } // Replace only first occurrence to avoid modifying comments/strings/chained commands return preg_replace('/docker\s+compose/', $dockerComposeReplacement, $command, 1); } /** * Inject build arguments right after build-related subcommands in docker/docker compose commands. * This ensures build args are only applied to build operations, not to push, pull, up, etc. * * Supports: * - docker compose build * - docker buildx build * - docker builder build * - docker build (legacy) * * Examples: * - Input: "docker compose -f file.yml build" * Output: "docker compose -f file.yml build --build-arg X --build-arg Y" * * - Input: "docker buildx build --platform linux/amd64" * Output: "docker buildx build --build-arg X --build-arg Y --platform linux/amd64" * * - Input: "docker builder build --tag myimage:latest" * Output: "docker builder build --build-arg X --build-arg Y --tag myimage:latest" * * - Input: "docker compose build && docker compose push" * Output: "docker compose build --build-arg X --build-arg Y && docker compose push" * * - Input: "docker compose push" * Output: "docker compose push" (unchanged - no build command found) * * @param string $command The docker command * @param string $buildArgsString The build arguments to inject (e.g., "--build-arg X --build-arg Y") * @return string The modified command with build args injected after build subcommand */ function injectDockerComposeBuildArgs(string $command, string $buildArgsString): string { // Early return if no build args to inject if (empty(trim($buildArgsString))) { return $command; } // Match build-related commands: // - ' builder build' (docker builder build) // - ' buildx build' (docker buildx build) // - ' build' (docker compose build, docker build) // Followed by either: // - whitespace (allowing service names, flags, or any valid arguments) // - end of string ($) // This regex ensures we match build subcommands, not "build" in other contexts // IMPORTANT: Order matters - check longer patterns first (builder build, buildx build) before ' build' $pattern = '/( builder build| buildx build| build)(?=\s|$)/'; // Replace the first occurrence of build command with build command + build-args $modifiedCommand = preg_replace( $pattern, '$1 '.$buildArgsString, $command, 1 // Only replace first occurrence ); return $modifiedCommand ?? $command; } /** * Run a command that uses the Railpack BuildKit builder, then stop the builder when no other * process uses it. An idle buildkitd keeps the memory of earlier builds until it restarts. * * The lock file is in the buildx metadata directory, which all helper containers of a server share. * The script has no `&&`, `||`, pipe, or `$(`, because the sudo parser would put sudo into it. */ function railpackBuilderLockedScript(string $command): string { return 'exec 9>/root/.docker/buildx/coolify-railpack.lock; if flock -s 9; then :; else exit $?; fi; ' ."{$command}; status=\$?; " .'if flock -n -x 9; then DOCKER_CONFIG=/root/.docker docker buildx stop coolify-railpack >/dev/null 2>&1; fi; ' .'exit $status'; } /** * The buildx metadata of a server, as the `docker run -v` source that the deployment helper uses. */ function railpackBuildxMetadataVolume(Server $server): string { return isDev() && $server->isLocalhost() ? 'coolify-buildx' : '$HOME/.docker/buildx'; } /** * Run a command for the Railpack builder from a temporary helper container, then stop the builder * when no build uses it. */ function railpackBuilderHelperCommand(string $buildxMetadataVolume, string $helperImage, string $command): string { $script = railpackBuilderLockedScript($command); return "docker run --rm -v {$buildxMetadataVolume}:/root/.docker/buildx -v /var/run/docker.sock:/var/run/docker.sock {$helperImage} bash -c '{$script}' 2>/dev/null || true"; }