diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index e869ca91bd..2adaad476e 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -17,6 +17,7 @@
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
+use Symfony\Component\Yaml\Yaml;
class FileStorage extends Component
{
@@ -30,6 +31,8 @@ class FileStorage extends Component
public ?string $workdir = null;
+ public ?string $resolvedFromEnvVar = null;
+
public bool $permanently_delete = true;
public bool $isReadOnly = false;
@@ -64,6 +67,7 @@ public function mount()
}
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI() || $this->fileStorage->is_too_large;
+ $this->resolvedFromEnvVar = $this->isV6Parser() ? $this->detectEnvVarSource() : null;
$this->syncData();
}
@@ -89,6 +93,69 @@ public function syncData(bool $toModel = false): void
}
}
+ private function isV6Parser(): bool
+ {
+ $version = $this->resource instanceof Application
+ ? $this->resource->compose_parsing_version
+ : ($this->resource->service->compose_parsing_version ?? null);
+
+ return (int) $version >= 6;
+ }
+
+ /**
+ * Check the raw docker-compose for env var references in this volume's source.
+ * Returns the variable name if found, null otherwise.
+ */
+ private function detectEnvVarSource(): ?string
+ {
+ try {
+ $resource = $this->resource;
+ $dockerComposeRaw = $resource->service?->docker_compose_raw
+ ?? $resource->docker_compose_raw
+ ?? null;
+
+ if (! $dockerComposeRaw) {
+ return null;
+ }
+
+ $compose = Yaml::parse($dockerComposeRaw);
+
+ if (! isset($compose['services'])) {
+ return null;
+ }
+
+ $mountPath = $this->fileStorage->mount_path;
+
+ // Search all services in the compose for a volume matching this mount path
+ foreach ($compose['services'] as $serviceVolumes) {
+ $volumes = data_get($serviceVolumes, 'volumes', []);
+ foreach ($volumes as $volume) {
+ $source = null;
+ $target = null;
+
+ if (is_string($volume)) {
+ $parts = explode(':', $volume);
+ if (count($parts) >= 2) {
+ $source = $parts[0];
+ $target = $parts[1];
+ }
+ } elseif (is_array($volume)) {
+ $source = data_get($volume, 'source');
+ $target = data_get($volume, 'target');
+ }
+
+ if ($target === $mountPath && $source && preg_match('/\$\{([a-zA-Z_][a-zA-Z0-9_]*)/', $source, $matches)) {
+ return $matches[1];
+ }
+ }
+ }
+ } catch (\Throwable $e) {
+ // Silently fail — this is just a UI hint
+ }
+
+ return null;
+ }
+
public function convertToDirectory()
{
try {
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
index b45d9aba3c..3681333104 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php
@@ -4,6 +4,7 @@
use App\Models\Application;
use App\Models\EnvironmentVariable;
+use App\Models\LocalFileVolume;
use App\Support\ValidationPatterns;
use App\Traits\EnvironmentVariableProtection;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -270,6 +271,9 @@ public function submit($data = null)
$this->updateOrder();
$this->getDevView();
+
+ // Re-resolve volume paths that reference env vars (e.g., ${VAR:-./path})
+ LocalFileVolume::reResolveVolumePaths($this->resource);
} catch (\Throwable $e) {
return handleError($e, $this);
} finally {
diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
index 094320bdd4..860769f929 100644
--- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
+++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php
@@ -5,6 +5,7 @@
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
+use App\Models\LocalFileVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
@@ -227,6 +228,13 @@ public function submit()
$this->serialize();
$this->syncData(true);
$this->syncData(false);
+
+ // Re-resolve volume paths that reference env vars (e.g., ${VAR:-./path})
+ $resourceable = $this->env->resourceable ?? null;
+ if ($resourceable) {
+ LocalFileVolume::reResolveVolumePaths($resourceable);
+ }
+
$this->dispatch('success', 'Environment variable updated.');
$this->dispatch('envsUpdated');
$this->dispatch('configurationChanged');
@@ -378,7 +386,14 @@ public function delete()
}
}
+ $resourceable = $this->env->resourceable ?? null;
$this->env->delete();
+
+ // Re-resolve volume paths that reference env vars (e.g., ${VAR:-./path})
+ if ($resourceable) {
+ LocalFileVolume::reResolveVolumePaths($resourceable);
+ }
+
$this->dispatch('environmentVariableDeleted');
$this->dispatch('success', 'Environment variable deleted successfully.');
} catch (\Exception $e) {
diff --git a/app/Models/Application.php b/app/Models/Application.php
index 2c408483e0..c5776f9f84 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -118,7 +118,7 @@ class Application extends BaseModel
{
use ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
- private static $parserVersion = '5';
+ private static $parserVersion = '6';
protected $fillable = [
'name',
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index b521ede3d6..8455c4aa58 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -6,6 +6,9 @@
use App\Jobs\ServerStorageSaveJob;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
+use Illuminate\Support\Stringable;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
@@ -55,6 +58,63 @@ protected static function booted()
});
}
+ /**
+ * Resolve env var default syntax from fs_path (e.g., ${VAR:-./path} -> ./path).
+ * Resolves against the owning resource's environment variables so that
+ * Coolify-defined values take precedence over defaults.
+ *
+ * @throws \RuntimeException if the resolved path is bare '.' or empty (unresolvable variable with no default)
+ */
+ public function resolvedFsPath(): string
+ {
+ $envVars = $this->getResourceEnvironmentVariables();
+ $resolved = resolveEnvVarDefault(str($this->fs_path), $envVars);
+ $value = $resolved->value();
+
+ if ($value === '.' || $value === '' || str_contains($value, '${')) {
+ throw new \RuntimeException(
+ 'Cannot resolve storage path: environment variable has no value and no default was provided. '
+ .'Original path: '.$this->fs_path
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Gather environment variables from the owning resource (and its parent
+ * service when the resource is a ServiceApplication or ServiceDatabase).
+ */
+ private function getResourceEnvironmentVariables(): Collection
+ {
+ if ($this->exists) {
+ $this->loadMissing(['service']);
+ }
+ $resource = $this->resource;
+
+ if (! $resource) {
+ return collect();
+ }
+
+ $envVars = collect();
+
+ // Service children (ServiceApplication / ServiceDatabase) — merge parent service env vars
+ $parentService = data_get($resource, 'service');
+ if ($parentService && method_exists($parentService, 'environment_variables')) {
+ $envVars = $parentService->environment_variables()->get(['key', 'value'])
+ ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]);
+ }
+
+ // Resource's own env vars (overrides parent service vars)
+ if (method_exists($resource, 'environment_variables')) {
+ $resourceEnvVars = $resource->environment_variables()->get(['key', 'value'])
+ ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]);
+ $envVars = $envVars->merge($resourceEnvVars);
+ }
+
+ return $envVars;
+ }
+
protected function isBinary(): Attribute
{
return Attribute::make(
@@ -74,6 +134,194 @@ public function service()
return $this->morphTo('resource');
}
+ /**
+ * Re-resolve fs_path for file volumes whose raw compose source references
+ * environment variables. Call this after env vars are updated.
+ *
+ * Works for Application and Service resources.
+ */
+ public static function reResolveVolumePaths($resource): void
+ {
+ try {
+ if ($resource instanceof Application) {
+ static::reResolveForApplication($resource);
+ } elseif ($resource instanceof Service) {
+ static::reResolveForService($resource);
+ }
+ } catch (\Throwable) {
+ // Best-effort — don't break env var saves
+ }
+ }
+
+ private static function reResolveForApplication(Application $application): void
+ {
+ if ((int) $application->compose_parsing_version < 6) {
+ return;
+ }
+
+ $dockerComposeRaw = $application->docker_compose_raw;
+ if (! $dockerComposeRaw) {
+ return;
+ }
+
+ $compose = Yaml::parse($dockerComposeRaw);
+ if (! isset($compose['services'])) {
+ return;
+ }
+
+ $envVars = $application->environment_variables()->get(['key', 'value'])
+ ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]);
+
+ $mainDirectory = str(base_configuration_dir().'/applications/'.$application->uuid);
+
+ // Build raw source map from ALL compose services (Application owns all volumes)
+ $rawSources = [];
+ foreach ($compose['services'] as $service) {
+ $volumes = data_get($service, 'volumes', []);
+ foreach ($volumes as $volume) {
+ $source = null;
+ $target = null;
+ if (is_string($volume)) {
+ $parsed = parseDockerVolumeString($volume);
+ $source = $parsed['source'];
+ $target = $parsed['target'];
+ } elseif (is_array($volume)) {
+ $source = data_get_str($volume, 'source');
+ $target = data_get_str($volume, 'target');
+ }
+ if ($source && $target) {
+ $targetValue = $target instanceof Stringable ? $target->value() : (string) $target;
+ $rawSources[$targetValue] = $source;
+ }
+ }
+ }
+
+ static::updateFileVolumePaths($application, $rawSources, $envVars, $mainDirectory);
+ }
+
+ private static function reResolveForService(Service $service): void
+ {
+ if ((int) $service->compose_parsing_version < 6) {
+ return;
+ }
+
+ $dockerComposeRaw = $service->docker_compose_raw;
+ if (! $dockerComposeRaw) {
+ return;
+ }
+
+ $compose = Yaml::parse($dockerComposeRaw);
+ if (! isset($compose['services'])) {
+ return;
+ }
+
+ $serviceEnvVars = $service->environment_variables()->get(['key', 'value'])
+ ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]);
+
+ $mainDirectory = str(base_configuration_dir().'/services/'.$service->uuid);
+
+ $volumeOwners = collect()
+ ->merge($service->applications ?? collect())
+ ->merge($service->databases ?? collect());
+
+ foreach ($volumeOwners as $owner) {
+ $childEnvVars = $owner->environment_variables()->get(['key', 'value'])
+ ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]);
+ $mergedEnvVars = $serviceEnvVars->merge($childEnvVars);
+
+ $serviceName = $owner->name ?? null;
+ if (! $serviceName) {
+ continue;
+ }
+
+ $rawSources = [];
+ $volumes = data_get($compose, "services.{$serviceName}.volumes", []);
+ foreach ($volumes as $volume) {
+ $source = null;
+ $target = null;
+ if (is_string($volume)) {
+ $parsed = parseDockerVolumeString($volume);
+ $source = $parsed['source'];
+ $target = $parsed['target'];
+ } elseif (is_array($volume)) {
+ $source = data_get_str($volume, 'source');
+ $target = data_get_str($volume, 'target');
+ }
+ if ($source && $target) {
+ $targetValue = $target instanceof Stringable ? $target->value() : (string) $target;
+ $rawSources[$targetValue] = $source;
+ }
+ }
+
+ static::updateFileVolumePaths($owner, $rawSources, $mergedEnvVars, $mainDirectory);
+ }
+ }
+
+ private static function updateFileVolumePaths($resource, array $rawSources, $envVars, Stringable $mainDirectory): void
+ {
+ foreach ($resource->fileStorages()->get() as $fileVolume) {
+ $rawSource = $rawSources[$fileVolume->mount_path] ?? null;
+ if (! $rawSource) {
+ continue;
+ }
+
+ $sourceStr = $rawSource instanceof Stringable ? $rawSource : str($rawSource);
+ if (! str_contains($sourceStr->value(), '${')) {
+ continue;
+ }
+
+ $resolved = resolveEnvVarDefault($sourceStr, $envVars);
+ if (sourceIsLocal($resolved)) {
+ $resolved = replaceLocalSource($resolved, $mainDirectory);
+ if ($fileVolume->fs_path !== $resolved->value()) {
+ $fileVolume->update(['fs_path' => $resolved->value()]);
+ }
+ } else {
+ // Env var removed or no longer resolves to local path — convert back to named volume
+ $slugWithoutUuid = Str::slug($resolved, '-');
+ $name = $resource->uuid.'_'.$slugWithoutUuid;
+ LocalPersistentVolume::updateOrCreate(
+ [
+ 'mount_path' => $fileVolume->mount_path,
+ 'resource_id' => $resource->id,
+ 'resource_type' => get_class($resource),
+ ],
+ [
+ 'name' => $name,
+ ]
+ );
+ $fileVolume->delete();
+ }
+ }
+
+ // Reclassify: when env var now resolves to a local path, create LocalFileVolume
+ // and remove stale LocalPersistentVolume
+ foreach ($rawSources as $mountPath => $rawSource) {
+ $sourceStr = $rawSource instanceof Stringable ? $rawSource : str($rawSource);
+ if (! str_contains($sourceStr->value(), '${')) {
+ continue;
+ }
+
+ $resolved = resolveEnvVarDefault($sourceStr, $envVars);
+ if (! sourceIsLocal($resolved)) {
+ continue;
+ }
+
+ if (! $resource->fileStorages()->where('mount_path', $mountPath)->exists()) {
+ $fsPath = replaceLocalSource($resolved, $mainDirectory);
+ LocalFileVolume::create([
+ 'mount_path' => $mountPath,
+ 'fs_path' => $fsPath,
+ 'is_directory' => true,
+ 'resource_id' => $resource->id,
+ 'resource_type' => get_class($resource),
+ ]);
+ }
+
+ $resource->persistentStorages()->where('mount_path', $mountPath)->delete();
+ }
+ }
+
public function loadStorageOnServer()
{
if ($this->is_host_file) {
@@ -90,7 +338,7 @@ public function loadStorageOnServer()
$server = $this->resource->destination->server;
}
$commands = collect([]);
- $path = data_get_str($this, 'fs_path');
+ $path = str($this->resolvedFsPath());
if ($path->startsWith('.')) {
$path = $path->after('.');
$path = $workdir.$path;
@@ -148,7 +396,7 @@ public function deleteStorageOnServer()
$server = $this->resource->destination->server;
}
$commands = collect([]);
- $path = data_get_str($this, 'fs_path');
+ $path = str($this->resolvedFsPath());
if ($path->startsWith('.')) {
$path = $path->after('.');
$path = $workdir.$path;
@@ -189,19 +437,28 @@ public function saveStorageOnServer()
$server = $this->resource->destination->server;
}
$commands = collect([]);
+
+ // Resolve env var syntax (e.g., ${VAR:-./path} -> ./path) for legacy records.
+ $fsPath = $this->resolvedFsPath();
+ $path = str($fsPath);
+ if ($path->startsWith('.')) {
+ $path = $path->after('.');
+ $path = $workdir.$path;
+ }
+
+ // Validate and escape resolved path before any shell interpolation.
+ $pathValue = $path->value();
+ validateShellSafePath($pathValue, 'storage path');
+ $escapedPath = escapeshellarg($pathValue);
$escapedWorkdir = escapeshellarg($workdir);
if ($this->is_directory) {
- // Validate fs_path early before any shell interpolation
- validateShellSafePath($this->fs_path, 'storage path');
- $escapedFsPath = escapeshellarg($this->fs_path);
- $commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true");
+ $commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
$commands->push("cd {$escapedWorkdir}");
}
- $path = data_get_str($this, 'fs_path');
- $content = data_get($this, 'content');
- $pathForParentDirectory = str($this->fs_path);
+
+ $pathForParentDirectory = str($pathValue);
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
$parent_dir = $pathForParentDirectory->beforeLast('/');
if ($parent_dir != '') {
@@ -209,14 +466,8 @@ public function saveStorageOnServer()
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
}
}
- if ($path->startsWith('.')) {
- $path = $path->after('.');
- $path = $workdir.$path;
- }
- // Validate and escape resolved path (may differ from fs_path if relative)
- validateShellSafePath($path, 'storage path');
- $escapedPath = escapeshellarg($path);
+ $content = data_get($this, 'content');
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
@@ -231,7 +482,7 @@ public function saveStorageOnServer()
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
} elseif ($isDir === 'OK' && ! $this->is_directory) {
- if ($path === '/' || $path === '.' || $path === '..' || $path === '' || str($path)->isEmpty() || is_null($path)) {
+ if ($pathValue === '/' || $pathValue === '.' || $pathValue === '..' || $pathValue === '' || str($pathValue)->isEmpty()) {
$this->is_directory = true;
$this->save();
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file.
Please delete the directory on the server or mark it as directory.');
diff --git a/app/Models/Service.php b/app/Models/Service.php
index 72b4676578..53cff64d35 100644
--- a/app/Models/Service.php
+++ b/app/Models/Service.php
@@ -45,7 +45,7 @@ class Service extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
- private static $parserVersion = '5';
+ private static $parserVersion = '6';
protected $fillable = [
'uuid',
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index ea99e99931..e8f801aca7 100644
--- a/bootstrap/helpers/parsers.php
+++ b/bootstrap/helpers/parsers.php
@@ -112,7 +112,7 @@ function validateVolumeStringForInjection(string $volumeString): void
parseDockerVolumeString($volumeString);
}
-function parseDockerVolumeString(string $volumeString): array
+function parseDockerVolumeString(string $volumeString, bool $preserveEnvDefaults = false): array
{
$volumeString = trim($volumeString);
$source = null;
@@ -287,25 +287,24 @@ function parseDockerVolumeString(string $volumeString): array
}
// 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
+ $defaultValue = $parts[1] ?? '';
if ($defaultValue !== '') {
- $source = $defaultValue;
+ if ($preserveEnvDefaults) {
+ // v6+: keep ${VAR:-default} so resolveEnvVarDefault() can apply env overrides
+ } else {
+ // v5 and below: strip to just the default value (v4.x behavior)
+ $source = $defaultValue;
+ }
} else {
- // Empty default value, keep the variable reference for env resolution
+ // Empty default — clean up to simple variable reference
$source = '${'.$varName.'}';
}
}
- // Otherwise keep the variable as-is for later expansion (no default value)
}
// Validate source path for command injection attempts
@@ -315,11 +314,11 @@ function parseDockerVolumeString(string $volumeString): array
// 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);
+ // Skip validation for environment variable references (with or without defaults)
+ // Pattern 1: ${VAR} or ${VAR:-safe_default}
+ // Pattern 2: ${VAR}/path or ${VAR:-safe_default}/path
+ $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}$/', $sourceStr);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}[\/\w\.\-]*$/', $sourceStr);
if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
@@ -432,6 +431,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$allEnvironments = $allEnvironments->mapWithKeys(function ($item) {
return [$item['key'] => $item['value']];
});
+ // For preview deployments, merge preview env vars (they override normal ones)
+ if ($isPullRequest) {
+ $previewEnvironments = $resource->environment_variables_preview()->get(['key', 'value'])
+ ->mapWithKeys(fn ($item) => [$item['key'] => $item['value']]);
+ $allEnvironments = $allEnvironments->merge($previewEnvironments);
+ }
// filter and add magic environments
foreach ($environment as $key => $value) {
// Get all SERVICE_ variables from keys and values
@@ -688,6 +693,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$predefinedPort = null;
$originalResource = $resource;
+ $isV6 = (int) $resource->compose_parsing_version >= 6;
if ($volumes->count() > 0) {
foreach ($volumes as $index => $volume) {
@@ -697,9 +703,13 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$content = null;
$isDirectory = false;
if (is_string($volume)) {
- $parsed = parseDockerVolumeString($volume);
+ $parsed = parseDockerVolumeString($volume, $isV6);
$source = $parsed['source'];
$target = $parsed['target'];
+ // v6+: resolve env vars in source (e.g., ${VAR} -> value from Coolify env vars)
+ if ($isV6) {
+ $source = resolveEnvVarDefault($source, $allEnvironments);
+ }
// Mode is available in $parsed['mode'] if needed
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) {
@@ -721,15 +731,19 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
+ // v6+: resolve environment variable defaults (e.g., ${VAR:-./path} -> ./path)
+ if ($isV6 && $source !== null && ! empty($source->value())) {
+ $source = resolveEnvVarDefault($source, $allEnvironments);
+ }
+
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
- // Allow environment variable references and env vars with path concatenation
- $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
- $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
- $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
+ // Allow environment variable references (with or without defaults) and path concatenation
+ $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}$/', $sourceValue);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}[\/\w\.\-]*$/', $sourceValue);
- if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
+ if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (Exception $e) {
@@ -2068,6 +2082,7 @@ function serviceParser(Service $resource): Collection
}
$originalResource = $savedService;
+ $isV6 = (int) $resource->compose_parsing_version >= 6;
if ($volumes->count() > 0) {
foreach ($volumes as $index => $volume) {
@@ -2077,9 +2092,13 @@ function serviceParser(Service $resource): Collection
$content = null;
$isDirectory = false;
if (is_string($volume)) {
- $parsed = parseDockerVolumeString($volume);
+ $parsed = parseDockerVolumeString($volume, $isV6);
$source = $parsed['source'];
$target = $parsed['target'];
+ // v6+: resolve env vars in source (e.g., ${VAR} -> value from Coolify env vars)
+ if ($isV6) {
+ $source = resolveEnvVarDefault($source, $allEnvironments);
+ }
// Mode is available in $parsed['mode'] if needed
$foundConfig = $originalResource->fileStorages()->whereMountPath($target)->first();
if (sourceIsLocal($source)) {
@@ -2101,15 +2120,19 @@ function serviceParser(Service $resource): Collection
$content = data_get($volume, 'content');
$isDirectory = (bool) data_get($volume, 'isDirectory', null) || (bool) data_get($volume, 'is_directory', null);
+ // v6+: resolve environment variable defaults (e.g., ${VAR:-./path} -> ./path)
+ if ($isV6 && $source !== null && ! empty($source->value())) {
+ $source = resolveEnvVarDefault($source, $allEnvironments);
+ }
+
// Validate source and target for command injection (array/long syntax)
if ($source !== null && ! empty($source->value())) {
$sourceValue = $source->value();
- // Allow environment variable references and env vars with path concatenation
- $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}$/', $sourceValue);
- $isEnvVarWithDefault = preg_match('/^\$\{[^}]+:-[^}]*\}$/', $sourceValue);
- $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*\}[\/\w\.\-]*$/', $sourceValue);
+ // Allow environment variable references (with or without defaults) and path concatenation
+ $isSimpleEnvVar = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}$/', $sourceValue);
+ $isEnvVarWithPath = preg_match('/^\$\{[a-zA-Z_][a-zA-Z0-9_]*(:-[a-zA-Z0-9_.\/~\-]*)?\}[\/\w\.\-]*$/', $sourceValue);
- if (! $isSimpleEnvVar && ! $isEnvVarWithDefault && ! $isEnvVarWithPath) {
+ if (! $isSimpleEnvVar && ! $isEnvVarWithPath) {
try {
validateShellSafePath($sourceValue, 'volume source');
} catch (Exception $e) {
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 39c23d2bb5..e68ea62935 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -1506,9 +1506,45 @@ function getTopLevelNetworks(Service|Application $resource)
return $topLevelNetworks->keys();
}
}
+function resolveEnvVarDefault(Stringable $source, ?Collection $environmentVariables = null): Stringable
+{
+ $value = $source->value();
+ if (preg_match('/^\$\{([^}]+)\}$/', $value, $matches)) {
+ $varContent = $matches[1];
+
+ // Extract variable name and optional default from ${VAR:-default} or ${VAR}
+ $varName = $varContent;
+ $defaultValue = null;
+ if (strpos($varContent, ':-') !== false) {
+ $parts = explode(':-', $varContent, 2);
+ $varName = $parts[0];
+ $defaultValue = $parts[1] ?? '';
+ }
+
+ // 1. If env var is set in Coolify, use its value
+ if ($environmentVariables !== null && $environmentVariables->has($varName)) {
+ $envValue = $environmentVariables->get($varName);
+ if ($envValue !== null && $envValue !== '') {
+ return str($envValue);
+ }
+ }
+
+ // 2. If a non-empty default exists, use it
+ if ($defaultValue !== null && $defaultValue !== '') {
+ return str($defaultValue);
+ }
+
+ // 3. No env var and no default — return original reference unchanged so callers
+ // (e.g. sourceIsLocal()) don't misclassify it as a local bind mount.
+ return $source;
+ }
+
+ return $source;
+}
+
function sourceIsLocal(Stringable $source)
{
- if ($source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~') || $source->startsWith('..') || $source->startsWith('~/') || $source->startsWith('../')) {
+ if ($source->value() === '.' || $source->startsWith('./') || $source->startsWith('/') || $source->startsWith('~') || $source->startsWith('..') || $source->startsWith('~/') || $source->startsWith('../')) {
return true;
}
@@ -3725,7 +3761,7 @@ function wireNavigate(): string
try {
$settings = instanceSettings();
- // Return wire:navigate for SPA navigation with prefetching, or empty string if disabled
+ // Return wire:navigate for SPA navigation without hover-prefetching, or empty string if disabled
return ($settings->is_wire_navigate_enabled ?? true) ? 'wire:navigate' : '';
} catch (Exception $e) {
return 'wire:navigate';
diff --git a/resources/views/livewire/project/service/file-storage.blade.php b/resources/views/livewire/project/service/file-storage.blade.php
index e47d290b88..6e1b5a12dc 100644
--- a/resources/views/livewire/project/service/file-storage.blade.php
+++ b/resources/views/livewire/project/service/file-storage.blade.php
@@ -11,15 +11,16 @@
@elseif ($isReadOnly)