diff --git a/services/webapp/code/rosetta/core_app/api.py b/services/webapp/code/rosetta/core_app/api.py
index 0b5062c064c251e04272d0e7d006c07eaf9b4c79..edb9e8db3defd37336960fbaf79603938fe62550 100644
--- a/services/webapp/code/rosetta/core_app/api.py
+++ b/services/webapp/code/rosetta/core_app/api.py
@@ -13,7 +13,7 @@ from rest_framework import status, serializers, viewsets
 from rest_framework.views import APIView
 from .utils import format_exception, send_email, os_shell, now_t, get_ssh_access_mode_credentials, get_or_create_container_from_repository, booleanize
 from .models import Profile, Task, TaskStatuses, Computing, Storage, KeyPair
-from .exceptions import ConsistencyException
+from .exceptions import PermissionDenied
 import json
 
 # Setup logging
@@ -153,8 +153,12 @@ class PrivatePOSTAPI(APIView):
             # Call API logic
             return self._post(request)
         except Exception as e:
-            logger.error(format_exception(e))
-            return error500('Got error in processing request: {}'.format(e))
+            # TODO: refactor me
+            if isinstance(e, PermissionDenied):
+                return error400(format(e))
+            else:
+                logger.error(format_exception(e))
+                return error500('Got error in processing request: {}'.format(e))
 
 class PrivateGETAPI(APIView):
     '''Base private GET API class'''
@@ -174,9 +178,12 @@ class PrivateGETAPI(APIView):
             # Call API logic
             return self._get(request)
         except Exception as e:
-            logger.error(format_exception(e))
-            return error500('Got error in processing request: {}'.format(e))
-
+            # TODO: refactor me
+            if isinstance(e, PermissionDenied):
+                return error400(format(e))
+            else:
+                logger.error(format_exception(e))
+                return error500('Got error in processing request: {}'.format(e))
 
 
 #==============================
@@ -689,6 +696,9 @@ class FileManagerAPI(PrivateGETAPI, PrivatePOSTAPI):
 
     def delete(self, path, user, storage):
 
+        if storage.read_only:
+            raise PermissionDenied('This storage is read-only')
+
         if storage.type == 'generic_posix':
 
             shell_path = self.sanitize_and_prepare_shell_path(path, user, storage)
@@ -707,6 +717,9 @@ class FileManagerAPI(PrivateGETAPI, PrivatePOSTAPI):
 
     def mkdir(self, path, user, storage, force=False):
 
+        if storage.read_only:
+            raise PermissionDenied('This storage is read-only')
+
         path = self.sanitize_and_prepare_shell_path(path, user, storage)
 
         if storage.type == 'generic_posix':
@@ -751,6 +764,9 @@ class FileManagerAPI(PrivateGETAPI, PrivatePOSTAPI):
 
     def rename(self, old, new, user, storage):
 
+        if storage.read_only:
+            raise PermissionDenied('This storage is read-only')
+
         if storage.type == 'generic_posix':
 
             old = self.sanitize_and_prepare_shell_path(old, user, storage)
@@ -772,6 +788,9 @@ class FileManagerAPI(PrivateGETAPI, PrivatePOSTAPI):
 
     def copy(self, source, target, user, storage):
 
+        if storage.read_only:
+            raise PermissionDenied('This storage is read-only')
+
         if storage.type == 'generic_posix':
 
             source = self.sanitize_and_prepare_shell_path(source, user, storage)
@@ -805,6 +824,9 @@ class FileManagerAPI(PrivateGETAPI, PrivatePOSTAPI):
 
     def scp_to(self, source, target, user, storage, mode='get'):
 
+        if storage.read_only:
+            raise PermissionDenied('This storage is read-only')
+
         source = self.sanitize_shell_path(source) # This is a folder on Rosetta (/tmp)
         target = self.sanitize_and_prepare_shell_path(target, user, storage)
 
diff --git a/services/webapp/code/rosetta/core_app/computing_managers.py b/services/webapp/code/rosetta/core_app/computing_managers.py
index 9d78a26ca39b730233be8c190466fc61546dcc52..4aad4d42a4988f23383291e7f32c6b4c4ede0716 100644
--- a/services/webapp/code/rosetta/core_app/computing_managers.py
+++ b/services/webapp/code/rosetta/core_app/computing_managers.py
@@ -147,11 +147,17 @@ class InternalStandaloneComputingManager(StandaloneComputingManager):
                 if '$USER' in expanded_bind_path:
                     expanded_bind_path = expanded_bind_path.replace('$USER', task.user.username)
 
