Making a container’s root filesystem read-only is one of the simplest and most effective hardening measures. If an attacker gets in, they can’t modify binaries or drop malicious files.

Basic usage

services:
  app:
    image: myapp
    read_only: true

That’s it. The container’s filesystem is now immutable. But most applications need to write somewhere — logs, temp files, caches. That’s where tmpfs comes in.

Read-only with tmpfs for writable directories

Combine read_only with tmpfs to allow writes only where needed:

services:
  app:
    image: myapp
    read_only: true
    tmpfs:
      - /tmp:size=50M
      - /var/run:size=10M

A web server typically needs a few writable paths:

services:
  nginx:
    image: nginx
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro

Common writable paths by application

Different applications need different writable directories:

services:
  # Node.js application
  node-app:
    image: node:20-slim
    read_only: true
    tmpfs:
      - /tmp

  # Python application
  python-app:
    image: python:3.12-slim
    read_only: true
    tmpfs:
      - /tmp
      - /root/.cache

  # PostgreSQL (data on volume, rest read-only)
  postgres:
    image: postgres:16
    read_only: true
    tmpfs:
      - /tmp
      - /var/run/postgresql
    volumes:
      - db-data:/var/lib/postgresql/data

volumes:
  db-data:

Full hardening pattern

Combine read_only with other security options for defense in depth:

services:
  web:
    image: docker.io/docker/nginx-unprivileged:latest  # Docker Hardened Image
    read_only: true
    tmpfs:
      - /tmp
      - /var/cache/nginx
      - /var/run
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    ports:
      - "8080:8080"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro

This combines Docker Hardened Images (minimal attack surface, no shell, fewer CVEs), read-only filesystem, capability dropping (Tip #29), and an unprivileged image that runs as non-root by default — multiple layers of protection.

Debugging read-only issues

When switching to read-only, you might see errors like:

Read-only file system: '/var/log/app.log'

Use docker compose exec to find which paths need to be writable:

# Check which files the process tries to write
docker compose exec app find / -writable 2>/dev/null

# Or run with read_only disabled temporarily and monitor writes
docker compose exec app sh -c "inotifywait -mr / 2>&1 | grep -i 'create\|modify'"

Then add only those paths as tmpfs mounts.

Further reading