Files
coolify/bootstrap/helpers/docker.php
T
Andras Bacsai f4a1e3e427 fix(proxy): keep Caddy WebSockets open across config reloads
caddy-docker-proxy reloads Caddy whenever any proxied container changes,
and Caddy closes every WebSocket on reload by default. Set
stream_close_delay on app, service, and dashboard reverse_proxy routes.

Closes #7942
2026-11-03 22:06:54 +01:00

2372 lines
102 KiB
PHP

<?php
use App\Enums\ProxyTypes;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\DomainPortOverrides;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
/**
* Stable short hash for compose service names used in Traefik router/service IDs.
* Keeps api.test vs api-test distinct after label-safe normalization.
*/
function traefikServiceNameHash(string $serviceName): string
{
return substr(md5($serviceName), 0, 4);
}
/**
* Label-safe Traefik router/service name segment for a compose service.
* Dots (and other non [a-zA-Z0-9-]) break Docker/Traefik label paths; always append a hash
* so normalized names like api.test and api-test never collide.
*/
function traefikSafeServiceNameSegment(string $serviceName): string
{
$normalized = str($serviceName)
->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<int, TResource> $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<string, int>|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<Collection|array|string> $containerLabels
* @return array<string, int>
*/
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<int, Service> $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<string> $extraFilters Additional `--filter` values, e.g. `label=coolify.pullRequestId=0`.
* @param list<string>|null $legacyExtraFilters Filters for containers without the UUID label; defaults to $extraFilters.
* @return list<string>
*/
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<string> $extraFilters
* @param list<string>|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:');
}
/**
* Lists the containers of a Compose project, one JSON object per line, for composeContainersWithRemovedVolumes().
*/
function composeProjectMountsCommand(string $projectName): string
{
$format = '{"name":{{json .Name}},"service":{{json (index .Config.Labels "com.docker.compose.service")}},"imageVolumes":{{json .Config.Volumes}},"mounts":{{json .Mounts}}}';
return 'docker ps -aq --filter '.escapeshellarg("label=com.docker.compose.project={$projectName}")
.' | xargs -r docker inspect --format '.escapeshellarg($format).' 2>/dev/null || true';
}
/**
* `docker compose up` gives a recreated container the mount of the old container at each path
* that the image declares with VOLUME. This also keeps a volume that the user deleted in Coolify,
* so its old data comes back (#6875). Returns the containers to remove before `up`.
*
* Only a volume that Coolify made (its name starts with "{uuid}_") and that has no storage row
* anymore counts. A volume that is only removed from the Compose file still has its row, and an
* external volume or a bind mount is not Coolify's, so Compose keeps using them as before.
*
* @param string $projectMounts output of composeProjectMountsCommand()
* @param array<string, mixed> $composeServices the services of the Compose file that `up` starts
* @param list<string> $storageNames names of the storage rows that the resource still has
* @return array<string, string> container name => volume that the user deleted
*/
function composeContainersWithRemovedVolumes(string $projectMounts, array $composeServices, string $uuid, array $storageNames): array
{
$normalize = fn (string $path): string => rtrim($path, '/') ?: '/';
$containers = [];
foreach (preg_split('/\R/', trim($projectMounts)) as $line) {
$container = json_decode($line, true);
$service = data_get($container, 'service');
if (! is_array($container) || ! is_string($service) || ! is_array(data_get($composeServices, $service))) {
continue;
}
$targets = composeServiceVolumeTargets(data_get($composeServices, "{$service}.volumes", []));
if ($targets === null) {
continue;
}
$imageVolumes = array_map($normalize, array_keys(data_get($container, 'imageVolumes') ?? []));
foreach (data_get($container, 'mounts') ?? [] as $mount) {
$name = (string) data_get($mount, 'Name');
$path = $normalize((string) data_get($mount, 'Destination'));
if (data_get($mount, 'Type') !== 'volume' || ! str_starts_with($name, "{$uuid}_") || in_array($name, $storageNames, true)
|| ! in_array($path, $imageVolumes, true) || in_array($path, $targets, true)) {
continue;
}
$containers[ltrim((string) data_get($container, 'name'), '/')] = $name;
}
}
return $containers;
}
/**
* The container paths of the volumes of a Compose service, or null when a path is not literal
* (for example `${DATA_PATH}`).
*
* @return list<string>|null
*/
function composeServiceVolumeTargets(mixed $volumes): ?array
{
$targets = [];
foreach (is_array($volumes) ? $volumes : [] as $volume) {
if (is_array($volume)) {
$target = data_get($volume, 'target');
} else {
// source:target:mode, where the source can contain colons, for example ${DATA:-/data}.
$parts = explode(':', (string) $volume);
if (count($parts) > 1 && preg_match('/^[a-zA-Z,]+$/', end($parts))) {
array_pop($parts);
}
$target = end($parts);
}
if (! is_string($target) || ! str_starts_with($target, '/')) {
return null;
}
$targets[] = rtrim($target, '/') ?: '/';
}
return $targets;
}
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<int, string>|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}.{$loop}_reverse_proxy.stream_close_delay=".CADDY_STREAM_CLOSE_DELAY);
$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+)(?<raw>"(?:\\\\.|[^"])*"|\'(?:\\\\.|[^\'])*\'|[^\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<int, string>
*/
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' and 'value'
* @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.');
}
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';
}
/**
* Start the Railpack builder and wait until buildkitd accepts connections. Run it inside
* railpackBuilderLockedScript(), so that no other process stops the builder after it starts.
*
* buildx waits for buildkitd only when it starts the builder itself. A concurrent build that sees
* the container already running connects at once and fails while the BuildKit socket is missing.
* The lock makes the other builds wait until the first bootstrap is complete.
*/
function railpackBuilderBootstrapScript(): string
{
return 'exec 8>/root/.docker/buildx/coolify-railpack-bootstrap.lock; flock -x 8; '
.'DOCKER_CONFIG=/root/.docker docker buildx inspect --bootstrap coolify-railpack >/dev/null 2>&1; '
.'flock -u 8';
}
/**
* 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";
}