+                # Read only?
+                if storage.read_only:
+                    mode_string = ':ro'
+                else:
+                    mode_string = ''
+
                 # Add the bind
                 if not binds:
-                    binds = '-v{}:{}'.format(expanded_base_path, expanded_bind_path)
+                    binds = '-v{}:{}{}'.format(expanded_base_path, expanded_bind_path, mode_string)
                 else:
-                    binds += ' -v{}:{}'.format(expanded_base_path, expanded_bind_path)
+                    binds += ' -v{}:{}{}'.format(expanded_base_path, expanded_bind_path, mode_string)
 
         # Host name, image entry command
         run_command += ' {} -h task-{} --name task-{} -d -t {}/{}:{}'.format(binds, task.short_uuid, task.short_uuid, task.container.registry, task.container.image_name, task.container.image_tag)
@@ -348,11 +354,17 @@ class SSHStandaloneComputingManager(StandaloneComputingManager, SSHComputingMana
                     if '$USER' in expanded_bind_path:
                         expanded_bind_path = expanded_bind_path.replace('$USER', task.user.username)
 
+                    # Read only?
+                    if storage.read_only:
+                        mode_string = ':ro'
+                    else:
+                        mode_string = ''
+
                     # Add the bind
                     if not binds:
-                        binds = '-v{}:{}'.format(expanded_base_path, expanded_bind_path)
+                        binds = '-v{}:{}{}'.format(expanded_base_path, expanded_bind_path, mode_string)
                     else:
-                        binds += ' -v{}:{}'.format(expanded_base_path, expanded_bind_path)
+                        binds += ' -v{}:{}{}'.format(expanded_base_path, expanded_bind_path, mode_string)
 
             # TODO: remove this hardcoding
             prefix = 'sudo' if (computing_host == 'slurmclusterworker' and container_engine=='docker') else ''
diff --git a/services/webapp/code/rosetta/core_app/exceptions.py b/services/webapp/code/rosetta/core_app/exceptions.py
index 12a3bd8d8a1522a1868e875847d4c8f64cbf2ea0..cbb94230cf28148a79bf109fd4a3739d7fdcf09c 100644
--- a/services/webapp/code/rosetta/core_app/exceptions.py
+++ b/services/webapp/code/rosetta/core_app/exceptions.py
@@ -4,3 +4,6 @@ class ErrorMessage(Exception):
 
 class ConsistencyException(Exception):
     pass
+
+class PermissionDenied(Exception):
+    pass
\ No newline at end of file
diff --git a/services/webapp/code/rosetta/core_app/migrations/0037_storage_read_only.py b/services/webapp/code/rosetta/core_app/migrations/0037_storage_read_only.py
new file mode 100644
index 0000000000000000000000000000000000000000..cd05fb70714574b950b23ecfdbfa8d1a4ffdf4bf
--- /dev/null
+++ b/services/webapp/code/rosetta/core_app/migrations/0037_storage_read_only.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2.1 on 2025-03-05 09:46
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core_app', '0036_container_disable_http_basicauth_embedding'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='storage',
+            name='read_only',
+            field=models.BooleanField(default=False, verbose_name='Read only? (if supported)'),
+        ),
+    ]
diff --git a/services/webapp/code/rosetta/core_app/models.py b/services/webapp/code/rosetta/core_app/models.py
index 9d6cb5f4e4e4b6bbd3487224d886484f51dd0d6e..8efcb05bee0c18d7670f3077cc380d83fcbd4fc2 100644
--- a/services/webapp/code/rosetta/core_app/models.py
+++ b/services/webapp/code/rosetta/core_app/models.py
@@ -387,6 +387,9 @@ class Storage(models.Model):
     base_path = models.CharField('Base path', max_length=4096, blank=False, null=False)
     bind_path = models.CharField('Bind path', max_length=4096, blank=True, null=True)
 
+    # Read only?
+    read_only = models.BooleanField('Read only? (if supported)', default=False)
+
     # Link with a computing resource
     computing = models.ForeignKey(Computing, related_name='storages', on_delete=models.CASCADE, blank=True, null=True) # Make optional?
     access_through_computing = models.BooleanField('Access through linked computing resource?', default=False)