What Is Read-Only Root Filesystem
A read-only root filesystem is a security hardening technique that prevents write access to the container's entire filesystem except for explicitly declared writable paths. This means your application code (/app, /opt, /usr) is immutable at runtime, system files (/etc, /bin, /lib) are protected from modification, and only designated directories (/tmp, /var/run, etc.) are writable. Attackers cannot create backdoors, install tools, or modify application behavior.
Why It Matters
Immutability is the foundation of container security. Once your container starts with a read-only filesystem, nothing critical can change during runtime. The code you deployed is the code that runs—no runtime modifications are possible. Attackers cannot install backdoors by writing to disk. Malware cannot persist because it has nowhere to write. Runtime supply chain attacks that modify application behavior at execution time become impossible. This immutability transforms the security model from "hope we detect and respond to attacks" to "attacks of this class cannot occur by design."
Tamper detection becomes automatic. Any attempt to write to a protected path immediately fails with a "Read-only file system" error. This error is auditable—it appears in logs and can trigger alerts. If a compromised container attempts to write a backdoor to /bin or modify /etc/passwd, the write fails immediately, the failure is logged, and security systems can detect the intrusion attempt in real time. Traditional containers allow these modifications silently, enabling attackers to establish persistence. Read-only filesystems reject the attack before persistence is possible.
Compliance frameworks explicitly require this protection. PCI-DSS (payment card industry standard) requires containers handling sensitive data to enforce filesystem integrity. FedRAMP (government cloud security) mandates immutable containers for federal systems. DISA STIG (Department of Defense security standard) requires read-only root filesystems. CIS Benchmarks explicitly score read-only filesystems as a security control. These are not suggestions—they are compliance requirements for regulated organizations. Read-only filesystems are the mechanism to satisfy these requirements.
Operational simplicity eliminates an entire class of bugs. In traditional containers with writable filesystems, applications sometimes write to unexpected locations: temporary files, log files, cache files in locations the developer didn't anticipate. These writes create technical debt—disk space is consumed, garbage accumulates, file permissions become incorrect. Read-only filesystems prevent this class of problem entirely by forcing developers to explicitly declare where applications need to write. The explicit declaration becomes documentation. The failures become visible in development rather than manifesting silently in production.
How It Works
Docker (--read-only): At runtime, the container launches with the entire filesystem mounted as read-only.
docker run --read-only --tmpfs /tmp --tmpfs /var/run myapp:latestKubernetes (readOnlyRootFilesystem: true): The kubelet enforces read-only root at the pod level.
securityContext: readOnlyRootFilesystem: trueAny write to a protected path immediately fails with "Read-only file system" error, preventing the application from continuing.
The Writable Path Inventory
Every CleanStart application image requires specific directories to be writable. This complete table documents which paths each application needs and why:
Application | Writable Paths | Purpose | Volume Type | Size | Notes |
|---|---|---|---|---|---|
PostgreSQL |
| Database files, WAL logs, indexes | PersistentVolumeClaim | Variable (10GB+) | CRITICAL: Must persist across restarts |
| Unix socket, PID file | emptyDir | <1MB | Can be ephemeral | |
| Temp files, query spills | emptyDir | 1GB | May grow with large queries | |
Redis |
| RDB snapshots, AOF logs | PersistentVolumeClaim | Variable (depends on data) | Optional persistence |
| Lua script temp, memory ops | emptyDir | <100MB | Rarely used | |
Kafka |
| Log segments (critical) | PersistentVolumeClaim | Variable (100GB+) | DO NOT use emptyDir — data loss |
| Temp files during compaction | emptyDir | 10GB+ | May spike during log rotation | |
Nginx |
| Caching, buffering | emptyDir | 1-10GB | Performance-critical |
| PID file, socket | emptyDir | <1MB | Ephemeral, created at startup | |
| Temp files, uploads | emptyDir | 1-5GB | Request temporary storage | |
Python |
| Package install, temp files | emptyDir | 1GB | Virtual env, pip cache |
| Pip package cache (optional) | emptyDir | Variable | Optional for faster reinstalls | |
Node.js |
| NPM/Yarn temp, module cache | emptyDir | 2-5GB | Npm native modules compilation |
| NPM cache (optional) | emptyDir | Variable | Optional, for faster installs | |
Java |
| JVM temp files, heap dumps | emptyDir | 2-8GB | Critical for JVM operation |
| Compiled class cache (optional) | emptyDir | Variable | Performance optimization | |
Go |
| Go build cache (rarely needed) | emptyDir | 100MB | Usually not needed at runtime |
Rust |
| Temp files (rarely needed) | emptyDir | 100MB | Usually not needed at runtime |
Legend: PersistentVolumeClaim (PVC) represents data that must survive pod restarts (databases, caches with persistence). emptyDir represents ephemeral storage, recreated when pod starts (temporary files, non-persistent caches). tmpfs represents an in-memory mount (very fast, lost on restart, limited by available memory).
Per-Application Complete Examples
PostgreSQL
PostgreSQL requires two critical writable paths: /var/lib/postgresql/data (database files) and /var/run/postgresql (socket/runtime).
Dockerfile with Read-Only Setup
FROM postgres:16-alpine # Create necessary directories during buildRUN mkdir -p /var/lib/postgresql/data && \ mkdir -p /var/run/postgresql && \ chmod 700 /var/lib/postgresql/data && \ chown postgres:postgres /var/lib/postgresql/data /var/run/postgresql # Application runs as non-rootUSER postgres # CRITICAL: Use exec form (not shell form)ENTRYPOINT ["docker-entrypoint.sh"]CMD ["postgres"]Kubernetes Manifest with Read-Only Filesystem
apiVersion: v1kind: Podmetadata: name: postgres-db namespace: databasespec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 999 # postgres UID fsGroup: 999 containers: - name: postgres image: postgres:16-alpine # Security restrictions securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] add: ["NET_BIND_SERVICE"] # For TCP port readOnlyRootFilesystem: true # Writable mounts volumeMounts: - name: data mountPath: /var/lib/postgresql/data - name: postgres-run mountPath: /var/run/postgresql - name: tmp mountPath: /tmp ports: - name: postgres containerPort: 5432 protocol: TCP livenessProbe: exec: command: - /bin/sh - -c - pg_isready -U postgres initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: exec: command: - /bin/sh - -c - pg_isready -U postgres initialDelaySeconds: 5 periodSeconds: 5 resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "1Gi" cpu: "1000m" volumes: # Data must be persistent - name: data persistentVolumeClaim: claimName: postgres-data-pvc # Runtime directory (ephemeral) - name: postgres-run emptyDir: {} # Temporary files - name: tmp emptyDir: sizeLimit: 1GiDocker Compose with Read-Only
version: '3.8' services: postgres: image: postgres:16-alpine read_only: true # Read-only root filesystem # Writable mounts tmpfs: - /var/run/postgresql # Runtime socket - /tmp # Temporary files volumes: - postgres_data:/var/lib/postgresql/data # Persistent data environment: POSTGRES_PASSWORD: secure_password POSTGRES_DB: application ports: - "5432:5432" volumes: postgres_data: driver: localWhat Breaks Without Writable Paths
Missing Path | Error | Impact |
|---|---|---|
|
| Database won't start, complete failure |
|
| Cannot accept connections |
|
| Large queries fail, sort operations fail |
Debugging Read-Only Failures
When PostgreSQL fails on read-only filesystem, check the actual error using kubectl logs pod/postgres-db. The output might show "FATAL: could not create shared memory segment: Permission denied". Verify volume mounts in pod spec by running kubectl get pod postgres-db -o yaml | grep -A 20 volumeMounts:. Test connection with mounted socket using kubectl exec -it pod/postgres-db -- psql -h /var/run/postgresql -U postgres -d postgres. If socket path is wrong, you'll see "could not translate host name "/var/run/postgresql" to address", which means you need to ensure /var/run/postgresql is in volumeMounts.
# Check actual errorkubectl logs pod/postgres-db # Verify volume mounts in pod speckubectl get pod postgres-db -o yaml | grep -A 20 volumeMounts: # Test connection with mounted socketkubectl exec -it pod/postgres-db -- \ psql -h /var/run/postgresql -U postgres -d postgres # If socket path is wrong, solution: Ensure /var/run/postgresql is in volumeMountsRedis
Redis is simpler than PostgreSQL but still needs writable paths for persistence and temporary operations.
Dockerfile with Read-Only Setup
FROM redis:7-alpine # Create data directoryRUN mkdir -p /data && \ chown redis:redis /data && \ chmod 700 /data USER redis # Use exec formENTRYPOINT ["redis-server"]CMD ["--dir", "/data", "--appendonly", "yes"]Kubernetes Manifest with Read-Only Filesystem
apiVersion: v1kind: Podmetadata: name: redis-cachespec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 999 # redis UID containers: - name: redis image: redis:7-alpine securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] readOnlyRootFilesystem: true # Writable mounts volumeMounts: - name: data mountPath: /data - name: tmp mountPath: /tmp ports: - name: redis containerPort: 6379 livenessProbe: tcpSocket: port: 6379 initialDelaySeconds: 10 periodSeconds: 5 resources: requests: memory: "128Mi" cpu: "100m" limits: memory: "512Mi" cpu: "500m" volumes: - name: data persistentVolumeClaim: claimName: redis-data-pvc - name: tmp emptyDir: sizeLimit: 100MiDocker Compose with Read-Only
version: '3.8' services: redis: image: redis:7-alpine read_only: true tmpfs: - /tmp volumes: - redis_data:/data ports: - "6379:6379" command: redis-server --dir /data --appendonly yes volumes: redis_data:What Breaks Without Writable Paths
Missing Path | Error | Impact |
|---|---|---|
|
| Persistence fails, no AOF logging |
|
| Memory operations fail |
Kafka
Kafka is the most critical: /var/lib/kafka/data contains the actual message log. Using emptyDir will lose data.
Dockerfile with Read-Only Setup
FROM confluentinc/cp-kafka:7.5-ubi8 # Kafka user typically 'appuser'RUN mkdir -p /var/lib/kafka/data && \ mkdir -p /tmp && \ chown appuser:appuser /var/lib/kafka/data /tmp USER appuser # Kafka startup script handles shell wrapping safelyENTRYPOINT ["/etc/confluent/docker/run"]Kubernetes Manifest with Read-Only Filesystem
apiVersion: v1kind: Podmetadata: name: kafka-broker-0spec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 1000 containers: - name: kafka image: confluentinc/cp-kafka:7.5-ubi8 securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] add: ["NET_BIND_SERVICE"] readOnlyRootFilesystem: true # CRITICAL: Use PersistentVolumeClaim for data volumeMounts: - name: kafka-data mountPath: /var/lib/kafka/data - name: tmp mountPath: /tmp - name: var-run mountPath: /var/run env: - name: KAFKA_DATA_DIR value: /var/lib/kafka/data - name: KAFKA_BROKER_ID value: "0" ports: - name: plaintext containerPort: 9092 - name: controller containerPort: 9093 livenessProbe: tcpSocket: port: 9092 initialDelaySeconds: 30 periodSeconds: 10 resources: requests: memory: "1Gi" cpu: "500m" limits: memory: "2Gi" cpu: "1000m" volumes: # DATA LOSS WARNING: Never use emptyDir for Kafka data - name: kafka-data persistentVolumeClaim: claimName: kafka-broker-0-pvc # Temp files during log compaction - name: tmp emptyDir: sizeLimit: 10Gi # Runtime files - name: var-run emptyDir: sizeLimit: 100MiDocker Compose with Read-Only
version: '3.8' services: kafka: image: confluentinc/cp-kafka:7.5-ubi8 read_only: true tmpfs: - /tmp - /var/run # Use named volume (persists data) # NEVER use tmpfs for Kafka data volumes: - kafka_data:/var/lib/kafka/data environment: KAFKA_DATA_DIR: /var/lib/kafka/data KAFKA_BROKER_ID: "0" ports: - "9092:9092" volumes: kafka_data: driver: localCritical: What Breaks Without Writable Paths
Missing Path | Error | Impact |
|---|---|---|
|
| DATA LOSS - all messages lost |
Using | Pod restart loses all messages | COMPLETE DATA LOSS |
|
| Log compaction fails, consumer lag builds |
DATA LOSS SCENARIO: When the pod restarts, emptyDir volumes at /var/lib/kafka/data are wiped. Kafka then starts with an empty directory, causing all historical messages to be lost permanently. Consumers subsequently see only new messages after the restart.
Correct Setup:
volumes:- name: kafka-data persistentVolumeClaim: claimName: kafka-broker-0-pvc # ✓ Data survives pod restartNginx
Nginx caches request bodies, stores temporary files, and needs a runtime directory.
Dockerfile with Read-Only Setup
FROM nginx:alpine # Create necessary directoriesRUN mkdir -p /var/cache/nginx && \ mkdir -p /var/run && \ chown -R nginx:nginx /var/cache/nginx /var/run && \ chmod 700 /var/cache/nginx USER nginx # Copy pre-built config (created during image build)COPY nginx.conf /etc/nginx/nginx.conf ENTRYPOINT ["nginx"]CMD ["-g", "daemon off;"]Kubernetes Manifest with Read-Only Filesystem
apiVersion: v1kind: Podmetadata: name: nginx-webspec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 101 # nginx UID containers: - name: nginx image: nginx:alpine securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] add: ["NET_BIND_SERVICE"] # Needed for port 80 readOnlyRootFilesystem: true volumeMounts: - name: cache mountPath: /var/cache/nginx - name: var-run mountPath: /var/run - name: tmp mountPath: /tmp - name: config mountPath: /etc/nginx readOnly: true ports: - name: http containerPort: 80 - name: https containerPort: 443 livenessProbe: httpGet: path: /health port: 80 initialDelaySeconds: 5 periodSeconds: 10 resources: requests: memory: "64Mi" cpu: "100m" limits: memory: "256Mi" cpu: "500m" volumes: - name: cache emptyDir: sizeLimit: 5Gi - name: var-run emptyDir: sizeLimit: 10Mi - name: tmp emptyDir: sizeLimit: 1Gi - name: config configMap: name: nginx-configDocker Compose with Read-Only
version: '3.8' services: nginx: image: nginx:alpine read_only: true tmpfs: - /var/cache/nginx - /var/run - /tmp volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro ports: - "80:80" - "443:443"What Breaks Without Writable Paths
Missing Path | Error | Impact |
|---|---|---|
|
| Caching disabled, proxying fails |
|
| PID file cannot be written |
|
| Request body buffering fails |
Python
Python applications need /tmp for package installation, virtual environments, and runtime temporary files.
Dockerfile with Read-Only Setup
FROM python:3.11-slim WORKDIR /app # Install dependencies during build (not at runtime)COPY requirements.txt .RUN pip install --no-cache-dir -r requirements.txt # Create app userRUN useradd -m -u 1000 appuser COPY --chown=appuser:appuser . . USER appuser # Exec form (no shell)ENTRYPOINT ["python", "-u", "app.py"]Kubernetes Manifest with Read-Only Filesystem
apiVersion: v1kind: Podmetadata: name: python-appspec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 1000 containers: - name: app image: python-app:1.0.0 securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp - name: cache mountPath: /home/appuser/.cache env: - name: PYTHONUNBUFFERED value: "1" - name: TMPDIR value: /tmp ports: - name: http containerPort: 8000 livenessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 10 periodSeconds: 10 resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "500m" volumes: - name: tmp emptyDir: sizeLimit: 1Gi - name: cache emptyDir: sizeLimit: 500MiDocker Compose with Read-Only
version: '3.8' services: app: build: . read_only: true tmpfs: - /tmp - /home/appuser/.cache environment: PYTHONUNBUFFERED: "1" TMPDIR: /tmp ports: - "8000:8000"What Breaks Without Writable Paths
Missing Path | Error | Impact |
|---|---|---|
|
| Cannot create temp files, crashes |
| pip/poetry caching fails | Slower package lookups |
Node.js
Node.js needs /tmp for NPM package installation, native module compilation, and runtime temp files.
Dockerfile with Read-Only Setup
FROM node:18-alpine WORKDIR /app # Install dependencies during buildCOPY package*.json ./RUN npm ci --only=production && npm cache clean --force # Create non-root userRUN addgroup -g 1000 appuser && \ adduser -D -u 1000 -G appuser appuser COPY --chown=appuser:appuser . . USER appuser # Exec form (no shell)ENTRYPOINT ["node", "server.js"]Kubernetes Manifest with Read-Only Filesystem
apiVersion: v1kind: Podmetadata: name: node-appspec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 1000 containers: - name: app image: node-app:1.0.0 securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp - name: npm-cache mountPath: /home/appuser/.npm env: - name: NODE_ENV value: "production" - name: NODE_OPTIONS value: "--max-old-space-size=256" ports: - name: http containerPort: 3000 livenessProbe: httpGet: path: /health port: 3000 initialDelaySeconds: 10 periodSeconds: 10 resources: requests: memory: "256Mi" cpu: "250m" limits: memory: "512Mi" cpu: "500m" volumes: - name: tmp emptyDir: sizeLimit: 2Gi - name: npm-cache emptyDir: sizeLimit: 1GiDocker Compose with Read-Only
version: '3.8' services: app: build: . read_only: true tmpfs: - /tmp - /home/appuser/.npm environment: NODE_ENV: production NODE_OPTIONS: "--max-old-space-size=256" ports: - "3000:3000"What Breaks Without Writable Paths
Missing Path | Error | Impact |
|---|---|---|
|
| Crashes on first request |
| npm caching fails | Slower package resolution |
Java
Java applications need /tmp for JVM temporary files, garbage collection overhead, and optional heap dumps.
Dockerfile with Read-Only Setup
FROM eclipse-temurin:17-jre-alpine WORKDIR /app # Copy pre-built JAR (built in separate stage)COPY --chown=nobody:nogroup app.jar /app.jar USER nobody # Exec form (no shell)ENTRYPOINT ["java", "-jar", "/app.jar"]Kubernetes Manifest with Read-Only Filesystem
apiVersion: v1kind: Podmetadata: name: java-appspec: securityContext: readOnlyRootFilesystem: true runAsNonRoot: true runAsUser: 65534 # nobody containers: - name: app image: java-app:1.0.0 securityContext: allowPrivilegeEscalation: false capabilities: drop: ["ALL"] readOnlyRootFilesystem: true volumeMounts: - name: tmp mountPath: /tmp - name: java-cache mountPath: /home/nobody/.cache env: - name: JAVA_OPTS value: "-Djava.io.tmpdir=/tmp -Xmx512m -Xms256m" ports: - name: http containerPort: 8080 livenessProbe: httpGet: path: /actuator/health port: 8080 initialDelaySeconds: 30 periodSeconds: 10 resources: requests: memory: "512Mi" cpu: "500m" limits: memory: "1Gi" cpu: "1000m" volumes: - name: tmp emptyDir: sizeLimit: 2Gi - name: java-cache emptyDir: sizeLimit: 500MiDocker Compose with Read-Only
version: '3.8' services: app: image: java-app:1.0.0 read_only: true tmpfs: - /tmp - /home/nobody/.cache environment: JAVA_OPTS: "-Djava.io.tmpdir=/tmp -Xmx512m -Xms256m" ports: - "8080:8080"What Breaks Without Writable Paths
Missing Path | Error | Impact |
|---|---|---|
|
| JVM cannot allocate temporary memory, crashes |
Large |
| GC cannot write temporary data |
Troubleshooting Read-Only Issues
Common Error: "Read-only file system"
Symptom: Application crashes with EACCES: permission denied or similar
When the application starts, it attempts to write to a path. Since the filesystem is read-only, the write fails with "Error: EACCES: permission denied, open '/var/cache/app/data.json'", causing the application to terminate.
Solution: Find which path needs to be writable. Check the error from logs using kubectl logs pod/myapp. The output will show something like "ERROR: Could not write to /var/cache/app". Add that path to volumeMounts in the pod spec. Define the volume in the volumes section. Restart the pod with kubectl rollout restart deployment/myapp.
# 1. Check the error from logskubectl logs pod/myapp# Output: "ERROR: Could not write to /var/cache/app" # 2. Add that path to volumeMountsvolumeMounts:- name: app-cache mountPath: /var/cache/app # 3. Define the volumevolumes:- name: app-cache emptyDir: {} # 4. Restart podkubectl rollout restart deployment/myappFinding Unexpected Write Paths Using strace
When an application fails on read-only filesystem but you don't know why, you can use strace to trace file operations. Create a debug image with debugging tools. Run strace to capture all file operations. Filter results to see which paths fail with permission denied.
# Create -dev image with debugging toolsFROM myapp:latestRUN apt-get update && apt-get install -y strace # Run strace to capture all file operationsdocker run --read-only --tmpfs /tmp myapp-dev:dev \ strace -e open,openat,write -f ./app 2>&1 | grep "Permission denied" # Output will show which paths fail:# openat(AT_FDCWD, "/var/log/app.log", ...) = -1 EACCES (Permission denied)This reveals every path that needs to be writable.
Debugging Multi-Stage Build Issues
If dependencies installed in build stage aren't available in runtime, you have a multi-stage build problem. The wrong approach would have dependencies lost in runtime stage when copying from builder. Instead of using COPY --from=builder /app /app which misses dependencies, copy the entire Python site-packages.
FROM python:3.11 AS builderRUN pip install --target /deps numpy pandas FROM python:3.11-slimCOPY --from=builder /deps /usr/local/lib/python3.11/site-packagesVerifying Volume Mounts
Check that declared volumes are actually mounted. View pod manifest using kubectl get pod myapp -o yaml | grep -A 20 volumeMounts:. Expected output should show:
volumeMounts:- name: tmp mountPath: /tmp- name: cache mountPath: /var/cache/appIf a path is missing from this list, add it.
Testing Read-Only Locally
Before deploying to Kubernetes, test locally. Run Docker with read-only root and test tmpfs mounts. If this works, Kubernetes will work. If this fails, fix the volumes before deploying.
# Docker: Run with read-only rootdocker run --read-only \ --tmpfs /tmp \ --tmpfs /var/run \ -v mydata:/data \ myapp:latestPerformance Considerations
Volume Type Impact on Read-Only Filesystem
Volume Type | Speed | Persistence | Best For |
|---|---|---|---|
| Fast (local storage) | Ephemeral | Temp files, caches that don't need to survive |
| Very fast (RAM) | Ephemeral | Small temp files, performance-critical |
| Depends (NFS, block storage) | Persistent | Databases, message logs, data that must survive |
Rule: Use the fastest option that meets your durability requirements.
Sizing emptyDir Volumes
Specify sizeLimit to prevent runaway temporary files. This fails writes if /tmp exceeds 2GB.
volumes:- name: tmp emptyDir: sizeLimit: 2Gi # Fail writes if /tmp exceeds 2GBWhen sizeLimit is exceeded, the application writes to /tmp and the emptyDir reaches its 2GB limit, the write fails with "No space left on device", and the pod may be evicted by kubelet.
Choose size based on your application: Python/Node.js needs 1-5GB for package installations and build artifacts. Java needs 2-8GB for GC overhead and large allocations. Nginx needs 1-10GB for request buffering and caches. Kafka needs 10-100GB for log compaction temp files.
Summary: Read-Only Filesystem Best Practices
- Always declare writable paths explicitly: Don't assume what your application needs
- Use appropriate volume types:
PersistentVolumeClaimfor data,emptyDirfor temp,tmpfsfor speed - Set volume size limits: Prevent runaway temporary files
- Test locally first:
docker run --read-onlybefore deploying to Kubernetes - Monitor for permission errors: Watch logs for "Read-only file system" errors during rollout
- Document your volumes: Add comments explaining why each path is writable
Read-only filesystems are now a standard security practice. CleanStart applications are designed to work cleanly with them across all application types.
