Docker 29 🐳 Read-Only Containers (–read-only) and Mounting Temporary In-Memory File Systems
A container’s root filesystem is writable by default. The application can write to any directory that its user has permission to write to, including the directories where the application’s own binaries and configuration files live. This is convenient for development and for applications that assume they can write anywhere. It is also a security liability. An attacker who gains code execution inside the container can modify the application’s files, install a backdoor, or write a persistence mechanism. The --read-only flag removes the writable layer entirely, turning the container’s root filesystem into an immutable golden image. The few directories that genuinely need to be writable are provided explicitly as tmpfs mounts or volumes.
This chapter covers read-only containers and the tmpfs mounts that make them work. You will learn what the --read-only flag does, how to identify the directories an application needs to write to, how to mount those directories as tmpfs or volumes, and the security posture that results. You will also see the limitations of tmpfs and the patterns for common applications like Nginx, Node.js, and databases.
Key point: --read-only mounts the container’s root filesystem as read-only. The application cannot write to any path that is part of the image. Directories that need to be writable are declared explicitly with --tmpfs (ephemeral, in-memory) or --mount type=volume (persistent). This enforces the principle of immutable infrastructure: the container’s filesystem is a golden image that cannot be tampered with at runtime.
Why read-only containers exist
The persistence problem. A compromised container with a writable filesystem can install a backdoor, modify configuration files, or write a malicious executable to a directory in the PATH. The attacker’s changes persist for the lifetime of the container and may survive restarts if the filesystem is not ephemeral. A read-only root filesystem prevents all of this. There is no writable layer to persist anything to.
The tampering problem. Even without a compromise, a writable root filesystem allows accidental modification. A misconfigured application might overwrite its own configuration. A runaway process might fill the disk. A read-only filesystem makes these failures impossible. The application can only write to the paths that were explicitly provided.
The immutability problem. The read-only flag enforces the principle of immutable infrastructure. The container’s filesystem is treated as a “golden image” that is never modified at runtime. When the container is replaced, the new container starts from the same immutable image. There is no drift between the image and the running container.
The explicitness problem. The read-only flag forces the deployment to be explicit about what the application needs to write. Instead of assuming the application can write anywhere, the deployment must declare each writable path. This surfaces the application’s write requirements and makes them visible in the container configuration.
The security problem. The attack surface is reduced. An attacker who gains code execution cannot write to the filesystem, cannot modify the application’s binaries, and cannot create new files outside the explicitly mounted paths. The only writable locations are the ones the deployment provided, and those are usually ephemeral.
a. The --read-only flag and identifying writable paths
The --read-only flag is passed to docker run. It mounts the container’s root filesystem as read-only.
docker run --read-only nginx
This command will likely fail. Nginx needs to write to /var/cache/nginx, /var/run, and /var/log/nginx. With a read-only root filesystem, those writes fail, and Nginx exits with an error.
The error messages are the first tool for identifying writable paths. When the container starts with --read-only and fails, the logs show which paths the application tried to write to. The pattern is to start the container read-only, read the error, and add a tmpfs or volume mount for the path that failed.
A more systematic approach is to inspect the application’s Dockerfile and documentation. The Dockerfile shows where the application is installed and what directories it creates. The VOLUME instruction in a Dockerfile indicates a directory that is expected to be writable. The application’s documentation describes its write requirements.
A third approach is strace. Running the application under strace and filtering for write, open, and openat system calls shows every file the application tries to open for writing. This is the most thorough method and is useful for applications with undocumented write paths.
For common applications, the writable paths are well known. Nginx needs /var/cache/nginx, /var/run, and optionally /var/log/nginx. Node.js applications typically need /tmp and possibly a log directory. Python applications need /tmp and the cache directory. Databases need their data directory as a volume, not a tmpfs, because the data must persist.
b. Mounting writable paths with tmpfs
A tmpfs mount provides a writable directory that stores its data in the host’s memory. The data is lost when the container stops, and it never touches the disk. This is the right choice for directories that need to be writable but whose contents do not need to persist: caches, PID files, socket files, and temporary scratch space.
The --tmpfs flag is the short form.
docker run --read-only \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
nginx
The --mount flag is the preferred form because it is more explicit and supports more options.
docker run --read-only \
--mount type=tmpfs,dst=/var/cache/nginx,tmpfs-size=100m,tmpfs-mode=1777 \
--mount type=tmpfs,dst=/var/run,tmpfs-size=10m,tmpfs-mode=755 \
nginx
The tmpfs-size option sets the maximum size of the mount. The tmpfs-mode option sets the permissions. The mode=1777 is the standard for /tmp: world-writable with the sticky bit, so users can only delete their own files. The mode=755 is the standard for /var/run.
The size matters. tmpfs data counts toward the container’s memory limit. A large tmpfs-size does not grant additional RAM outside the container’s memory limit. If the container has a 512 MB memory limit and a 256 MB tmpfs mount, the application has 256 MB left for its own memory. Filling the tmpfs can cause the container to be killed by the OOM killer.
A tmpfs mount obscures any files that were in the directory before the mount. If the image has files in /var/cache/nginx, mounting a tmpfs over that directory hides them. This is usually desirable—the cache directory is meant to be empty at startup—but it is worth knowing.
The tmpfs mount is specific to Linux. It does not work on Windows containers. It also cannot be shared between containers. Each container gets its own tmpfs mount.
c. Persistent writable paths and combined hardening
Some applications need writable directories whose contents must persist across container restarts. A log directory, an upload directory, or an application data directory falls into this category. These paths should be mounted as named volumes, not tmpfs.
docker run --read-only \
--mount type=tmpfs,dst=/var/cache/nginx \
--mount type=tmpfs,dst=/var/run \
--mount type=volume,source=nginx-logs,dst=/var/log/nginx \
nginx
The nginx-logs volume persists the logs. The tmpfs mounts provide the ephemeral writable paths. The root filesystem remains read-only. This is the complete pattern: read-only root, tmpfs for ephemeral paths, volumes for persistent paths.
The same pattern applies to a Node.js application.
docker run --read-only \
--mount type=tmpfs,dst=/tmp,tmpfs-size=64m \
--mount type=volume,source=app-logs,dst=/app/logs \
--mount type=bind,source=./app,dst=/app,readonly \
node:24-alpine
The application code is mounted read-only from the host. The logs go to a volume. The temporary files go to tmpfs. The root filesystem is read-only.
A PostgreSQL container with a read-only root filesystem needs a tmpfs for /run/postgresql and a volume for /var/lib/postgresql/data.
docker run --read-only \
--mount type=tmpfs,dst=/run/postgresql,uid=999,gid=999 \
--mount type=volume,source=pgdata,dst=/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:18
The uid and gid on the tmpfs mount match the UID and GID that PostgreSQL runs as inside the container. Without this, PostgreSQL cannot write to the directory.
The read-only flag should be combined with other hardening measures. Dropping all capabilities and adding back only what is needed (cap_drop: [ALL], cap_add: [NET_BIND_SERVICE]), preventing privilege escalation (no-new-privileges:true), and running as a non-root user (user: "1000:1000") all complement the read-only filesystem. The read-only flag is one layer; the full posture is several.
Complete Example Session
# ============================================
# PART 1: READ-ONLY CONTAINER FAILS
# ============================================
docker run --read-only nginx
# Error: nginx: [emerg] mkdir() "/var/cache/nginx/client_temp" failed (30: Read-only file system)
# ============================================
# PART 2: ADD TMPFS FOR NGINX
# ============================================
docker run -d --name web \
--read-only \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
nginx
# ============================================
# PART 3: VERIFY ROOT IS READ-ONLY
# ============================================
docker exec web touch /test-file
# touch: /test-file: Read-only file system
# ============================================
# PART 4: VERIFY TMPFS IS WRITABLE
# ============================================
docker exec web touch /var/cache/nginx/test-file
# Success
# ============================================
# PART 5: ADD PERSISTENT LOG VOLUME
# ============================================
docker run -d --name web \
--read-only \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
--mount type=volume,source=nginx-logs,dst=/var/log/nginx \
nginx
# ============================================
# PART 6: NODE.JS WITH READ-ONLY ROOT
# ============================================
docker run -d --name app \
--read-only \
--tmpfs /tmp:size=64m \
--mount type=volume,source=app-logs,dst=/app/logs \
--mount type=bind,source=./app,dst=/app,readonly \
node:24-alpine
# ============================================
# PART 7: TMPFS WITH SIZE AND MODE
# ============================================
docker run --read-only \
--mount type=tmpfs,dst=/tmp,tmpfs-size=64m,tmpfs-mode=1777 \
alpine
# ============================================
# PART 8: TMPFS WITH UID/GID
# ============================================
docker run --read-only \
--mount type=tmpfs,dst=/run/postgresql,uid=999,gid=999 \
postgres:18
# ============================================
# PART 9: DOCKER COMPOSE EQUIVALENT
# ============================================
cat > docker-compose.yml << EOF
services:
web:
image: nginx
read_only: true
tmpfs:
- /var/cache/nginx:size=100m
- /var/run:size=10m
volumes:
- nginx-logs:/var/log/nginx
volumes:
nginx-logs:
EOF
docker compose up -d
# ============================================
# PART 10: COMBINED HARDENING
# ============================================
docker run -d --name secure \
--read-only \
--tmpfs /tmp:size=64m \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--security-opt=no-new-privileges:true \
--user=1000:1000 \
nginx:alpine
The ten parts covered the read-only failure, adding tmpfs for Nginx, verifying the read-only root, verifying the writable tmpfs, adding a persistent log volume, Node.js with a read-only root, tmpfs with size and mode, tmpfs with UID/GID, the Docker Compose equivalent, and combined hardening.
Quick Reference
Read-Only Flags
| Flag | Effect |
|---|---|
--read-only | Mount root filesystem read-only |
--tmpfs /path | Mount a writable tmpfs at /path |
--mount type=tmpfs,dst=/path | Explicit tmpfs mount |
--mount type=volume,source=name,dst=/path | Persistent volume |
tmpfs Options
| Option | Purpose | Example |
|---|---|---|
tmpfs-size | Maximum size | 64m, 1g |
tmpfs-mode | Permissions | 1777 (sticky), 755 |
uid | Owner UID | uid=999 |
gid | Owner GID | gid=999 |
Common Writable Paths
| Application | tmpfs Paths | Volume Paths |
|---|---|---|
| Nginx | /var/cache/nginx, /var/run | /var/log/nginx |
| Node.js | /tmp | /app/logs, /app/data |
| Python | /tmp, /root/.cache | /app/logs |
| PostgreSQL | /run/postgresql | /var/lib/postgresql/data |
| Redis | /tmp | /data |
| Java | /tmp | Log/heap dump directories |
Docker Compose Syntax
services:
app:
image: myapp
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
- /run:size=10m
volumes:
- app-logs:/app/logs
volumes:
app-logs:
Verification Commands
| Command | Purpose |
|---|---|
docker exec c touch /test-file | Verify root is read-only |
docker exec c touch /tmp/test | Verify tmpfs is writable |
docker exec c touch /app/logs/test | Verify volume is writable |
docker inspect c | Check mounts and flags |
Best Practices
✅ Do This:
# Use --read-only for all production containers
docker run --read-only myapp # ✅
# Add tmpfs for ephemeral writable paths
docker run --read-only --tmpfs /tmp --tmpfs /var/run myapp # ✅
# Set size limits on tmpfs to prevent memory exhaustion
docker run --read-only --mount type=tmpfs,dst=/tmp,tmpfs-size=64m myapp # ✅
# Use volumes for persistent writable paths
docker run --read-only --mount type=volume,source=logs,dst=/app/logs myapp # ✅
# Set uid/gid on tmpfs for database containers
docker run --read-only --mount type=tmpfs,dst=/run/postgresql,uid=999,gid=999 postgres:18 # ✅
# Combine with other hardening measures
docker run --read-only --cap-drop=ALL --security-opt=no-new-privileges:true --user=1000:1000 myapp # ✅
# Test that the container works with read-only root
docker exec c touch /test-file # should fail # ✅
❌ Don’t Do This:
# Don't run production containers without --read-only
docker run myapp # writable root filesystem # ⚠️
# Don't mount the entire filesystem as tmpfs
docker run --read-only --tmpfs / myapp # obscures everything # ❌
# Don't set tmpfs-size larger than the memory limit
docker run --memory=512m --read-only --mount type=tmpfs,dst=/tmp,tmpfs-size=1g myapp # ❌
# Don't use tmpfs for data that must persist
docker run --read-only --tmpfs /data myapp # data lost on stop # ❌
# Don't forget uid/gid for non-root applications
docker run --read-only --mount type=tmpfs,dst=/run/postgresql postgres:18 # ❌ permission denied
# Don't mount the Docker socket in a read-only container
docker run --read-only -v /var/run/docker.sock:/var/run/docker.sock myapp # ❌ security risk
# Don't assume read-only alone is sufficient
# Combine with capability dropping, no-new-privileges, and non-root # ⚠️
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Container fails to start | Application needs writable path | Add tmpfs or volume |
| Permission denied on tmpfs | Wrong UID/GID | Set uid/gid on mount |
| Container OOM killed | tmpfs fills memory | Reduce tmpfs-size |
| Data lost on restart | Used tmpfs for persistent data | Use a volume |
tmpfs not available | Windows container | Use a volume |
| Files obscured | tmpfs mounted over existing files | Expected behavior |
| Permissions reset | Container restart | Check Docker version |
Real-World Examples
1. Nginx Read-Only
docker run -d --name web \
--read-only \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
--mount type=volume,source=nginx-logs,dst=/var/log/nginx \
nginx
2. Node.js Read-Only
docker run -d --name app \
--read-only \
--tmpfs /tmp:size=64m \
--mount type=volume,source=app-logs,dst=/app/logs \
--mount type=bind,source=./app,dst=/app,readonly \
node:24-alpine
3. PostgreSQL Read-Only
docker run -d --name db \
--read-only \
--mount type=tmpfs,dst=/run/postgresql,uid=999,gid=999 \
--mount type=volume,source=pgdata,dst=/var/lib/postgresql/data \
-e POSTGRES_PASSWORD=secret \
postgres:18
4. Redis Read-Only
docker run -d --name cache \
--read-only \
--tmpfs /tmp \
--mount type=volume,source=redis-data,dst=/data \
redis:7-alpine
5. Python Read-Only
docker run -d --name api \
--read-only \
--tmpfs /tmp:size=50m \
--tmpfs /root/.cache:size=50m \
--mount type=volume,source=api-logs,dst=/app/logs \
python:3.11-slim
6. Docker Compose
services:
web:
image: nginx
read_only: true
tmpfs:
- /var/cache/nginx:size=100m
- /var/run:size=10m
volumes:
- nginx-logs:/var/log/nginx
volumes:
nginx-logs:
7. Combined Hardening
docker run -d --name secure \
--read-only \
--tmpfs /tmp:size=64m \
--cap-drop=ALL \
--cap-add=NET_BIND_SERVICE \
--security-opt=no-new-privileges:true \
--user=1000:1000 \
--memory=512m \
myapp
8. Verify Read-Only
docker exec web touch /test-file
# Read-only file system
9. Verify tmpfs
docker exec web touch /var/cache/nginx/test-file
# Success
10. Inspect Mounts
docker inspect web | jq '.[0].HostConfig.ReadonlyRootfs'
# true
docker inspect web | jq '.[0].HostConfig.Tmpfs'
# {"/var/cache/nginx": "", "/var/run": ""}
Visual
Read-Only Container Architecture
┌─────────────────────────────────────────────────────────────┐
│ READ-ONLY CONTAINER │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Root filesystem (read-only) │ │
│ │ /usr/bin/nginx (immutable) │ │
│ │ /etc/nginx/ (immutable) │ │
│ │ /app (immutable) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ tmpfs mounts (writable, ephemeral) │ │
│ │ /var/cache/nginx (in memory) │ │
│ │ /var/run (in memory) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Volumes (writable, persistent) │ │
│ │ /var/log/nginx (on disk) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ The root is immutable. Writable paths are explicit. │
│ │
└─────────────────────────────────────────────────────────────┘
tmpfs Memory Accounting
┌─────────────────────────────────────────────────────────────┐
│ Container memory limit: 512 MB │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ tmpfs /tmp: 64 MB │ │
│ │ tmpfs /run: 10 MB │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Application memory: 438 MB available │
│ │
│ If the application fills /tmp and /run, the container │
│ can OOM. The tmpfs size counts toward the limit. │
│ │
│ Setting tmpfs-size=1g with memory=512m does not work. │
│ The mount is limited by the container's memory. │
│ │
└─────────────────────────────────────────────────────────────┘
Identifying Writable Paths
┌─────────────────────────────────────────────────────────────┐
│ METHOD 1: Run and check errors │
│ docker run --read-only myapp │
│ Logs show: "Read-only file system" at /path │
│ │
│ METHOD 2: Inspect Dockerfile │
│ VOLUME /data │
│ RUN mkdir /var/log/app │
│ │
│ METHOD 3: strace │
│ docker run --cap-add=SYS_PTRACE myapp \ │
│ strace -f -e trace=write,open,openat │
│ │
│ METHOD 4: Documentation │
│ The application's docs list write paths │
│ │
└─────────────────────────────────────────────────────────────┘
Combined Security Posture
┌─────────────────────────────────────────────────────────────┐
│ LAYERED HARDENING │
│ │
│ 1. Read-only root filesystem │
│ --read-only │
│ │
│ 2. Writable paths only where needed │
│ --tmpfs /tmp, --mount type=volume,... │
│ │
│ 3. Drop all capabilities │
│ --cap-drop=ALL │
│ │
│ 4. Add back only what is needed │
│ --cap-add=NET_BIND_SERVICE │
│ │
│ 5. Prevent privilege escalation │
│ --security-opt=no-new-privileges:true │
│ │
│ 6. Run as non-root │
│ --user=1000:1000 │
│ │
│ 7. Memory limit │
│ --memory=512m │
│ │
│ Each layer is independent. Together they reduce the │
│ blast radius of a compromise. │
│ │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Read-only flag | --read-only |
| tmpfs flag | --tmpfs /path |
| tmpfs mount | --mount type=tmpfs,dst=/path |
| tmpfs size | tmpfs-size=64m |
| tmpfs mode | tmpfs-mode=1777 |
| tmpfs uid/gid | uid=999,gid=999 |
| Persistent writable | --mount type=volume,source=name,dst=/path |
| Root verification | touch /test-file fails |
| tmpfs verification | touch /tmp/test succeeds |
| Compose equivalent | read_only: true + tmpfs: list |
Key takeaways:
--read-onlymounts the container’s root filesystem as read-only. The application cannot write to any path that is part of the image. This prevents filesystem tampering, backdoor installation, and accidental modification.- Writable paths are declared explicitly with
tmpfsor volumes.tmpfsprovides ephemeral, in-memory writable storage for caches, PID files, and temporary data. Volumes provide persistent writable storage for logs, uploads, and application data. tmpfsdata counts toward the container’s memory limit. A largetmpfs-sizedoes not grant additional RAM outside the limit. Filling the mount can cause the container to be killed by the OOM killer.- The
--mountflag is preferred over--tmpfs. It is more explicit and supports more options, includingtmpfs-size,tmpfs-mode,uid, andgid. tmpfsobscures existing files in the mounted directory. If the image has files in the directory, mounting atmpfsover it hides them. This is usually the intended behavior for cache directories.tmpfsis Linux-only and cannot be shared between containers. It does not work on Windows containers, and each container gets its owntmpfsmount.- The read-only flag is one layer of a security posture. Combine it with
--cap-drop=ALL,--cap-addfor the minimum needed,--security-opt=no-new-privileges:true, and--userto run as non-root.
Remember: A read-only container is not a container that cannot write. It is a container whose root filesystem cannot be written, with explicitly declared writable paths for the directories that need them. The pattern is: start read-only, read the errors, add tmpfs for ephemeral paths and volumes for persistent paths, and verify that the root is read-only and the writable paths work. The result is a container that is harder to compromise, easier to reason about, and consistent with the principle of immutable infrastructure. The application’s filesystem is a golden image. The writable paths are the exceptions, and they are visible in the container’s configuration.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!