Files
coolify/bootstrap/helpers/parsers.php
T
Andras Bacsai ea1199bf8e fix(compose): allow required and alternative variables in network names
Accept the `?`, `:?`, `+` and `:+` interpolation operators in Compose
network names, such as `${CONTAINER_NAME?error}`. The validation added
in v4.4.0 only knew `-` and `:-`, so these valid files stopped deploying.

The text after `?` is an error message and is not used as the default of
the created environment variable. Unsafe operands are still rejected.

Fixes #12126
2026-10-09 08:45:03 +02:00

3446 lines
162 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
);
}
}
/**
* Matches one braced Compose variable: `${VAR}`, `${VAR:-default}`, `${VAR-default}`,
* `${VAR:?error}`, `${VAR?error}`, `${VAR:+value}` or `${VAR+value}`.
*/
const COMPOSE_NETWORK_VARIABLE_PATTERN = '\$\{([A-Za-z_][A-Za-z0-9_]*)(?::?([-?+])([^}]*))?\}';
/**
* Splits a top-level network `name:` that is one whole Compose variable (see
* COMPOSE_NETWORK_VARIABLE_PATTERN), such as an external network that differs per server. Only the
* `-` operators give a default; the text after `?` is an error message and after `+` a value that
* Compose uses only when the variable is set.
*
* @return array{variable: string, default: ?string}|null
*/
function composeNetworkNameVariable(string $name): ?array
{
if (preg_match('/\A'.COMPOSE_NETWORK_VARIABLE_PATTERN.'\z/', $name, $matches) !== 1) {
return null;
}
return ['variable' => $matches[1], 'default' => ($matches[2] ?? '') === '-' ? $matches[3] : null];
}
/**
* Tells if the text after the operator of a Compose variable is safe. A default or alternative value
* becomes part of the network name; an error message only shows in the Compose error, so it can
* also contain spaces and simple punctuation.
*/
function isSafeComposeNetworkVariableOperand(string $operator, string $operand): bool
{
$pattern = $operator === '?' ? '/\A[A-Za-z0-9 _.,:!-]*\z/' : '/\A[A-Za-z0-9_.-]*\z/';
return preg_match($pattern, $operand) === 1;
}
/**
* 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 and the braced variables of COMPOSE_NETWORK_VARIABLE_PATTERN with safe operands in a network value that Compose interpolates.
*/
function validateComposeNetworkNameWithVariables(string $name, string $context, ?string $serviceName = null): void
{
$variable = composeNetworkNameVariable($name);
if ($variable !== null && $variable['default'] !== null) {
$isValid = ValidationPatterns::isValidDockerNetwork($variable['default']);
} else {
$hasSafeDefaults = true;
$withoutVariables = preg_replace_callback(
'/'.COMPOSE_NETWORK_VARIABLE_PATTERN.'|\$[A-Za-z_][A-Za-z0-9_]*/',
function (array $matches) use (&$hasSafeDefaults): string {
if (! isSafeComposeNetworkVariableOperand($matches[2] ?? '', $matches[3] ?? '')) {
$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;
}