diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php
index 2ce4374a0..54ef82872 100644
--- a/app/Livewire/Project/Service/FileStorage.php
+++ b/app/Livewire/Project/Service/FileStorage.php
@@ -62,7 +62,7 @@ public function mount()
$this->fs_path = $this->fileStorage->fs_path;
}
- $this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
+ $this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
$this->syncData();
}
@@ -104,7 +104,8 @@ public function convertToDirectory()
public function loadStorageOnServer()
{
try {
- $this->authorize('update', $this->resource);
+ // Loading content is a read operation, so we use 'view' permission
+ $this->authorize('view', $this->resource);
$this->fileStorage->loadStorageOnServer();
$this->syncData();
diff --git a/app/Livewire/Project/Service/Storage.php b/app/Livewire/Project/Service/Storage.php
index 644b100b8..12d8bcbc3 100644
--- a/app/Livewire/Project/Service/Storage.php
+++ b/app/Livewire/Project/Service/Storage.php
@@ -67,7 +67,7 @@ public function refreshStoragesFromEvent()
public function refreshStorages()
{
$this->fileStorage = $this->resource->fileStorages()->get();
- $this->resource->refresh();
+ $this->resource->load('persistentStorages.resource');
}
public function getFilesProperty()
diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php
index 5970ec904..c8dc68d66 100644
--- a/app/Livewire/Project/Shared/Storages/Show.php
+++ b/app/Livewire/Project/Shared/Storages/Show.php
@@ -67,7 +67,7 @@ private function syncData(bool $toModel = false): void
public function mount()
{
$this->syncData(false);
- $this->isReadOnly = $this->storage->isReadOnlyVolume();
+ $this->isReadOnly = $this->storage->shouldBeReadOnlyInUI();
}
public function submit()
diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php
index 96170dbd6..9d7095cb5 100644
--- a/app/Models/LocalFileVolume.php
+++ b/app/Models/LocalFileVolume.php
@@ -209,6 +209,23 @@ public function scopeWherePlainMountPath($query, $path)
return $query->get()->where('plain_mount_path', $path);
}
+ // Check if this volume belongs to a service resource
+ public function isServiceResource(): bool
+ {
+ return in_array($this->resource_type, [
+ 'App\Models\ServiceApplication',
+ 'App\Models\ServiceDatabase',
+ ]);
+ }
+
+ // Determine if this volume should be read-only in the UI
+ // File/directory mounts can be edited even for services
+ public function shouldBeReadOnlyInUI(): bool
+ {
+ // Check for explicit :ro flag in compose (existing logic)
+ return $this->isReadOnlyVolume();
+ }
+
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@@ -239,22 +256,40 @@ public function isReadOnlyVolume(): bool
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
+ // Note: We match on mount_path (container path) only, since fs_path gets transformed
+ // from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
- // Check if this volume matches our fs_path and mount_path
+ // Check if this volume matches our mount_path
if (count($parts) >= 2) {
- $hostPath = $parts[0];
$containerPath = $parts[1];
$options = $parts[2] ?? null;
- // Match based on fs_path and mount_path
- if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
+ // Match based on mount_path
+ // Remove leading slash from mount_path if present for comparison
+ $mountPath = str($this->mount_path)->ltrim('/')->toString();
+ $containerPathClean = str($containerPath)->ltrim('/')->toString();
+
+ if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $options === 'ro';
}
}
+ } elseif (is_array($volume)) {
+ // Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
+ $containerPath = data_get($volume, 'target');
+ $readOnly = data_get($volume, 'read_only', false);
+
+ // Match based on mount_path
+ // Remove leading slash from mount_path if present for comparison
+ $mountPath = str($this->mount_path)->ltrim('/')->toString();
+ $containerPathClean = str($containerPath)->ltrim('/')->toString();
+
+ if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
+ return $readOnly === true;
+ }
}
}
diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php
index e7862478b..7126253ea 100644
--- a/app/Models/LocalPersistentVolume.php
+++ b/app/Models/LocalPersistentVolume.php
@@ -10,6 +10,11 @@ class LocalPersistentVolume extends Model
{
protected $guarded = [];
+ public function resource()
+ {
+ return $this->morphTo('resource');
+ }
+
public function application()
{
return $this->morphTo('resource');
@@ -50,6 +55,54 @@ protected function hostPath(): Attribute
);
}
+ // Check if this volume belongs to a service resource
+ public function isServiceResource(): bool
+ {
+ return in_array($this->resource_type, [
+ 'App\Models\ServiceApplication',
+ 'App\Models\ServiceDatabase',
+ ]);
+ }
+
+ // Check if this volume belongs to a dockercompose application
+ public function isDockerComposeResource(): bool
+ {
+ if ($this->resource_type !== 'App\Models\Application') {
+ return false;
+ }
+
+ // Only access relationship if already eager loaded to avoid N+1
+ if (! $this->relationLoaded('resource')) {
+ return false;
+ }
+
+ $application = $this->resource;
+ if (! $application) {
+ return false;
+ }
+
+ return data_get($application, 'build_pack') === 'dockercompose';
+ }
+
+ // Determine if this volume should be read-only in the UI
+ // Service volumes and dockercompose application volumes are read-only
+ // (users should edit compose file directly)
+ public function shouldBeReadOnlyInUI(): bool
+ {
+ // All service volumes should be read-only in UI
+ if ($this->isServiceResource()) {
+ return true;
+ }
+
+ // All dockercompose application volumes should be read-only in UI
+ if ($this->isDockerComposeResource()) {
+ return true;
+ }
+
+ // Check for explicit :ro flag in compose (existing logic)
+ return $this->isReadOnlyVolume();
+ }
+
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
@@ -85,6 +138,7 @@ public function isReadOnlyVolume(): bool
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
+ // Note: We match on mount_path (container path) only, since host paths get transformed
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
@@ -104,6 +158,19 @@ public function isReadOnlyVolume(): bool
return $options === 'ro';
}
}
+ } elseif (is_array($volume)) {
+ // Long-form syntax: { type: bind/volume, source: ..., target: ..., read_only: true }
+ $containerPath = data_get($volume, 'target');
+ $readOnly = data_get($volume, 'read_only', false);
+
+ // Match based on mount_path
+ // Remove leading slash from mount_path if present for comparison
+ $mountPath = str($this->mount_path)->ltrim('/')->toString();
+ $containerPathClean = str($containerPath)->ltrim('/')->toString();
+
+ if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
+ return $readOnly === true;
+ }
}
}
diff --git a/resources/views/livewire/project/service/configuration.blade.php b/resources/views/livewire/project/service/configuration.blade.php
index 7379ca706..f1ad3e06a 100644
--- a/resources/views/livewire/project/service/configuration.blade.php
+++ b/resources/views/livewire/project/service/configuration.blade.php
@@ -37,7 +37,7 @@