Files
coolify/bootstrap/helpers/parsers.php
T
Andras Bacsai 81045b2dc2 fix(compose): deploy large Compose files such as Sentry self-hosted
Sentry self-hosted (getsentry/self-hosted 26.8.0) could not be deployed
as a Docker Compose application. Three separate problems blocked it:

- YAML collection aliases: the first fix raised the limit to 256 only in
  Application::oldRawParser(). The safety check added in v4.4.0
  (validateDockerComposeForInjection) and all other Compose parse calls
  still used the Symfony default of 128, so the Sentry file (133 aliases)
  failed with "Maximum number of collection aliases (128) exceeded".
  Add parseDockerComposeYaml() and use it for every Compose parse.
- Argument list too long: the deployment job sent the generated Compose
  file as base64 inside the SSH command. Linux limits one argument to
  128 KiB, and the Sentry file is about 370 KB. execute_remote_command()
  now accepts an 'input' key that streams content over SSH stdin, and
  the job writes the Compose file with it.
- A list-style build arg without a value (build.args: [SENTRY_IMAGE])
  merged into a map environment kept its numeric key, so Docker Compose
  rejected the file with "non-string key in ...environment: 0". It now
  becomes NAME with no value, as in Compose.

Closes #11725
2026-10-07 13:30:42 +02:00

3427 lines
161 KiB
PHP

<?php
use App\Enums\ProxyTypes;
use App\Jobs\ServerFilesFromServerJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\ValidationPatterns;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
/**
* Parses Docker Compose YAML with a higher collection alias limit than the Symfony default (128),
* because large Compose files (for example Sentry self-hosted) reuse YAML anchors many times.
*
* @throws ParseException If the YAML is invalid or exceeds the alias limit
*/
function parseDockerComposeYaml(string $compose): mixed
{
return Yaml::parse($compose, maxAliasesForCollections: Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES);
}
/**
* Validates a Docker Compose YAML string for command injection vulnerabilities.
* This should be called BEFORE saving to database to prevent malicious data from being stored.
*
* @param string $composeYaml The raw Docker Compose YAML content
*
* @throws Exception If the compose file contains command injection attempts
*/
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = parseDockerComposeYaml($composeYaml);
} catch (Exception $e) {
throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
if (! is_array($parsed) || ! isset($parsed['services']) || ! is_array($parsed['services'])) {
throw new Exception('Docker Compose file must contain a "services" section');
}
// Validate service names
foreach ($parsed['services'] as $serviceName => $serviceConfig) {
try {
validateShellSafePath($serviceName, 'service name');
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.',
0,
$e
);
}
// Validate volumes in this service (both string and array formats)
if (isset($serviceConfig['volumes']) && is_array($serviceConfig['volumes'])) {
foreach ($serviceConfig['volumes'] as $volume) {
if (is_string($volume)) {
// String format: "source:target" or "source:target:mode"
validateVolumeStringForInjection($volume);
} elseif (is_array($volume)) {
// Array format: {type: bind, source: ..., target: ...}
if (isset($volume['source'])) {
$source = $volume['source'];
if (is_string($source)) {
validateComposeArrayVolumeSource($source);
}
}
if (isset($volume['target'])) {
$target = $volume['target'];
if (is_string($target)) {
try {
validateShellSafePath($target, 'volume target');
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
$e
);
}
}
}
validateComposeContentVolumeSource($volume);
}
}
}
if (is_array($serviceConfig) && isset($serviceConfig['networks']) && is_array($serviceConfig['networks'])) {
foreach ($serviceConfig['networks'] as $networkKey => $networkDetails) {
if (is_int($networkKey) && (is_string($networkDetails) || is_int($networkDetails))) {
validateComposeNetworkNameWithVariables((string) $networkDetails, 'service network', (string) $serviceName);
} elseif (is_string($networkKey) || is_int($networkKey)) {
// Compose does not interpolate keys, so a key must be a plain network name.
validateComposeNetworkName((string) $networkKey, 'service network', (string) $serviceName);
}
}
}
}
if (isset($parsed['networks']) && is_array($parsed['networks'])) {
foreach ($parsed['networks'] as $networkName => $networkConfig) {
if (is_string($networkName) || is_int($networkName)) {
validateComposeNetworkName((string) $networkName);
}
if (is_array($networkConfig) && isset($networkConfig['name']) && is_string($networkConfig['name'])) {
validateComposeNetworkNameField($networkConfig['name']);
}
}
}
if (isset($parsed['volumes']) && is_array($parsed['volumes'])) {
foreach ($parsed['volumes'] as $volumeName => $volumeConfig) {
if (isComposeExternalVolume($volumeConfig)) {
validateComposeExternalVolume($volumeName, $volumeConfig);
}
}
}
}
/**
* The directory that the Compose parsers resolve `./` bind sources in. Services that use parser
* version 3 resolve them in the applications directory.
*/
function composeResourceDirectory(Application|Service $resource): string
{
if ($resource instanceof Service && (int) $resource->compose_parsing_version === 3) {
return application_configuration_dir().'/'.$resource->uuid;
}
return $resource->workdir();
}
/**
* Coolify writes the `content:` of a Compose bind volume to the source path on the host. Only
* administrators can edit a Compose file, and they can mount any host path, so the source can be
* any path, also outside the resource directory. It must exist and be safe to
* use in a shell command; Coolify escapes it in every remote command.
*
* @param array<string, mixed> $volume A long-syntax Compose volume
*
* @throws Exception If the source is missing or contains shell metacharacters
*/
function validateComposeContentVolumeSource(array $volume): void
{
if (! array_key_exists('content', $volume) || ($volume['type'] ?? null) !== 'bind') {
return;
}
$source = $volume['source'] ?? null;
if (! is_string($source) || trim($source) === '') {
throw new Exception('Invalid Docker volume definition (array syntax): A bind volume with content needs a source path.');
}
validateComposeArrayVolumeSource($source);
}
/**
* Keep the existing array-source forms, but inspect the default that was previously skipped.
*/
function validateComposeArrayVolumeSource(string $source): void
{
try {
if (preg_match('/[\x00-\x1F\x7F]/', $source)) {
throw new Exception('Invalid volume source: contains a control character.');
}
if (preg_match('/^\$\{[A-Za-z_][A-Za-z0-9_]*\}[\/\w.\-]*$/', $source)) {
return;
}
if (preg_match('/^\$\{[A-Za-z_][A-Za-z0-9_]*:-([^}]*)\}$/', $source, $matches)) {
validateShellSafePath($matches[1], 'volume source');
return;
}
validateShellSafePath($source, 'volume source');
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.',
0,
$e
);
}
}
/**
* Splits a top-level network `name:` that is one whole Compose variable (`${VAR}`, `${VAR:-default}`
* or `${VAR-default}`), such as an external network that differs per server.
*
* @return array{variable: string, default: ?string}|null
*/
function composeNetworkNameVariable(string $name): ?array
{
if (preg_match('/\A\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}\z/', $name, $matches) !== 1) {
return null;
}
return ['variable' => $matches[1], 'default' => $matches[2] ?? null];
}
/**
* Creates a resource environment variable for each top-level network `name:` that is one Compose
* variable, like variables in `environment:`, so users can see and change it. The compose file keeps
* the variable and Compose resolves it from the deployment `.env`. The default from the compose file
* (or an empty value) is only the first value: a value that the user changed is kept. A variable with
* a default that is not a valid network name is not created.
*
* For applications, the EnvironmentVariable `created` hook adds the preview copy.
*/
function ensureComposeNetworkNameVariables(Application|Service $resource, iterable $networks): void
{
foreach ($networks as $network) {
$name = data_get($network, 'name');
$variable = is_string($name) ? composeNetworkNameVariable($name) : null;
if ($variable === null) {
continue;
}
if ($variable['default'] !== null && ! ValidationPatterns::isValidDockerNetwork($variable['default'])) {
continue;
}
$resource->environment_variables()->firstOrCreate([
'key' => $variable['variable'],
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $variable['default'] ?? '',
'is_preview' => false,
]);
}
}
/**
* A network `name:` may contain variables: only Docker Compose reads this value and it never runs a
* shell; Coolify's own network commands use the network keys.
*
* @throws Exception If the value is not a valid network name with safe variables
*/
function validateComposeNetworkNameField(string $name): void
{
validateComposeNetworkNameWithVariables($name, 'network name field');
}
/**
* Allows $VAR, ${VAR}, ${VAR:-default} and ${VAR-default} with safe defaults in a network value that Compose interpolates.
*/
function validateComposeNetworkNameWithVariables(string $name, string $context, ?string $serviceName = null): void
{
$variable = composeNetworkNameVariable($name);
if ($variable !== null) {
$isValid = $variable['default'] === null || ValidationPatterns::isValidDockerNetwork($variable['default']);
} else {
$hasSafeDefaults = true;
$withoutVariables = preg_replace_callback(
'/\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?-([^}]*))?\}|\$[A-Za-z_][A-Za-z0-9_]*/',
function (array $matches) use (&$hasSafeDefaults): string {
$default = $matches[2] ?? '';
if ($default !== '' && preg_match('/\A[A-Za-z0-9_.-]+\z/', $default) !== 1) {
$hasSafeDefaults = false;
}
return 'x';
},
$name,
);
$isValid = $hasSafeDefaults && $withoutVariables !== null && ValidationPatterns::isValidDockerNetwork($withoutVariables);
}
if (! $isValid) {
throw invalidComposeNetworkNameException($name, $context, $serviceName, allowsVariables: true);
}
}
/**
* Reject Docker Compose network names that are not valid Docker identifiers.
*
* @throws Exception If the network name is not a valid Docker network identifier
*/
function validateComposeNetworkName(string $networkName, string $context = 'network name', ?string $serviceName = null): void
{
if ($networkName === '' || ! ValidationPatterns::isValidDockerNetwork($networkName)) {
throw invalidComposeNetworkNameException($networkName, $context, $serviceName);
}
}
function invalidComposeNetworkNameException(string $name, string $context, ?string $serviceName, bool $allowsVariables = false): Exception
{
$location = $serviceName === null ? '' : " in service {$serviceName}";
return new Exception(
"Invalid Docker Compose {$context} \"{$name}\"{$location}. Network names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores"
.($allowsVariables ? ', and can use variables such as ${NETWORK:-default}.' : '.')
);
}
/**
* Tells if a top-level Compose volume declaration is external: `external: true` or the old
* `external: {name: x}` syntax. Docker Compose does not create or remove an external volume.
*/
function isComposeExternalVolume(mixed $declaration): bool
{
if (! is_array($declaration) || ! array_key_exists('external', $declaration)) {
return false;
}
$external = $declaration['external'];
if (is_array($external)) {
return true;
}
return filter_var($external, FILTER_VALIDATE_BOOLEAN);
}
/**
* Returns the top-level declaration of a volume source when the Compose file declares it as
* external, and null for all other sources. The parsers use an external volume as written: no
* "{uuid}_" prefix and no LocalPersistentVolume row, so that Coolify never removes a volume that it
* does not own. A preview deployment never uses an external volume; it gets its own volume
* "{uuid}_{volume}-pr-{id}" like any other named volume.
*
* @param iterable<array-key, mixed> $topLevelVolumes
* @return array<string, mixed>|null
*
* @throws Exception If the external volume has a name that is not safe (see validateComposeExternalVolume())
*/
function composeExternalVolumeDeclaration(iterable $topLevelVolumes, string $source): ?array
{
$declaration = collect($topLevelVolumes)->get($source);
if (! isComposeExternalVolume($declaration)) {
return null;
}
validateComposeExternalVolume($source, $declaration);
return $declaration;
}
/**
* Tells if a parser uses the volume source as written because it is an external volume.
*
* Only for production deployments: a preview deployment always gets its own volume.
*
* Before Coolify used external volumes as written, the parsers renamed them like all other
* volumes (the old name, for example "{uuid}_{volume}"), so the resource wrote its data into the
* renamed volume. When the owner still has the storage entry
* with the old name, the parser must keep the old name, or the resource loses its data. Then
* this function records a warning on the resource and returns false. When the user deletes that
* storage entry, the next parse uses the external volume.
*
* Returns false also for a source that is not external. Then the parser continues as usual.
*
* @param iterable<array-key, mixed> $topLevelVolumes
* @param string $legacyName the name that the parser gave the volume before it used external volumes as written
*
* @throws Exception If the external volume has a name that is not safe (see validateComposeExternalVolume())
*/
function useComposeExternalVolumeAsWritten(Application|Service $resource, Application|ServiceApplication|ServiceDatabase $owner, iterable $topLevelVolumes, string $source, string $legacyName): bool
{
$declaration = composeExternalVolumeDeclaration($topLevelVolumes, $source);
if ($declaration === null) {
return false;
}
if ($legacyName === $source) {
return true;
}
$hasLegacyStorage = LocalPersistentVolume::query()
->where('resource_type', get_class($owner))
->where('resource_id', $owner->getKey())
->where('name', $legacyName)
->exists();
if (! $hasLegacyStorage) {
return true;
}
$resource->addComposeVolumeWarning(composeLegacyExternalVolumeWarning($source, $declaration, $legacyName));
return false;
}
/**
* The name that the legacy application parsers (compose_parsing_version 1 and 2) gave a volume
* before they used external volumes as written: parser version 1 kept the name and added only the
* preview suffix, parser version 2 added the "{uuid}-" prefix too.
*/
function legacyApplicationComposeVolumeName(Application $resource, string $source, int $pull_request_id): string
{
$name = addPreviewDeploymentSuffix($source, $pull_request_id);
return $resource->compose_parsing_version === '2' ? "{$resource->uuid}-{$name}" : $name;
}
/**
* The top-level declaration of a volume that a legacy application parser (compose_parsing_version 1
* and 2) renamed. Only applications from before Coolify kept driver options use these parsers, and
* they have no storage entries, so their volumes keep the old name-only declaration. Docker created
* them without the options, and Docker Compose would otherwise ask to recreate them.
*
* @return array{name: string}
*/
function legacyApplicationRenamedVolumeDeclaration(string $name): array
{
return ['name' => $name];
}
/**
* Records a warning when a legacy Compose application (parser version 1 or 2) does not use an
* external volume as written. These parsers keep the old volume name (see
* legacyApplicationComposeVolumeName()), so the resource keeps its data. The parser version 1 keeps
* the name of a production volume, so it uses the external volume and gets no warning. A preview
* always uses its own volume, so it gets no warning either.
*
* The volume names are not validated here, so that the legacy parsers keep their old behavior. The
* warning shows the Docker volume name only when it is a literal, valid Docker volume name.
*
* @param iterable<array-key, mixed> $topLevelVolumes the top-level volumes as declared in the Compose file
*/
function warnLegacyApplicationComposeExternalVolume(Application $resource, iterable $topLevelVolumes, string $source, int $pull_request_id): void
{
if ($pull_request_id !== 0) {
return;
}
$declaration = collect($topLevelVolumes)->get($source);
if (! isComposeExternalVolume($declaration)) {
return;
}
$keptName = legacyApplicationComposeVolumeName($resource, $source, $pull_request_id);
if ($keptName === $source) {
return;
}
$dockerVolume = data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $source;
$external = ! is_string($dockerVolume) || $dockerVolume === $source || preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $dockerVolume) !== 1
? 'external'
: "external (Docker volume '{$dockerVolume}')";
$resource->addComposeVolumeWarning("Volume '{$source}' is declared as {$external}, but this application uses an old Compose parser, so Coolify uses '{$keptName}'. To use the external volume, copy your data into it, then clone this application or create it again.");
}
/**
* The warning for an external volume that the resource does not use yet (see useComposeExternalVolumeAsWritten()).
* The volume names are validated and contain no secrets.
*
* @param array<string, mixed> $declaration
*/
function composeLegacyExternalVolumeWarning(string $source, array $declaration, string $legacyName): string
{
$dockerVolume = composeExternalVolumeDockerName($source, $declaration);
$external = $dockerVolume === $source ? 'external' : "external (Docker volume '{$dockerVolume}')";
return "Volume '{$source}' is declared as {$external}, but Coolify still uses '{$legacyName}' because this resource used it before. To use the external volume, copy your data into it and delete the storage entry '{$legacyName}', then redeploy.";
}
/**
* The Docker volume that an external Compose volume declaration refers to: `name:`, the old
* `external: {name: x}` syntax, or else the key.
*
* @param array<string, mixed> $declaration
*/
function composeExternalVolumeDockerName(string $key, array $declaration): string
{
return (string) (data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $key);
}
/**
* The top-level declaration of a volume that a parser renamed (for example `data` to `{uuid}_data`).
* It keeps the options of the original declaration, such as `driver`, `driver_opts` and `labels`, so a
* local volume that binds a host folder still binds it. Variables in the options stay as written;
* Docker Compose resolves them from `.env`. The parser renamed the volume, so the declaration gets the
* new name, and it is not external: Docker Compose creates the renamed volume.
*
* @return array<string, mixed>
*/
function composeRenamedVolumeDeclaration(mixed $declaration, string $name): array
{
$renamed = is_array($declaration) ? $declaration : [];
unset($renamed['external'], $renamed['name']);
$renamed['name'] = $name;
return $renamed;
}
/**
* The top-level declaration of a renamed volume with its storage entry. A volume that existed before
* Coolify kept the driver options (see the `ignores_compose_driver_options` flag) keeps its old
* name-only declaration: Docker created it without the options, and Docker Compose would otherwise
* ask to recreate it on every deployment.
*
* A preview volume does not get `driver_opts` that name a host device (for example a bind mount of a
* host folder): the preview would otherwise mount the same folder or disk as the production volume.
*
* @return array<string, mixed>
*/
function composeRenamedVolumeDeclarationFor(mixed $declaration, string $name, ?LocalPersistentVolume $volume, bool $isPreview = false): array
{
if ($volume?->ignores_compose_driver_options) {
return ['name' => $name];
}
$renamed = composeRenamedVolumeDeclaration($declaration, $name);
if ($isPreview && filled(data_get($renamed, 'driver_opts.device')) && data_get($renamed, 'driver_opts.type') !== 'tmpfs') {
unset($renamed['driver_opts']);
}
return $renamed;
}
/**
* The Docker volumes that a Compose file declares as external. Returns an empty list for a
* Compose file that is empty or not valid YAML.
*
* @return list<string>
*/
function composeExternalVolumeDockerNames(?string $compose): array
{
if (blank($compose)) {
return [];
}
try {
$volumes = data_get(parseDockerComposeYaml($compose), 'volumes');
} catch (Throwable) {
return [];
}
if (! is_array($volumes)) {
return [];
}
return collect($volumes)
->filter(fn (mixed $declaration): bool => isComposeExternalVolume($declaration))
->map(fn (array $declaration, int|string $key): string => composeExternalVolumeDockerName((string) $key, $declaration))
->values()
->all();
}
/**
* The external volumes that the services of a Compose file mount, one entry for each volume and
* service. Returns an empty list for a Compose file that is empty or not valid YAML.
*
* @return list<array{key: string, dockerName: string, service: string, mountPaths: list<string>}>
*/
function composeExternalVolumeMounts(?string $compose): array
{
if (blank($compose)) {
return [];
}
try {
$yaml = parseDockerComposeYaml($compose);
} catch (Throwable) {
return [];
}
$topLevelVolumes = data_get($yaml, 'volumes');
$services = data_get($yaml, 'services');
if (! is_array($topLevelVolumes) || ! is_array($services)) {
return [];
}
$mounts = [];
foreach ($services as $serviceName => $service) {
$serviceVolumes = data_get($service, 'volumes');
if (! is_array($serviceVolumes)) {
continue;
}
foreach ($serviceVolumes as $volume) {
$parsed = match (true) {
is_string($volume) => parseDockerVolumeString($volume),
is_array($volume) => $volume,
default => [],
};
// parseDockerVolumeString() returns Stringable values.
$source = data_get($parsed, 'source');
$target = data_get($parsed, 'target');
if (! (is_scalar($source) || $source instanceof Stringable) || ! (is_scalar($target) || $target instanceof Stringable)) {
continue;
}
$source = (string) $source;
$target = (string) $target;
$declaration = $topLevelVolumes[$source] ?? null;
if (! isComposeExternalVolume($declaration)) {
continue;
}
$dockerName = data_get($declaration, 'name') ?? data_get($declaration, 'external.name') ?? $source;
$mountKey = $source."\0".$serviceName;
$mounts[$mountKey] ??= [
'key' => $source,
'dockerName' => is_scalar($dockerName) ? (string) $dockerName : $source,
'service' => (string) $serviceName,
'mountPaths' => [],
];
if (! in_array($target, $mounts[$mountKey]['mountPaths'], true)) {
$mounts[$mountKey]['mountPaths'][] = $target;
}
}
}
return array_values($mounts);
}
/**
* The key of an external volume must be a literal Docker volume name, because Docker Compose does not
* resolve variables in keys. The name (`name:` or the old `external: {name: x}`) may also contain
* variables (see isComposeVolumeNameWithVariables()): Docker Compose resolves them from the deployment
* `.env` when it starts the resource. Coolify never removes an external volume, because it gets no
* storage entry, so Coolify does not need the resolved name.
*
* @param array<string, mixed> $declaration
*
* @throws Exception If the key or the name is not a valid Docker volume name
*/
function validateComposeExternalVolume(int|string $key, array $declaration): void
{
$key = (string) $key;
if (preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $key) !== 1) {
throw new Exception(
'Invalid external Docker Compose volume. Volume names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores.'
);
}
$names = [];
if (array_key_exists('name', $declaration)) {
$names[] = $declaration['name'];
}
if (is_array($declaration['external'] ?? null) && array_key_exists('name', $declaration['external'])) {
$names[] = $declaration['external']['name'];
}
foreach ($names as $name) {
if (! is_string($name) || (preg_match(ValidationPatterns::VOLUME_NAME_PATTERN, $name) !== 1 && ! isComposeVolumeNameWithVariables($name))) {
throw new Exception(
"Invalid external Docker Compose volume {$key}. The name must be a Docker volume name (alphanumeric characters, dots, hyphens, and underscores) and can contain variables such as \${NAME} or \${NAME:-default}."
);
}
}
}
/**
* Tells if a volume name contains Compose variables and otherwise only Docker volume name characters.
* The variables are `$NAME`, `${NAME}`, `${NAME-default}` and `${NAME:-default}`, with a default
* that contains only Docker volume name characters.
*/
function isComposeVolumeNameWithVariables(string $name): bool
{
return str_contains($name, '$')
&& preg_match('/^(?:[A-Za-z0-9._-]|\$[A-Za-z_][A-Za-z0-9_]*|\$\{[A-Za-z_][A-Za-z0-9_]*(?::?-[A-Za-z0-9._-]*)?\})+$/', $name) === 1;
}
/**
* Validates a Docker volume string (format: "source:target" or "source:target:mode")
*
* @param string $volumeString The volume string to validate
*
* @throws Exception If the volume string contains command injection attempts
*/
function validateVolumeStringForInjection(string $volumeString): void
{
// Canonical parsing also validates and throws on unsafe input
parseDockerVolumeString($volumeString);
}
function parseDockerVolumeString(string $volumeString): array
{
$volumeString = trim($volumeString);
$source = null;
$target = null;
$mode = null;
// First, check if the source contains an environment variable with default value
// This needs to be done before counting colons because ${VAR:-value} contains a colon
$envVarPattern = '/^\$\{[^}]+:-[^}]*\}/';
$hasEnvVarWithDefault = false;
$envVarEndPos = 0;
if (preg_match($envVarPattern, $volumeString, $matches)) {
$hasEnvVarWithDefault = true;
$envVarEndPos = strlen($matches[0]);
}
// Count colons, but exclude those inside environment variables
$effectiveVolumeString = $volumeString;
if ($hasEnvVarWithDefault) {
// Temporarily replace the env var to count colons correctly
$effectiveVolumeString = substr($volumeString, $envVarEndPos);
$colonCount = substr_count($effectiveVolumeString, ':');
} else {
$colonCount = substr_count($volumeString, ':');
}
if ($colonCount === 0) {
// Named volume without target (unusual but valid)
// Example: "myvolume"
$source = $volumeString;
$target = $volumeString;
} elseif ($colonCount === 1) {
// Simple volume mapping
// Examples: "gitea:/data" or "./data:/app/data" or "${VAR:-default}:/data"
if ($hasEnvVarWithDefault) {
$source = substr($volumeString, 0, $envVarEndPos);
$remaining = substr($volumeString, $envVarEndPos);
if (strlen($remaining) > 0 && $remaining[0] === ':') {
$target = substr($remaining, 1);
} else {
$target = $remaining;
}
} else {
$parts = explode(':', $volumeString);
$source = $parts[0];
$target = $parts[1];
}
} elseif ($colonCount === 2) {
// Volume with mode OR Windows path OR env var with mode
// Handle env var with mode first
if ($hasEnvVarWithDefault) {
// ${VAR:-default}:/path:mode
$source = substr($volumeString, 0, $envVarEndPos);
$remaining = substr($volumeString, $envVarEndPos);
if (strlen($remaining) > 0 && $remaining[0] === ':') {
$remaining = substr($remaining, 1);
$lastColon = strrpos($remaining, ':');
if ($lastColon !== false) {
$possibleMode = substr($remaining, $lastColon + 1);
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
if (in_array($possibleMode, $validModes)) {
$mode = $possibleMode;
$target = substr($remaining, 0, $lastColon);
} else {
$target = $remaining;
}
} else {
$target = $remaining;
}
}
} elseif (preg_match('/^[A-Za-z]:/', $volumeString)) {
// Windows path as source (C:/, D:/, etc.)
// Find the second colon which is the real separator
$secondColon = strpos($volumeString, ':', 2);
if ($secondColon !== false) {
$source = substr($volumeString, 0, $secondColon);
$target = substr($volumeString, $secondColon + 1);
} else {
// Malformed, treat as is
$source = $volumeString;
$target = $volumeString;
}
} else {
// Not a Windows path, check for mode
$lastColon = strrpos($volumeString, ':');
$possibleMode = substr($volumeString, $lastColon + 1);
// Check if the last part is a valid Docker volume mode
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
if (in_array($possibleMode, $validModes)) {
// It's a mode
// Examples: "gitea:/data:ro" or "./data:/app/data:rw"
$mode = $possibleMode;
$volumeWithoutMode = substr($volumeString, 0, $lastColon);
$colonPos = strpos($volumeWithoutMode, ':');
if ($colonPos !== false) {
$source = substr($volumeWithoutMode, 0, $colonPos);
$target = substr($volumeWithoutMode, $colonPos + 1);
} else {
// Shouldn't happen for valid volume strings
$source = $volumeWithoutMode;
$target = $volumeWithoutMode;
}
} else {
// The last colon is part of the path
// For now, treat the first occurrence of : as the separator
$firstColon = strpos($volumeString, ':');
$source = substr($volumeString, 0, $firstColon);
$target = substr($volumeString, $firstColon + 1);
}
}
} else {
// More than 2 colons - likely Windows paths or complex cases
// Use a heuristic: find the most likely separator colon
// Look for patterns like "C:" at the beginning (Windows drive)
if (preg_match('/^[A-Za-z]:/', $volumeString)) {
// Windows path as source
// Find the next colon after the drive letter
$secondColon = strpos($volumeString, ':', 2);
if ($secondColon !== false) {
$source = substr($volumeString, 0, $secondColon);
$remaining = substr($volumeString, $secondColon + 1);
// Check if there's a mode at the end
$lastColon = strrpos($remaining, ':');
if ($lastColon !== false) {
$possibleMode = substr($remaining, $lastColon + 1);
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
if (in_array($possibleMode, $validModes)) {
$mode = $possibleMode;
$target = substr($remaining, 0, $lastColon);
} else {
$target = $remaining;
}
} else {
$target = $remaining;
}
} else {
// Malformed, treat as is
$source = $volumeString;
$target = $volumeString;
}
} else {
// Try to parse normally, treating first : as separator
$firstColon = strpos($volumeString, ':');
$source = substr($volumeString, 0, $firstColon);
$remaining = substr($volumeString, $firstColon + 1);
// Check for mode at the end
$lastColon = strrpos($remaining, ':');
if ($lastColon !== false) {
$possibleMode = substr($remaining, $lastColon + 1);
$validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent'];
if (in_array($possibleMode, $validModes)) {
$mode = $possibleMode;
$target = substr($remaining, 0, $lastColon);
} else {
$target = $remaining;
}
} else {
$target = $remaining;
}
}
}
// Handle environment variable expansion in source
// Example: ${VOLUME_DB_PATH:-db} should extract default value if present
if ($source && preg_match('/^\$\{([^}]+)\}$/', $source, $matches)) {
$varContent = $matches[1];
// Check if there's a default value with :-
if (strpos($varContent, ':-') !== false) {
$parts = explode(':-', $varContent, 2);
$varName = $parts[0];
$defaultValue = isset($parts[1]) ? $parts[1] : '';
// If there's a non-empty default value, use it for source
if ($defaultValue !== '') {
$source = $defaultValue;
} else {
// Empty default value, keep the variable reference for env resolution
$source = '${'.$varName.'}';
}
}
// Otherwise keep the variable as-is for later expansion (no default value)
}
// Validate source path for command injection attempts
// We validate the final source value after environment variable processing
if ($source !== null) {
// Allow environment variables like ${VAR_NAME} or ${VAR}
// Also allow env vars followed by safe path concatenation (e.g., ${VAR}/path)
$sourceStr = is_string($source) ? $source : $source;
// Skip validation for simple environment variable references
// Pattern 1: ${WORD_CHARS} with no special characters inside
// Pattern 2: ${WORD_CHARS}/path/to/file (env var with path concatenation)
$isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceStr);
$isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceStr);
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceStr, 'volume source');
} catch (Exception $e) {
// Re-throw with more context about the volume string
throw new Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
}
// Also validate target path
if ($target !== null) {
$targetStr = is_string($target) ? $target : $target;
// Target paths in containers are typically absolute paths, so we validate them too
// but they're less likely to be dangerous since they're not used in host commands
// Still, defense in depth is important
try {
validateShellSafePath($targetStr, 'volume target');
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition: '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
return [
'source' => $source !== null ? str($source) : null,
'target' => $target !== null ? str($target) : null,
'mode' => $mode !== null ? str($mode) : null,
];
}
function addTraefikDockerNetworkLabel(Collection $labels, string $network): Collection
{
$hasUserDefinedNetwork = $labels->contains(
fn ($label): bool => is_string($label) && str($label)->before('=')->is('traefik.docker.network')
);
if (! $hasUserDefinedNetwork) {
$labels->push("traefik.docker.network={$network}");
}
return $labels;
}
/**
* Remove one-time fields from long-form volume entries without reformatting the rest of the source.
* Fall back to a YAML dump when the source uses a form that the line edit cannot handle safely.
*
* @param array<string, mixed> $cleanedYaml
* @param array<int, string> $fields
*/
function removeComposeVolumeFieldsPreservingComments(string $source, array $cleanedYaml, array $fields): string
{
$context = [];
$removeIndent = null;
$blockIndent = null;
$result = [];
foreach (preg_split('/(?<=\n)/', $source) as $line) {
$text = rtrim($line, "\r\n");
$indent = strspn($text, ' ');
if ($removeIndent !== null) {
if (trim($text) === '' || $indent > $removeIndent) {
continue;
}
$removeIndent = null;
}
if ($blockIndent !== null) {
if (trim($text) === '' || $indent > $blockIndent) {
$result[] = $line;
continue;
}
$blockIndent = null;
}
if (trim($text) === '' || str_starts_with(ltrim($text), '#')) {
$result[] = $line;
continue;
}
while ($context && end($context)['indent'] >= $indent) {
array_pop($context);
}
$body = substr($text, $indent);
$isListItem = preg_match('/^-\s+/', $body) === 1;
if ($isListItem) {
$context[] = ['indent' => $indent, 'key' => '[]'];
$body = preg_replace('/^-\s+/', '', $body);
}
if (preg_match('/^([\w.-]+|"[^"]+"|\x27[^\x27]+\x27)\s*:(.*)$/', $body, $matches)) {
$key = trim($matches[1], "\"'");
$path = array_column($context, 'key');
if (! $isListItem && count($path) === 4 && $path[0] === 'services' && $path[2] === 'volumes' && $path[3] === '[]' && in_array($key, $fields, true)) {
$removeIndent = $indent;
continue;
}
$value = trim($matches[2]);
if ($value === '' || str_starts_with($value, '#')) {
$context[] = ['indent' => $indent, 'key' => $key];
} elseif (preg_match('/^[|>][+-]?(?:\s+#.*)?$/', $value)) {
$blockIndent = $indent;
}
}
$result[] = $line;
}
$candidate = implode('', $result);
try {
if (parseDockerComposeYaml($candidate) === $cleanedYaml) {
return $candidate;
}
} catch (Exception) {
// Use the validated parsed result if the line edit is not valid YAML.
}
return Yaml::dump($cleanedYaml, 10, 2);
}
function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null): Collection
{
$resource->resetComposeVolumeWarnings();
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
// Store original compose for later use to update docker_compose_raw with content removed
$originalCompose = $compose;
if (! $compose) {
return collect([]);
}
$pullRequestId = $pull_request_id;
$isPullRequest = $pullRequestId == 0 ? false : true;
$server = data_get($resource, 'destination.server');
try {
$yaml = parseDockerComposeYaml($compose);
} catch (Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
$topLevel = collect([
'volumes' => collect(data_get($yaml, 'volumes', [])),
'networks' => collect(data_get($yaml, 'networks', [])),
'configs' => collect(data_get($yaml, 'configs', [])),
'secrets' => collect(data_get($yaml, 'secrets', [])),
]);
ensureComposeNetworkNameVariables($resource, $topLevel->get('networks'));
// If there are predefined volumes, make sure they are not null
if ($topLevel->get('volumes')->count() > 0) {
$temp = collect([]);
foreach ($topLevel['volumes'] as $volumeName => $volume) {
if (is_null($volume)) {
continue;
}
$temp->put($volumeName, $volume);
}
$topLevel['volumes'] = $temp;
}
// Get the base docker network
$baseNetwork = collect([$uuid]);
if ($isPullRequest) {
$baseNetwork = collect(["{$uuid}-{$pullRequestId}"]);
}
$parsedServices = collect([]);
$allMagicEnvironments = collect([]);
foreach ($services as $serviceName => $service) {
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
}
$magicEnvironments = collect([]);
$image = data_get_str($service, 'image');
$environment = collect(data_get($service, 'environment', []));
$buildArgs = collect(data_get($service, 'build.args', []));
$environment = $environment->merge($buildArgs);
$environment = collect(data_get($service, 'environment', []));
$buildArgs = collect(data_get($service, 'build.args', []));
$environment = $environment->merge($buildArgs);
// convert environment variables to one format
$environment = convertToKeyValueCollection($environment);
// Add Coolify defined environments
$allEnvironments = $resource->environment_variables()->get(['key', 'value']);
$allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
return [$item['key'] => $item['value']];
});
// filter and add magic environments
foreach ($environment as $key => $value) {
// Get all SERVICE_ variables from keys and values
$key = str($key);
$value = str($value);
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches);
if (count($valueMatches[2]) > 0) {
foreach ($valueMatches[2] as $match) {
$match = str($match);
if ($match->startsWith('SERVICE_')) {
if ($magicEnvironments->has($match->value())) {
continue;
}
$magicEnvironments->put($match->value(), '');
}
}
}
// Get magic environments where we need to preset the FQDN
// for example SERVICE_FQDN_APP_3000 (without a value)
if ($key->startsWith('SERVICE_FQDN_')) {
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000
$parsed = parseServiceEnvironmentVariable($key->value());
$fqdnFor = $parsed['service_name'];
$port = $parsed['port'];
$fqdn = $resource->fqdn;
if (blank($resource->fqdn)) {
$fqdn = generateFqdn(server: $server, random: "$uuid", parserVersion: $resource->compose_parsing_version);
}
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$fqdn = "$fqdn$path";
}
}
$fqdnWithPort = $fqdn;
if ($port) {
$fqdnWithPort = "$fqdn:$port";
}
if (is_null($resource->fqdn)) {
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->fqdn = $fqdnWithPort;
$resource->save();
}
if (! $parsed['has_port']) {
$resource->environment_variables()->updateOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_preview' => false,
]);
}
if ($parsed['has_port']) {
$newKey = str($key)->beforeLast('_');
$resource->environment_variables()->updateOrCreate([
'key' => $newKey->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_preview' => false,
]);
}
}
// Also populate docker_compose_domains for dockercompose apps from direct SERVICE_* declarations.
if ($resource->build_pack === 'dockercompose' && ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_'))) {
$parsed = parseServiceEnvironmentVariable($key->value());
$normalizedServiceName = normalizeComposeServiceName((string) $parsed['service_name']);
$originalServiceName = findComposeServiceName($normalizedServiceName, array_keys($services));
if ($originalServiceName !== null) {
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
if (! hasComposeServiceDomainEntry($domains, $originalServiceName)) {
$serviceNameForDomain = str($parsed['service_name'])->replace('_', '-')->value();
$domainValue = generateUrl(server: $server, random: "$serviceNameForDomain-$uuid");
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
$domainValue = "$domainValue$path";
}
}
if ($parsed['port'] && is_numeric($parsed['port'])) {
$domainValue = "$domainValue:{$parsed['port']}";
}
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
$domains,
$originalServiceName,
$domainValue,
array_keys($services),
));
$resource->save();
}
}
}
}
$allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
if ($magicEnvironments->count() > 0) {
// Generate Coolify environment variables
foreach ($magicEnvironments as $key => $value) {
$key = str($key);
$value = replaceVariables($value);
$command = parseCommandFromMagicEnvVariable($key);
if ($command->value() === 'FQDN' || $command->value() === 'URL') {
// ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs regardless of which one is in template
$parsed = parseServiceEnvironmentVariable($key->value());
$serviceName = $parsed['service_name'];
$port = $parsed['port'];
// Extract case-preserved service name from template
$strKey = str($key->value());
if ($parsed['has_port']) {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceNamePreserved = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
} else {
$serviceNamePreserved = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
}
} else {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceNamePreserved = $strKey->after('SERVICE_URL_')->value();
} else {
$serviceNamePreserved = $strKey->after('SERVICE_FQDN_')->value();
}
}
$originalServiceName = str($serviceName)->replace('_', '-')->value();
// Env var SERVICE_* names still use underscores; domain map keys use original compose names.
$serviceName = normalizeComposeServiceName((string) $serviceName);
// Generate BOTH FQDN & URL
$fqdn = generateFqdn(server: $server, random: "$originalServiceName-$uuid", parserVersion: $resource->compose_parsing_version);
$url = generateUrl(server: $server, random: "$originalServiceName-$uuid");
// IMPORTANT: SERVICE_FQDN env vars should NOT contain scheme (host only)
// But $fqdn variable itself may contain scheme (used for database domain field)
// Strip scheme for environment variable values
$fqdnValueForEnv = str($fqdn)->after('://')->value();
// Append port if specified
$urlWithPort = $url;
$fqdnValueForEnvWithPort = $fqdnValueForEnv;
if ($port && is_numeric($port)) {
$urlWithPort = "$url:$port";
$fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port";
}
// ALWAYS create base SERVICE_FQDN variable (host only, no scheme)
$resource->environment_variables()->firstOrCreate([
'key' => "SERVICE_FQDN_{$serviceNamePreserved}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnv,
'is_preview' => false,
]);
// ALWAYS create base SERVICE_URL variable (with scheme)
$resource->environment_variables()->firstOrCreate([
'key' => "SERVICE_URL_{$serviceNamePreserved}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
]);
// If port-specific, ALSO create port-specific pairs
if ($parsed['has_port'] && $port) {
$resource->environment_variables()->firstOrCreate([
'key' => "SERVICE_FQDN_{$serviceNamePreserved}_{$port}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnvWithPort,
'is_preview' => false,
]);
$resource->environment_variables()->firstOrCreate([
'key' => "SERVICE_URL_{$serviceNamePreserved}_{$port}",
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $urlWithPort,
'is_preview' => false,
]);
}
if ($resource->build_pack === 'dockercompose') {
// Match env-derived name to the real compose service key (hyphens/dots preserved).
$composeServiceName = findComposeServiceName($serviceName, array_keys($services));
// Only add domain if the service exists
if ($composeServiceName !== null) {
$domains = json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: [];
// Update domain using URL with port if applicable
$domainValue = $port ? $urlWithPort : $url;
if (! hasComposeServiceDomainEntry($domains, $composeServiceName)) {
$resource->docker_compose_domains = json_encode(putComposeServiceDomain(
$domains,
$composeServiceName,
$domainValue,
array_keys($services),
));
$resource->save();
}
}
}
} else {
$value = generateEnvValue($command, $resource);
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
]);
}
}
}
}
// generate SERVICE_NAME variables for docker compose services
$serviceNameEnvironments = collect([]);
if ($resource->build_pack === 'dockercompose') {
$serviceNameEnvironments = generateDockerComposeServiceName($services, $pullRequestId);
}
// Parse the rest of the services
$previewOwnVolumes = collect([]);
foreach ($services as $serviceName => $service) {
$image = data_get_str($service, 'image');
$restart = data_get_str($service, 'restart', RESTART_MODE);
$logging = data_get($service, 'logging');
if ($server->isLogDrainEnabled()) {
if ($resource->isLogDrainEnabled()) {
$logging = generate_fluentd_configuration();
}
}
$volumes = collect(data_get($service, 'volumes', []));
$networks = collect(data_get($service, 'networks', []));
$use_network_mode = data_get($service, 'network_mode') !== null;
$depends_on = collect(data_get($service, 'depends_on', []));
$labels = collect(data_get($service, 'labels', []));
if ($labels->count() > 0) {
if (isAssociativeArray($labels)) {
$newLabels = collect([]);
$labels->each(function ($value, $key) use ($newLabels) {
$newLabels->push("$key=$value");
});
$labels = $newLabels;
}
}
$environment = collect(data_get($service, 'environment', []));
$ports = collect(data_get($service, 'ports', []));
$buildArgs = collect(data_get($service, 'build.args', []));
$environment = $environment->merge($buildArgs);
$environment = convertToKeyValueCollection($environment);
$coolifyEnvironments = collect([]);
$isDatabase = isDatabaseImage($image, $service);
$volumesParsed = collect([]);
$baseName = generateApplicationContainerName(
application: $resource,
pull_request_id: $pullRequestId
);
$containerName = "$serviceName-$baseName";
$predefinedPort = null;
$originalResource = $resource;
if ($volumes->count() > 0) {
foreach ($volumes as $index => $volume) {
$type = null;
$source = null;
$target = null;
$content = null;
$isDirectory = false;
if (is_string($volume)) {
$parsed = parseDockerVolumeString($volume);
$source = $parsed['source'];
$target = $parsed['target'];
// Mode is available in $parsed['mode'] if needed
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) {
$type = str('bind');
if ($foundConfig) {
$content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
$isDirectory = true;
}
} else {
$type = str('volume');
}
} elseif (is_array($volume)) {
$type = data_get_str($volume, 'type');
$source = data_get_str($volume, 'source');
$target = data_get_str($volume, 'target');
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
validateComposeArrayVolumeSource($source->value());
}
validateComposeContentVolumeSource($volume);
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) {
$content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
$isDirectory = true;
}
}
}
if ($type->value() === 'bind') {
if ($source->value() === '/var/run/docker.sock') {
$volume = $source->value().':'.$target->value();
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
}
} elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
$volume = $source->value().':'.$target->value();
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
}
} else {
if ((int) $resource->compose_parsing_version >= 4) {
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
} else {
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
}
$source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path);
$isPreviewSuffixEnabled = $foundConfig
? (bool) data_get($foundConfig, 'is_preview_suffix_enabled', true)
: true;
if ($isPullRequest && $isPreviewSuffixEnabled) {
$source = str(addPreviewDeploymentSuffix($source, $pull_request_id));
}
LocalFileVolume::updateOrCreate(
[
'mount_path' => $target,
'resource_id' => $originalResource->id,
'resource_type' => get_class($originalResource),
],
[
'fs_path' => $source,
'mount_path' => $target,
'content' => $content,
'is_directory' => $isDirectory,
'resource_id' => $originalResource->id,
'resource_type' => get_class($originalResource),
]
);
// The file storage keeps the path that Coolify writes; the Docker daemon may need another one.
$source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value()));
$volume = "$source:$target";
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
}
}
} elseif ($type->value() === 'volume') {
// A preview never mounts an external or network (NFS, CIFS) volume: two deployments
// that write into the same data directory (for example two databases) can corrupt it.
// The preview gets its own volume with the name that older Coolify versions gave
// it, so it keeps its data, and the preview cleanup removes it.
$declaration = $topLevel->get('volumes')->get($source->value());
$isNetworkVolume = in_array(data_get($declaration, 'driver_opts.type'), ['cifs', 'nfs'], true);
$isPreviewOwnVolume = $isPullRequest
&& ($isNetworkVolume || composeExternalVolumeDeclaration($topLevel->get('volumes'), $source->value()) !== null);
if (! $isPullRequest && useComposeExternalVolumeAsWritten($resource, $originalResource, $topLevel->get('volumes'), $source->value(), "{$uuid}_".Str::slug($source, '-'))) {
// The external volume gets no row, so Coolify never removes it.
$volumesParsed->put($index, $volume);
continue;
}
if (! $isPullRequest && $isNetworkVolume) {
// Network volumes are used as written.
$volumesParsed->put($index, $volume);
continue;
}
if ($isPreviewOwnVolume) {
$previewOwnVolumes->put($source->value(), true);
}
$slugWithoutUuid = Str::slug($source, '-');
$name = "{$uuid}_{$slugWithoutUuid}";
if ($isPullRequest) {
$name = addPreviewDeploymentSuffix($name, $pull_request_id);
}
if (is_string($volume)) {
$parsed = parseDockerVolumeString($volume);
$source = $parsed['source'];
$target = $parsed['target'];
$source = $name;
$volume = "$source:$target";
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
}
} elseif (is_array($volume)) {
data_set($volume, 'source', $name);
}
$persistentVolume = LocalPersistentVolume::updateOrCreate(
[
'name' => $name,
'resource_id' => $originalResource->id,
'resource_type' => get_class($originalResource),
],
[
'name' => $name,
'mount_path' => $target,
'resource_id' => $originalResource->id,
'resource_type' => get_class($originalResource),
]
);
$topLevel->get('volumes')->put($name, $isPreviewOwnVolume
? ['name' => $name]
: composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume, $isPullRequest));
}
dispatch(new ServerFilesFromServerJob($originalResource));
$volumesParsed->put($index, $volume);
}
}
if ($depends_on?->count() > 0) {
if ($isPullRequest) {
$newDependsOn = collect([]);
$depends_on->each(function ($dependency, $condition) use ($pullRequestId, $newDependsOn) {
if (is_numeric($condition)) {
$dependency = addPreviewDeploymentSuffix($dependency, $pullRequestId);
$newDependsOn->put($condition, $dependency);
} else {
$condition = addPreviewDeploymentSuffix($condition, $pullRequestId);
$newDependsOn->put($condition, $dependency);
}
});
$depends_on = $newDependsOn;
}
}
if (! $use_network_mode) {
if ($topLevel->get('networks')?->count() > 0) {
foreach ($topLevel->get('networks') as $networkName => $network) {
if ($networkName === 'default') {
continue;
}
// ignore aliases
if ($network['aliases'] ?? false) {
continue;
}
$networkExists = $networks->contains(function ($value, $key) use ($networkName) {
return $value == $networkName || $key == $networkName;
});
if (! $networkExists) {
$networks->put($networkName, null);
}
}
}
$baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
return $value == $baseNetwork;
});
if (! $baseNetworkExists) {
foreach ($baseNetwork as $network) {
$topLevel->get('networks')->put($network, [
'name' => $network,
'external' => true,
]);
}
}
}
// Collect/create/update ports
$collectedPorts = collect([]);
if ($ports->count() > 0) {
foreach ($ports as $sport) {
if (is_string($sport) || is_numeric($sport)) {
$collectedPorts->push($sport);
}
if (is_array($sport)) {
$target = data_get($sport, 'target');
$published = data_get($sport, 'published');
$protocol = data_get($sport, 'protocol');
$collectedPorts->push("$target:$published/$protocol");
}
}
}
$networks_temp = collect();
if (! $use_network_mode) {
foreach ($networks as $key => $network) {
if (gettype($network) === 'string') {
// networks:
// - appwrite
$networks_temp->put($network, null);
} elseif (gettype($network) === 'array') {
// networks:
// default:
// ipv4_address: 192.168.203.254
$networks_temp->put($key, $network);
}
}
foreach ($baseNetwork as $key => $network) {
$networks_temp->put($network, null);
}
if (data_get($resource, 'settings.connect_to_docker_network')) {
$network = $resource->destination->network;
$networks_temp->put($network, null);
$topLevel->get('networks')->put($network, [
'name' => $network,
'external' => true,
]);
}
}
$normalEnvironments = $environment->diffKeys($allMagicEnvironments);
$normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
return ! str($value)->startsWith('SERVICE_');
});
foreach ($normalEnvironments as $key => $value) {
$key = str($key);
$value = str($value);
$originalValue = $value;
$parsedValue = replaceVariables($value);
if ($value->startsWith('$SERVICE_')) {
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
]);
continue;
}
if (! $value->startsWith('$')) {
continue;
}
if ($key->value() === $parsedValue->value()) {
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
// Ensure the variable exists in DB for .env generation and UI display
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
// Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
// Do NOT replace with DB value: if user updates env var without re-parsing compose,
// a stale resolved value in environment: would override the correct .env value.
} else {
if ($value->startsWith('$')) {
$isRequired = false;
// Extract variable content between ${...} using balanced brace matching
$result = extractBalancedBraceContent($value->value(), 0);
if ($result !== null) {
$content = $result['content'];
$split = splitOnOperatorOutsideNested($content);
if ($split !== null) {
// Has default value syntax (:-, -, :?, or ?)
$varName = $split['variable'];
$operator = $split['operator'];
$defaultValue = $split['default'];
$isRequired = str_contains($operator, '?');
// Create the primary variable with its default (only if it doesn't exist)
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $varName,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$varName] = $envVar->value;
// Recursively process nested variables in default value
if (str_contains($defaultValue, '${')) {
$searchPos = 0;
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
while ($nestedResult !== null) {
$nestedContent = $nestedResult['content'];
$nestedSplit = splitOnOperatorOutsideNested($nestedContent);
// Determine the nested variable name
$nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
// Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
$isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
if (! $isMagicVariable) {
if ($nestedSplit !== null) {
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedSplit['variable'],
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $nestedSplit['default'],
'is_preview' => false,
]);
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
} else {
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedContent,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
$environment[$nestedContent] = $nestedEnvVar->value;
}
}
$searchPos = $nestedResult['end'] + 1;
if ($searchPos >= strlen($defaultValue)) {
break;
}
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
}
}
} else {
// Simple variable reference without default
$parsedKeyValue = replaceVariables($value);
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $content,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment using the saved DB value
$environment[$content] = $envVar->value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value
$parsedKeyValue = replaceVariables($value);
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
]);
// Add the variable to the environment using the saved DB value
$environment[$parsedKeyValue->value()] = $envVar->value;
continue;
}
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
]);
}
}
}
}
$branch = $originalResource->git_branch;
if ($pullRequestId !== 0) {
$branch = "pull/{$pullRequestId}/head";
}
if ($originalResource->environment_variables->where('key', 'COOLIFY_BRANCH')->isEmpty()) {
$coolifyEnvironments->put('COOLIFY_BRANCH', "\"{$branch}\"");
}
// Add COOLIFY_RESOURCE_UUID to environment
if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
$coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
}
// Add COOLIFY_CONTAINER_NAME to environment
if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
$coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
}
if ($isPullRequest) {
$preview = $resource->previews()->find($preview_id);
$domains = collect(json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []);
} else {
$domains = collect(json_decode(data_get($resource, 'docker_compose_domains') ?: '[]', true) ?: []);
}
// Only process domains for dockercompose applications to prevent SERVICE variable recreation
if ($resource->build_pack !== 'dockercompose') {
$domains = collect([]);
}
// Prefer original compose service key; fall back to legacy underscore storage keys.
$fqdns = getComposeServiceDomainString($domains, (string) $serviceName);
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
if ($resource->build_pack === 'dockercompose') {
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = composeDomainEntryString($domain);
$serviceNameFormatted = str($serviceName)->upper()->replace('-', '_')->replace('.', '_');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$serviceEnvKey = str(normalizeComposeServiceName((string) $forServiceName))->upper();
$coolifyEnvironments->put('SERVICE_URL_'.$serviceEnvKey, $coolifyUrl->__toString());
$coolifyEnvironments->put('SERVICE_FQDN_'.$serviceEnvKey, $coolifyFqdn);
$resource->environment_variables()->updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $resource->id,
'key' => 'SERVICE_URL_'.$serviceEnvKey,
], [
'value' => $coolifyUrl->__toString(),
'is_preview' => false,
]);
$resource->environment_variables()->updateOrCreate([
'resourceable_type' => Application::class,
'resourceable_id' => $resource->id,
'key' => 'SERVICE_FQDN_'.$serviceEnvKey,
], [
'value' => $coolifyFqdn,
'is_preview' => false,
]);
} else {
$resource->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $resource->id)
->where('key', 'LIKE', "SERVICE_FQDN_{$serviceNameFormatted}%")
->update([
'value' => null,
]);
$resource->environment_variables()->where('resourceable_type', Application::class)
->where('resourceable_id', $resource->id)
->where('key', 'LIKE', "SERVICE_URL_{$serviceNameFormatted}%")
->update([
'value' => null,
]);
}
}
}
// If the domain is set, we need to generate the FQDNs for the preview
if (filled($fqdns)) {
$fqdns = str($fqdns)->explode(',');
if ($isPullRequest) {
$preview = $resource->previews()->find($preview_id);
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: []);
if ($docker_compose_domains->count() > 0) {
$found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName);
if ($found_fqdn) {
$fqdns = str($found_fqdn)->explode(',')->map(fn ($fqdn) => trim($fqdn))->filter();
} else {
$fqdns = collect([]);
}
} else {
$generatedDomains = $fqdns->map(
fn ($fqdn) => $preview->generatedPreviewDomain((string) $fqdn)
);
$fqdns = $generatedDomains->pluck('url');
$preview->fqdn = $fqdns->implode(',');
$preview->domain_port_overrides = $generatedDomains
->filter(fn (array $generated): bool => filled($generated['port']))
->mapWithKeys(fn (array $generated): array => [$generated['url'] => $generated['port']])
->all();
$preview->save();
}
}
}
$defaultLabels = defaultLabels(
uuid: $resource->uuid,
name: $containerName,
projectName: $resource->project()->name,
resourceName: $resource->name,
pull_request_id: $pullRequestId,
type: 'application',
environment: $resource->environment->name,
);
$isDatabase = isDatabaseImage($image, $service);
// Add COOLIFY_FQDN & COOLIFY_URL to environment
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(','));
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(','));
}
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
if ($environment->count() > 0) {
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
})->map(function ($value, $key) use ($resource) {
// Preserve empty strings and null values with correct Docker Compose semantics:
// - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
// - Null: Variable is unset/removed from container environment (may inherit from host)
if ($value === null) {
// User explicitly wants variable unset - respect that
// NEVER override from database - null means "inherit from environment"
// Keep as null (will be excluded from container environment)
} elseif ($value === '') {
// Empty string - allow database override for backward compatibility
$dbEnv = $resource->environment_variables()->where('key', $key)->first();
// Only use database override if it exists AND has a non-empty value
if ($dbEnv && str($dbEnv->value)->isNotEmpty()) {
$value = $dbEnv->value;
}
// Otherwise keep empty string as-is
}
// Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}
// Without this, literal {{...}} strings end up in the compose environment: section,
// which takes precedence over the resolved values in the .env file (env_file:)
if (is_string($value) && str_contains($value, '{{')) {
$value = resolveSharedEnvironmentVariables($value, $resource);
}
return $value;
});
}
$serviceLabels = $labels->merge($defaultLabels);
if ($serviceLabels->count() > 0) {
$isContainerLabelEscapeEnabled = data_get($resource, 'settings.is_container_label_escape_enabled');
if ($isContainerLabelEscapeEnabled) {
$serviceLabels = $serviceLabels->map(function ($value, $key) {
return escapeDollarSign($value);
});
}
}
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$shouldGenerateLabelsExactly = $resource->destination->server->settings->generate_exact_labels;
$labelUuid = $resource->uuid;
$labelNetwork = data_get($resource, 'destination.network');
if ($isPullRequest) {
$labelUuid = "{$resource->uuid}-{$pullRequestId}";
}
if ($isPullRequest) {
$labelNetwork = "{$resource->destination->network}-{$pullRequestId}";
}
$noindexDomains = $isPullRequest ? $fqdns : $originalResource->noindexDomains();
$domainServiceName = findComposeServiceName((string) $serviceName, $domains->keys());
$composeRedirect = data_get($domains->get($domainServiceName), 'redirect');
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
? $composeRedirect
: 'both';
$previewForPorts = $isPullRequest
? ($resource->previews()->find($preview_id) ?? ApplicationPreview::where('application_id', $resource->id)->where('pull_request_id', $pullRequestId)->first())
: null;
$domainPortOverrides = $isPullRequest
? ($previewForPorts?->domain_port_overrides ?? [])
: ($originalResource->domain_port_overrides ?? []);
$onlyPort = firstDockerComposeServicePort($service);
$isTrafficAnalyticsEnabled = (bool) $server?->isTrafficAnalyticsEnabled();
$supportsLogAppend = (bool) $server?->caddySupportsLogAppend();
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
));
break;
case ProxyTypes::CADDY->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $labelNetwork,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
predefinedPort: $predefinedPort,
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
is_traffic_analytics_enabled: $isTrafficAnalyticsEnabled,
supports_log_append: $supportsLogAppend,
));
break;
}
} else {
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $labelNetwork,
uuid: $labelUuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
predefinedPort: $predefinedPort,
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
domainPortOverrides: $domainPortOverrides,
is_traffic_analytics_enabled: $isTrafficAnalyticsEnabled,
supports_log_append: $supportsLogAppend,
));
}
}
data_forget($service, 'volumes.*.content');
data_forget($service, 'volumes.*.isDirectory');
data_forget($service, 'volumes.*.is_directory');
data_forget($service, 'exclude_from_hc');
$volumesParsed = $volumesParsed->map(function ($volume) {
data_forget($volume, 'content');
data_forget($volume, 'is_directory');
data_forget($volume, 'isDirectory');
return $volume;
});
$payload = collect($service)->merge([
'container_name' => $containerName,
'restart' => $restart->value(),
'labels' => $serviceLabels,
]);
if (! $use_network_mode) {
$payload['networks'] = $networks_temp;
}
if ($ports->count() > 0) {
$payload['ports'] = $ports;
}
if ($volumesParsed->count() > 0) {
$payload['volumes'] = $volumesParsed;
}
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
$payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
}
if ($logging) {
$payload['logging'] = $logging;
}
if ($depends_on->count() > 0) {
$payload['depends_on'] = $depends_on;
}
// Auto-inject .env file so Coolify environment variables are available inside containers
// This makes Applications behave consistently with manual .env file usage
$existingEnvFiles = data_get($service, 'env_file');
$envFiles = collect(is_null($existingEnvFiles) ? [] : (is_array($existingEnvFiles) ? $existingEnvFiles : [$existingEnvFiles]))
->push('.env')
->unique()
->values();
$payload['env_file'] = $envFiles;
// Inject commit-based image tag for services with build directive (for rollback support)
// Only inject if service has build but no explicit image defined
$hasBuild = data_get($service, 'build') !== null;
$hasImage = data_get($service, 'image') !== null;
if ($hasBuild && ! $hasImage && $commit) {
$imageTag = str($commit)->substr(0, 128)->value();
if ($isPullRequest) {
$imageTag = "pr-{$pullRequestId}";
}
$imageRepo = "{$uuid}_{$serviceName}";
$payload['image'] = "{$imageRepo}:{$imageTag}";
}
if ($isPullRequest) {
$serviceName = addPreviewDeploymentSuffix($serviceName, $pullRequestId);
}
$parsedServices->put($serviceName, $payload);
}
// The preview mounts its own volumes instead of these external and network volumes (see above), so it does not declare them.
$topLevel->put('volumes', $topLevel->get('volumes')->except($previewOwnVolumes->keys()->all()));
$topLevel->put('services', $parsedServices);
$customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
$topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
return array_search($key, $customOrder);
});
// Remove empty top-level sections (volumes, networks, configs, secrets)
// Keep only non-empty sections to match Docker Compose best practices
$topLevel = $topLevel->filter(function ($value, $key) {
// Always keep 'services' section
if ($key === 'services') {
return true;
}
// Keep section only if it has content
return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value);
});
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
// Update docker_compose_raw to remove content: from volumes only
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = parseDockerComposeYaml($originalCompose);
$originalYamlBeforeCleanup = $originalYaml;
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
foreach ($originalYaml['services'] as $serviceName => &$service) {
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $key => &$volume) {
if (is_array($volume)) {
unset($volume['content']);
unset($volume['isDirectory']);
unset($volume['is_directory']);
}
}
}
}
}
if ($originalYaml !== $originalYamlBeforeCleanup) {
$resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments($originalCompose, $originalYaml, ['content', 'isDirectory', 'is_directory']);
}
} catch (Exception) {
// If parsing fails, keep the original docker_compose_raw unchanged
}
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->save();
return $topLevel;
}
function serviceParser(Service $resource): Collection
{
$resource->resetComposeVolumeWarnings();
$uuid = data_get($resource, 'uuid');
$compose = data_get($resource, 'docker_compose_raw');
// Store original compose for later use to update docker_compose_raw with content removed
$originalCompose = $compose;
if (! $compose) {
return collect([]);
}
// Extract inline comments from raw YAML before Symfony parser discards them
$envComments = extractYamlEnvironmentComments($compose);
$server = data_get($resource, 'server');
try {
$yaml = parseDockerComposeYaml($compose);
} catch (Exception) {
return collect([]);
}
$services = data_get($yaml, 'services', collect([]));
// Clean up corrupted environment variables from previous parser bugs
// (keys starting with $ or ending with } should not exist as env var names)
$resource->environment_variables()
->where('resourceable_type', get_class($resource))
->where('resourceable_id', $resource->id)
->where(function ($q) {
$q->where('key', 'LIKE', '$%')
->orWhere('key', 'LIKE', '%}');
})
->delete();
$topLevel = collect([
'volumes' => collect(data_get($yaml, 'volumes', [])),
'networks' => collect(data_get($yaml, 'networks', [])),
'configs' => collect(data_get($yaml, 'configs', [])),
'secrets' => collect(data_get($yaml, 'secrets', [])),
]);
ensureComposeNetworkNameVariables($resource, $topLevel->get('networks'));
// If there are predefined volumes, make sure they are not null
if ($topLevel->get('volumes')->count() > 0) {
$temp = collect([]);
foreach ($topLevel['volumes'] as $volumeName => $volume) {
if (is_null($volume)) {
continue;
}
$temp->put($volumeName, $volume);
}
$topLevel['volumes'] = $temp;
}
// Get the base docker network
$baseNetwork = collect([$uuid]);
$parsedServices = collect([]);
// Generate SERVICE_NAME variables for docker compose services
$serviceNameEnvironments = generateDockerComposeServiceName($services);
$allMagicEnvironments = collect([]);
// Presave services
foreach ($services as $serviceName => $service) {
// Validate service name for command injection
try {
validateShellSafePath($serviceName, 'service name');
} catch (Exception $e) {
throw new Exception(
'Invalid Docker Compose service name: '.$e->getMessage().
' Service names must not contain shell metacharacters.'
);
}
$image = data_get_str($service, 'image');
// Check for manually migrated services first (respects user's conversion choice)
$migratedApp = ServiceApplication::where('name', $serviceName)
->where('service_id', $resource->id)
->where('is_migrated', true)
->first();
$migratedDb = ServiceDatabase::where('name', $serviceName)
->where('service_id', $resource->id)
->where('is_migrated', true)
->first();
if ($migratedApp || $migratedDb) {
// Use the migrated service type, ignoring image detection
$isDatabase = (bool) $migratedDb;
$savedService = $migratedApp ?: $migratedDb;
} else {
// Use image detection for non-migrated services
$isDatabase = isDatabaseImage($image, $service);
if ($isDatabase) {
$databaseFound = ServiceDatabase::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($databaseFound) {
$savedService = $databaseFound;
} else {
$savedService = ServiceDatabase::create([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
} else {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
} else {
$savedService = ServiceApplication::create([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
}
}
// Update image if it changed
if ($savedService->image !== $image) {
$savedService->image = $image;
$savedService->save();
}
}
foreach ($services as $serviceName => $service) {
$predefinedPort = null;
$magicEnvironments = collect([]);
$image = data_get_str($service, 'image');
$environment = collect(data_get($service, 'environment', []));
$buildArgs = collect(data_get($service, 'build.args', []));
$environment = $environment->merge($buildArgs);
// Check for manually migrated services first (respects user's conversion choice)
$migratedApp = ServiceApplication::where('name', $serviceName)
->where('service_id', $resource->id)
->where('is_migrated', true)
->first();
$migratedDb = ServiceDatabase::where('name', $serviceName)
->where('service_id', $resource->id)
->where('is_migrated', true)
->first();
if ($migratedApp || $migratedDb) {
// Use the migrated service type, ignoring image detection
$isDatabase = (bool) $migratedDb;
} else {
// Use image detection for non-migrated services
$isDatabase = isDatabaseImage($image, $service);
}
$containerName = "$serviceName-{$resource->uuid}";
$predefinedPort = $resource->getRequiredPort();
if ($migratedApp || $migratedDb) {
// Use the already determined migrated service
$savedService = $migratedApp ?: $migratedDb;
} elseif ($isDatabase) {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
} else {
$savedService = ServiceDatabase::firstOrCreate([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
} else {
$savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName,
'service_id' => $resource->id,
], [
'is_gzip_enabled' => true,
]);
}
// Check if image changed
if ($savedService->image !== $image) {
$savedService->image = $image;
$savedService->save();
}
// Pocketbase does not need gzip for SSE.
if (str($savedService->image)->contains('pocketbase') && $savedService->is_gzip_enabled) {
$savedService->is_gzip_enabled = false;
$savedService->save();
}
$environment = collect(data_get($service, 'environment', []));
$buildArgs = collect(data_get($service, 'build.args', []));
$environment = $environment->merge($buildArgs);
// convert environment variables to one format
$environment = convertToKeyValueCollection($environment);
// Add Coolify defined environments
$allEnvironments = $resource->environment_variables()->get(['key', 'value']);
$allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
return [$item['key'] => $item['value']];
});
// filter and add magic environments
foreach ($environment as $key => $value) {
// Get all SERVICE_ variables from keys and values
$key = str($key);
$value = str($value);
$regex = '/\$(\{?([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\}?)/';
preg_match_all($regex, $value, $valueMatches);
if (count($valueMatches[2]) > 0) {
foreach ($valueMatches[2] as $match) {
$match = str($match);
if ($match->startsWith('SERVICE_')) {
if ($magicEnvironments->has($match->value())) {
continue;
}
$magicEnvironments->put($match->value(), '');
}
}
}
// Get magic environments where we need to preset the FQDN / URL
if ($key->startsWith('SERVICE_FQDN_') || $key->startsWith('SERVICE_URL_')) {
// SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 or SERVICE_URL_APP or SERVICE_URL_APP_3000
// ALWAYS create BOTH SERVICE_URL and SERVICE_FQDN pairs regardless of which one is in template
$parsed = parseServiceEnvironmentVariable($key->value());
// Extract service name preserving original case from template
$strKey = str($key->value());
if ($parsed['has_port']) {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->beforeLast('_')->value();
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
$serviceName = $strKey->after('SERVICE_FQDN_')->beforeLast('_')->value();
} else {
continue;
}
} else {
if ($strKey->startsWith('SERVICE_URL_')) {
$serviceName = $strKey->after('SERVICE_URL_')->value();
} elseif ($strKey->startsWith('SERVICE_FQDN_')) {
$serviceName = $strKey->after('SERVICE_FQDN_')->value();
} else {
continue;
}
}
$port = $parsed['port'];
$fqdnFor = $parsed['service_name'];
// Only ServiceApplication has fqdn column, ServiceDatabase does not
$isServiceApplication = $savedService instanceof ServiceApplication;
if ($isServiceApplication && blank($savedService->fqdn)) {
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
$url = generateUrl($server, "$fqdnFor-$uuid");
} elseif ($isServiceApplication) {
// FQDN may be a comma-separated list; use the first entry (same as updateCompose).
$firstFqdn = firstDomainFromList($savedService->fqdn);
$fqdn = getFqdnWithoutPort($firstFqdn);
$url = $fqdn;
} else {
// For ServiceDatabase, generate fqdn/url without saving to the model
$fqdn = generateFqdn(server: $server, random: "$fqdnFor-$uuid", parserVersion: $resource->compose_parsing_version);
$url = generateUrl($server, "$fqdnFor-$uuid");
}
// IMPORTANT: SERVICE_FQDN env vars should NOT contain scheme (host only)
// But $fqdn variable itself may contain scheme (used for database domain field)
// Strip scheme for environment variable values
$fqdnValueForEnv = str($fqdn)->after('://')->value();
if ($value && get_class($value) === Stringable::class && $value->startsWith('/')) {
$path = $value->value();
if ($path !== '/') {
// Only add path if it's not already present (prevents duplication on subsequent parse() calls)
if (! str($fqdn)->endsWith($path)) {
$fqdn = "$fqdn$path";
}
if (! str($url)->endsWith($path)) {
$url = "$url$path";
}
if (! str($fqdnValueForEnv)->endsWith($path)) {
$fqdnValueForEnv = "$fqdnValueForEnv$path";
}
}
}
$urlWithPort = $url;
$fqdnValueForEnvWithPort = $fqdnValueForEnv;
if ($fqdn && $port) {
$fqdnValueForEnvWithPort = "$fqdnValueForEnv:$port";
}
if ($url && $port) {
$urlWithPort = "$url:$port";
}
// Only save fqdn to ServiceApplication, not ServiceDatabase
if ($isServiceApplication && is_null($savedService->fqdn)) {
// Save URL (with scheme) to database, not FQDN
$savedService->fqdn = $url;
$savedService->save();
}
// ALWAYS create BOTH base SERVICE_URL and SERVICE_FQDN pairs (without port)
$fqdnKey = "SERVICE_FQDN_{$serviceName}";
$resource->environment_variables()->updateOrCreate([
'key' => $fqdnKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnv,
'is_preview' => false,
'comment' => $envComments[$fqdnKey] ?? null,
]);
$urlKey = "SERVICE_URL_{$serviceName}";
$resource->environment_variables()->updateOrCreate([
'key' => $urlKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$urlKey] ?? null,
]);
// For port-specific variables, ALSO create port-specific pairs
// If template variable has port, create both URL and FQDN with port suffix
if ($parsed['has_port'] && $port) {
$fqdnPortKey = "SERVICE_FQDN_{$serviceName}_{$port}";
$resource->environment_variables()->updateOrCreate([
'key' => $fqdnPortKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdnValueForEnvWithPort,
'is_preview' => false,
'comment' => $envComments[$fqdnPortKey] ?? null,
]);
$urlPortKey = "SERVICE_URL_{$serviceName}_{$port}";
$resource->environment_variables()->updateOrCreate([
'key' => $urlPortKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $urlWithPort,
'is_preview' => false,
'comment' => $envComments[$urlPortKey] ?? null,
]);
}
}
}
$allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments);
if ($magicEnvironments->count() > 0) {
foreach ($magicEnvironments as $magicKey => $value) {
$originalMagicKey = $magicKey; // Preserve original key for comment lookup
$key = str($magicKey);
$value = replaceVariables($value);
$command = parseCommandFromMagicEnvVariable($key);
if ($command->value() === 'FQDN') {
$fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value();
$fqdn = generateFqdn(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
$url = generateUrl(server: $server, random: str($fqdnFor)->replace('_', '-')->value()."-$uuid");
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
// Also check if a port-suffixed version exists (e.g., SERVICE_FQDN_UMAMI_3000)
$portSuffixedExists = $resource->environment_variables()
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->exists();
$serviceExists = findServiceApplicationForEnvName($resource, (string) $fqdnFor);
// Check if FQDN already has a port set (contains ':' after the domain)
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
// Only set FQDN if it's for the current service being processed (prevent race conditions)
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService) {
// Save URL otherwise it won't work.
$serviceExists->fqdn = $url;
$serviceExists->save();
}
// Create FQDN variable (use firstOrCreate to avoid overwriting values
// already set by direct template declarations or updateCompose)
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
// Also create the paired SERVICE_URL_* variable
$urlKey = 'SERVICE_URL_'.strtoupper($fqdnFor);
$resource->environment_variables()->firstOrCreate([
'key' => $urlKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$urlKey] ?? null,
]);
} elseif ($command->value() === 'URL') {
$urlFor = $key->after('SERVICE_URL_')->lower()->value();
$url = generateUrl(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid");
$fqdn = generateFqdn(server: $server, random: str($urlFor)->replace('_', '-')->value()."-$uuid", parserVersion: $resource->compose_parsing_version);
$envExists = $resource->environment_variables()->where('key', $key->value())->first();
// Also check if a port-suffixed version exists (e.g., SERVICE_URL_DASHBOARD_6791)
$portSuffixedExists = $resource->environment_variables()
->where('key', 'LIKE', $key->value().'_%')
->whereRaw('key ~ ?', ['^'.$key->value().'_[0-9]+$'])
->exists();
$serviceExists = findServiceApplicationForEnvName($resource, (string) $urlFor);
// Check if FQDN already has a port set (contains ':' after the domain)
$fqdnHasPort = $serviceExists && str($serviceExists->fqdn)->contains(':') && str($serviceExists->fqdn)->afterLast(':')->isMatch('/^\d+$/');
// Only set FQDN if it's for the current service being processed (prevent race conditions)
$isCurrentService = $serviceExists && $serviceExists->id === $savedService->id;
if (! $envExists && ! $portSuffixedExists && ! $fqdnHasPort && $isCurrentService) {
$serviceExists->fqdn = $url;
$serviceExists->save();
}
// Create URL variable (use firstOrCreate to avoid overwriting values
// already set by direct template declarations or updateCompose)
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $url,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
// Also create the paired SERVICE_FQDN_* variable
$fqdnKey = 'SERVICE_FQDN_'.strtoupper($urlFor);
$resource->environment_variables()->firstOrCreate([
'key' => $fqdnKey,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $fqdn,
'is_preview' => false,
'comment' => $envComments[$fqdnKey] ?? null,
]);
} else {
$value = generateEnvValue($command, $resource);
$resource->environment_variables()->firstOrCreate([
'key' => $key->value(),
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalMagicKey] ?? null,
]);
}
}
}
}
$serviceAppsLogDrainEnabledMap = $resource->applications()->get()->keyBy('name')->map(function ($app) {
return $app->isLogDrainEnabled();
});
// Parse the rest of the services
foreach ($services as $serviceName => $service) {
$image = data_get_str($service, 'image');
$restart = data_get_str($service, 'restart', RESTART_MODE);
$logging = data_get($service, 'logging');
if ($server->isLogDrainEnabled()) {
if ($serviceAppsLogDrainEnabledMap->get($serviceName)) {
$logging = generate_fluentd_configuration();
}
}
$volumes = collect(data_get($service, 'volumes', []));
$networks = collect(data_get($service, 'networks', []));
$use_network_mode = data_get($service, 'network_mode') !== null;
$depends_on = collect(data_get($service, 'depends_on', []));
$labels = collect(data_get($service, 'labels', []));
if ($labels->count() > 0) {
if (isAssociativeArray($labels)) {
$newLabels = collect([]);
$labels->each(function ($value, $key) use ($newLabels) {
$newLabels->push("$key=$value");
});
$labels = $newLabels;
}
}
$environment = collect(data_get($service, 'environment', []));
$ports = collect(data_get($service, 'ports', []));
$buildArgs = collect(data_get($service, 'build.args', []));
$environment = $environment->merge($buildArgs);
$environment = convertToKeyValueCollection($environment);
$coolifyEnvironments = collect([]);
// Check for manually migrated services first (respects user's conversion choice)
$migratedApp = ServiceApplication::where('name', $serviceName)
->where('service_id', $resource->id)
->where('is_migrated', true)
->first();
$migratedDb = ServiceDatabase::where('name', $serviceName)
->where('service_id', $resource->id)
->where('is_migrated', true)
->first();
if ($migratedApp || $migratedDb) {
// Use the migrated service type, ignoring image detection
$isDatabase = (bool) $migratedDb;
$savedService = $migratedApp ?: $migratedDb;
} else {
// Use image detection for non-migrated services
$isDatabase = isDatabaseImage($image, $service);
}
$volumesParsed = collect([]);
$containerName = "$serviceName-{$resource->uuid}";
$predefinedPort = $resource->getRequiredPort();
if ($migratedApp || $migratedDb) {
// Use the already determined migrated service
$savedService = $migratedApp ?: $migratedDb;
} elseif ($isDatabase) {
$applicationFound = ServiceApplication::where('name', $serviceName)->where('service_id', $resource->id)->first();
if ($applicationFound) {
$savedService = $applicationFound;
} else {
$savedService = ServiceDatabase::firstOrCreate([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
} else {
$savedService = ServiceApplication::firstOrCreate([
'name' => $serviceName,
'service_id' => $resource->id,
]);
}
if ($savedService->image !== $image) {
$savedService->image = $image;
$savedService->save();
}
$originalResource = $savedService;
if ($volumes->count() > 0) {
foreach ($volumes as $index => $volume) {
$type = null;
$source = null;
$target = null;
$content = null;
$isDirectory = false;
if (is_string($volume)) {
$parsed = parseDockerVolumeString($volume);
$source = $parsed['source'];
$target = $parsed['target'];
// Mode is available in $parsed['mode'] if needed
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) {
$type = str('bind');
if ($foundConfig) {
$content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// By default, we cannot determine if the bind is a directory or not, so we set it to directory
$isDirectory = true;
}
} else {
$type = str('volume');
}
} elseif (is_array($volume)) {
$type = data_get_str($volume, 'type');
$source = data_get_str($volume, 'source');
$target = data_get_str($volume, 'target');
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
validateComposeArrayVolumeSource($source->value());
}
validateComposeContentVolumeSource($volume);
if ($target !== null && ! empty($target->value())) {
try {
validateShellSafePath($target->value(), 'volume target');
} catch (Exception $e) {
throw new Exception(
'Invalid Docker volume definition (array syntax): '.$e->getMessage().
' Please use safe path names without shell metacharacters.'
);
}
}
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if ($foundConfig) {
$content = data_get($foundConfig, 'content');
$isDirectory = data_get($foundConfig, 'is_directory');
} else {
// if isDirectory is not set (or false) & content is also not set, we assume it is a directory
if ((is_null($isDirectory) || ! $isDirectory) && is_null($content)) {
$isDirectory = true;
}
}
}
if ($type->value() === 'bind') {
if ($source->value() === '/var/run/docker.sock') {
$volume = $source->value().':'.$target->value();
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
}
} elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
$volume = $source->value().':'.$target->value();
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
}
} else {
if ((int) $resource->compose_parsing_version >= 4) {
$mainDirectory = str(base_configuration_dir().'/services/'.$uuid);
} else {
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
}
$source = resolveComposeBindSource($source, $mainDirectory, $foundConfig?->fs_path);
LocalFileVolume::updateOrCreate(
[
'mount_path' => $target,
'resource_id' => $originalResource->id,
'resource_type' => get_class($originalResource),
],
[
'fs_path' => $source,
'mount_path' => $target,
'content' => $content,
'is_directory' => $isDirectory,
'resource_id' => $originalResource->id,
'resource_type' => get_class($originalResource),
]
);
// The file storage keeps the path that Coolify writes; the Docker daemon may need another one.
$source = $source->replace($mainDirectory, devHostDockerPath($server, $mainDirectory->value()));
$volume = "$source:$target";
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
}
}
} elseif ($type->value() === 'volume') {
if (useComposeExternalVolumeAsWritten($resource, $originalResource, $topLevel->get('volumes'), $source->value(), "{$uuid}_".Str::slug($source, '-'))) {
// The external volume gets no row, so Coolify never removes it.
$volumesParsed->put($index, $volume);
continue;
}
$declaration = $topLevel->get('volumes')->get($source->value());
if (in_array(data_get($declaration, 'driver_opts.type'), ['cifs', 'nfs'], true)) {
// Network volumes are used as written.
$volumesParsed->put($index, $volume);
continue;
}
$slugWithoutUuid = Str::slug($source, '-');
$name = "{$uuid}_{$slugWithoutUuid}";
if (is_string($volume)) {
$parsed = parseDockerVolumeString($volume);
$source = $parsed['source'];
$target = $parsed['target'];
$source = $name;
$volume = "$source:$target";
if (isset($parsed['mode']) && $parsed['mode']) {
$volume .= ':'.$parsed['mode']->value();
}
} elseif (is_array($volume)) {
data_set($volume, 'source', $name);
}
$persistentVolume = LocalPersistentVolume::updateOrCreate(
[
'name' => $name,
'resource_id' => $originalResource->id,
'resource_type' => get_class($originalResource),
],
[
'name' => $name,
'mount_path' => $target,
'resource_id' => $originalResource->id,
'resource_type' => get_class($originalResource),
]
);
$topLevel->get('volumes')->put($name, composeRenamedVolumeDeclarationFor($declaration, $name, $persistentVolume));
}
dispatch(new ServerFilesFromServerJob($originalResource));
$volumesParsed->put($index, $volume);
}
}
if (! $use_network_mode) {
if ($topLevel->get('networks')?->count() > 0) {
foreach ($topLevel->get('networks') as $networkName => $network) {
if ($networkName === 'default') {
continue;
}
// ignore aliases
if ($network['aliases'] ?? false) {
continue;
}
$networkExists = $networks->contains(function ($value, $key) use ($networkName) {
return $value == $networkName || $key == $networkName;
});
if (! $networkExists) {
$networks->put($networkName, null);
}
}
}
$baseNetworkExists = $networks->contains(function ($value, $_) use ($baseNetwork) {
return $value == $baseNetwork;
});
if (! $baseNetworkExists) {
foreach ($baseNetwork as $network) {
$topLevel->get('networks')->put($network, [
'name' => $network,
'external' => true,
]);
}
}
}
// Collect/create/update ports
$collectedPorts = collect([]);
if ($ports->count() > 0) {
foreach ($ports as $sport) {
if (is_string($sport) || is_numeric($sport)) {
$collectedPorts->push($sport);
}
if (is_array($sport)) {
$target = data_get($sport, 'target');
$published = data_get($sport, 'published');
$protocol = data_get($sport, 'protocol');
$collectedPorts->push("$target:$published/$protocol");
}
}
}
$originalResource->ports = $collectedPorts->implode(',');
$originalResource->save();
$networks_temp = collect();
if (! $use_network_mode) {
foreach ($networks as $key => $network) {
if (gettype($network) === 'string') {
// networks:
// - appwrite
$networks_temp->put($network, null);
} elseif (gettype($network) === 'array') {
// networks:
// default:
// ipv4_address: 192.168.203.254
$networks_temp->put($key, $network);
}
}
foreach ($baseNetwork as $key => $network) {
$networks_temp->put($network, null);
}
}
$normalEnvironments = $environment->diffKeys($allMagicEnvironments);
$normalEnvironments = $normalEnvironments->filter(function ($value, $key) {
return ! str($value)->startsWith('SERVICE_');
});
foreach ($normalEnvironments as $key => $value) {
$originalKey = $key; // Preserve original key for comment lookup
$key = str($key);
$value = str($value);
$originalValue = $value;
$parsedValue = replaceVariables($value);
if ($parsedValue->startsWith('SERVICE_')) {
$resource->environment_variables()->updateOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
continue;
}
if (! $value->startsWith('$')) {
continue;
}
if ($key->value() === $parsedValue->value()) {
// Simple variable reference (e.g. DATABASE_URL: ${DATABASE_URL})
// Ensure the variable exists in DB for .env generation and UI display
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'comment' => $envComments[$originalKey] ?? null,
]);
// Keep the ${VAR} reference in compose — Docker Compose resolves from .env at deploy time.
// Do NOT replace with DB value: if user updates env var without re-parsing compose,
// a stale resolved value in environment: would override the correct .env value.
} else {
if ($value->startsWith('$')) {
$isRequired = false;
// Extract variable content between ${...} using balanced brace matching
$result = extractBalancedBraceContent($value->value(), 0);
if ($result !== null) {
$content = $result['content'];
$split = splitOnOperatorOutsideNested($content);
if ($split !== null) {
// Has default value syntax (:-, -, :?, or ?)
$varName = $split['variable'];
$operator = $split['operator'];
$defaultValue = $split['default'];
$isRequired = str_contains($operator, '?');
// Create the primary variable with its default (only if it doesn't exist)
// Use firstOrCreate instead of updateOrCreate to avoid overwriting user edits
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $varName,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $defaultValue,
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment so it will be shown in the deployable compose file
$environment[$varName] = $envVar->value;
// Recursively process nested variables in default value
if (str_contains($defaultValue, '${')) {
// Extract and create nested variables
$searchPos = 0;
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
while ($nestedResult !== null) {
$nestedContent = $nestedResult['content'];
$nestedSplit = splitOnOperatorOutsideNested($nestedContent);
// Determine the nested variable name
$nestedVarName = $nestedSplit !== null ? $nestedSplit['variable'] : $nestedContent;
// Skip SERVICE_URL_* and SERVICE_FQDN_* variables - they are handled by magic variable system
$isMagicVariable = str_starts_with($nestedVarName, 'SERVICE_URL_') || str_starts_with($nestedVarName, 'SERVICE_FQDN_');
if (! $isMagicVariable) {
if ($nestedSplit !== null) {
// Create nested variable with its default (only if it doesn't exist)
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedSplit['variable'],
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $nestedSplit['default'],
'is_preview' => false,
]);
// Add nested variable to environment
$environment[$nestedSplit['variable']] = $nestedEnvVar->value;
} else {
// Simple nested variable without default (only if it doesn't exist)
$nestedEnvVar = $resource->environment_variables()->firstOrCreate([
'key' => $nestedContent,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
]);
// Add nested variable to environment
$environment[$nestedContent] = $nestedEnvVar->value;
}
}
// Look for more nested variables
$searchPos = $nestedResult['end'] + 1;
if ($searchPos >= strlen($defaultValue)) {
break;
}
$nestedResult = extractBalancedBraceContent($defaultValue, $searchPos);
}
}
} else {
// Simple variable reference without default
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $content,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment using the saved DB value
$environment[$content] = $envVar->value;
}
} else {
// Fallback to old behavior for malformed input (backward compatibility)
if ($value->contains(':-')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':-');
} elseif ($value->contains('-')) {
$value = replaceVariables($value);
$key = $value->before('-');
$value = $value->after('-');
} elseif ($value->contains(':?')) {
$value = replaceVariables($value);
$key = $value->before(':');
$value = $value->after(':?');
$isRequired = true;
} elseif ($value->contains('?')) {
$value = replaceVariables($value);
$key = $value->before('?');
$value = $value->after('?');
$isRequired = true;
}
if ($originalValue->value() === $value->value()) {
// This means the variable does not have a default value
// Use firstOrCreate to avoid overwriting user-saved values on redeploy
$parsedKeyValue = replaceVariables($value);
$envVar = $resource->environment_variables()->firstOrCreate([
'key' => $parsedKeyValue,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
// Add the variable to the environment using the saved DB value
$environment[$parsedKeyValue->value()] = $envVar->value;
continue;
}
// Variable with a default value from compose — use firstOrCreate to preserve user edits
$resource->environment_variables()->firstOrCreate([
'key' => $key,
'resourceable_type' => get_class($resource),
'resourceable_id' => $resource->id,
], [
'value' => $value,
'is_preview' => false,
'is_required' => $isRequired,
'comment' => $envComments[$originalKey] ?? null,
]);
}
}
}
}
// Add COOLIFY_RESOURCE_UUID to environment
if ($resource->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
$coolifyEnvironments->put('COOLIFY_RESOURCE_UUID', "{$resource->uuid}");
}
// Add COOLIFY_CONTAINER_NAME to environment
if ($resource->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
$coolifyEnvironments->put('COOLIFY_CONTAINER_NAME', "{$containerName}");
}
if ($savedService->serviceType()) {
$fqdns = generateServiceSpecificFqdns($savedService);
} else {
$fqdns = collect(data_get($savedService, 'fqdns'))->filter();
}
// Flags live on the ServiceApplication; a ServiceDatabase has no domains.
$noindexDomains = $savedService instanceof ServiceApplication
? $savedService->noindexDomains()
: collect([]);
$defaultLabels = defaultLabels(
uuid: $resource->uuid,
name: $containerName,
projectName: $resource->project()->name,
resourceName: $resource->name,
type: 'service',
subType: $savedService instanceof ServiceDatabase ? 'database' : 'application',
subUuid: $savedService->uuid,
subName: $savedService->human_name ?? $savedService->name,
environment: $resource->environment->name,
);
// Add COOLIFY_FQDN & COOLIFY_URL to environment
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$coolifyEnvironments->put('COOLIFY_FQDN', $fqdns->map(fn ($fqdn) => getHostWithoutPort($fqdn))->implode(','));
$coolifyEnvironments->put('COOLIFY_URL', $fqdns->map(fn ($fqdn) => getFqdnWithoutPort($fqdn))->implode(','));
}
add_coolify_default_environment_variables($resource, $coolifyEnvironments, $resource->environment_variables);
if ($environment->count() > 0) {
$environment = $environment->filter(function ($value, $key) {
return ! str($key)->startsWith('SERVICE_FQDN_');
})->map(function ($value, $key) use ($resource) {
// Preserve empty strings and null values with correct Docker Compose semantics:
// - Empty string: Variable is set to "" (e.g., HTTP_PROXY="" means "no proxy")
// - Null: Variable is unset/removed from container environment (may inherit from host)
if ($value === null) {
// User explicitly wants variable unset - respect that
// NEVER override from database - null means "inherit from environment"
// Keep as null (will be excluded from container environment)
} elseif ($value === '') {
// Empty string - allow database override for backward compatibility
$dbEnv = $resource->environment_variables()->where('key', $key)->first();
// Only use database override if it exists AND has a non-empty value
if ($dbEnv && str($dbEnv->value)->isNotEmpty()) {
$value = $dbEnv->value;
}
// Otherwise keep empty string as-is
}
// Resolve shared variable patterns like {{environment.VAR}}, {{project.VAR}}, {{team.VAR}}
// Without this, literal {{...}} strings end up in the compose environment: section,
// which takes precedence over the resolved values in the .env file (env_file:)
if (is_string($value) && str_contains($value, '{{')) {
$value = resolveSharedEnvironmentVariables($value, $resource);
}
return $value;
});
}
$serviceLabels = $labels->merge($defaultLabels);
if ($serviceLabels->count() > 0) {
$isContainerLabelEscapeEnabled = data_get($resource, 'is_container_label_escape_enabled');
if ($isContainerLabelEscapeEnabled) {
$serviceLabels = $serviceLabels->map(function ($value, $key) {
return escapeDollarSign($value);
});
}
}
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
$uuid = $resource->uuid;
$network = data_get($resource, 'destination.network');
$redirectDirection = in_array(data_get($originalResource, 'redirect'), ['www', 'non-www', 'both'], true)
? data_get($originalResource, 'redirect')
: 'both';
$onlyPort = $originalResource instanceof ServiceApplication
? $originalResource->getRequiredPort()
: $predefinedPort;
$isTrafficAnalyticsEnabled = (bool) $server?->isTrafficAnalyticsEnabled();
$supportsLogAppend = (bool) $server?->caddySupportsLogAppend();
if (! $use_network_mode && (! $shouldGenerateLabelsExactly || $server->proxyType() === ProxyTypes::TRAEFIK->value)) {
$serviceLabels = addTraefikDockerNetworkLabel($serviceLabels, $baseNetwork->first());
}
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
case ProxyTypes::CADDY->value:
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
predefinedPort: $onlyPort,
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
is_traffic_analytics_enabled: $isTrafficAnalyticsEnabled,
supports_log_append: $supportsLogAppend,
));
break;
}
} else {
$serviceLabels = $serviceLabels->merge(fqdnLabelsForTraefik(
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $network,
uuid: $uuid,
domains: $fqdns,
is_force_https_enabled: $originalResource->isForceHttpsEnabled(),
serviceLabels: $serviceLabels,
is_gzip_enabled: $originalResource->isGzipEnabled(),
is_stripprefix_enabled: $originalResource->isStripprefixEnabled(),
service_name: $serviceName,
image: $image,
onlyPort: $onlyPort,
predefinedPort: $onlyPort,
domainPortOverrides: $originalResource->domain_port_overrides ?? [],
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
is_traffic_analytics_enabled: $isTrafficAnalyticsEnabled,
supports_log_append: $supportsLogAppend,
));
}
}
if (data_get($service, 'restart') === 'no' || data_get($service, 'exclude_from_hc')) {
$savedService->update(['exclude_from_status' => true]);
}
data_forget($service, 'volumes.*.content');
data_forget($service, 'volumes.*.isDirectory');
data_forget($service, 'volumes.*.is_directory');
data_forget($service, 'exclude_from_hc');
$volumesParsed = $volumesParsed->map(function ($volume) {
data_forget($volume, 'content');
data_forget($volume, 'is_directory');
data_forget($volume, 'isDirectory');
return $volume;
});
$payload = collect($service)->merge([
'container_name' => $containerName,
'restart' => $restart->value(),
'labels' => $serviceLabels,
]);
if (! $use_network_mode) {
$payload['networks'] = $networks_temp;
}
if ($ports->count() > 0) {
$payload['ports'] = $ports;
}
if ($volumesParsed->count() > 0) {
$payload['volumes'] = $volumesParsed;
}
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
$payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
}
if ($logging) {
$payload['logging'] = $logging;
}
if ($depends_on->count() > 0) {
$payload['depends_on'] = $depends_on;
}
// Auto-inject .env file so Coolify environment variables are available inside containers
// This makes Services behave consistently with Applications
$existingEnvFiles = data_get($service, 'env_file');
$envFiles = collect(is_null($existingEnvFiles) ? [] : (is_array($existingEnvFiles) ? $existingEnvFiles : [$existingEnvFiles]))
->push('.env')
->unique()
->values();
$payload['env_file'] = $envFiles;
$parsedServices->put($serviceName, $payload);
}
$topLevel->put('services', $parsedServices);
$customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets'];
$topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) {
return array_search($key, $customOrder);
});
// Remove empty top-level sections (volumes, networks, configs, secrets)
// Keep only non-empty sections to match Docker Compose best practices
$topLevel = $topLevel->filter(function ($value, $key) {
// Always keep 'services' section
if ($key === 'services') {
return true;
}
// Keep section only if it has content
return $value instanceof Collection ? $value->isNotEmpty() : ! empty($value);
});
$cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2);
$resource->docker_compose = $cleanedCompose;
// Update docker_compose_raw to remove content: from volumes only
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = parseDockerComposeYaml($originalCompose);
$originalYamlBeforeCleanup = $originalYaml;
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
foreach ($originalYaml['services'] as $serviceName => &$service) {
if (isset($service['volumes'])) {
foreach ($service['volumes'] as $key => &$volume) {
if (is_array($volume)) {
unset($volume['content']);
unset($volume['isDirectory']);
unset($volume['is_directory']);
}
}
}
}
}
if ($originalYaml !== $originalYamlBeforeCleanup) {
$resource->docker_compose_raw = removeComposeVolumeFieldsPreservingComments($originalCompose, $originalYaml, ['content', 'isDirectory', 'is_directory']);
}
} catch (Exception $e) {
// If parsing fails, keep the original docker_compose_raw unchanged
}
data_forget($resource, 'environment_variables');
data_forget($resource, 'environment_variables_preview');
$resource->save();
return $topLevel;
}