Guía10 min

Rootfs read-only para un agente IA en Docker

Resumen

Cómo no dejar que un tool escriba en la imagen: read_only true, tmpfs en /tmp y volumen solo en /data. El sqlite no vive en el overlay. Un agente comprometido no persiste binaries. Compose y docker run, consultado el 3 de septiembre de 2026.

Docker
Sistema de archivos de solo lectura con tmpfs y un volumen de datos

Qué resuelve

Esta pieza se queda en la decisión práctica: qué instalar, qué riesgo agrega y cómo aplicarlo sin romper operación.

Un tool del agente hace curl | sh y escribe en /usr. Con rootfs read-only eso falla. El estado va a /data (volumen) y /tmp (tmpfs). USER no-root baja uid; esto baja dónde puede escribir. Fuentes oficiales consultadas el 3 de septiembre de 2026.

La regla

Compose: read_only: true (filesystem de solo lectura). CLI: --read-only monta el rootfs read-only y prohíbe writes fuera de los volúmenes que montes. Ejemplo canónico de Docker: docker run --read-only -v /icanwrite busybox touch /icanwrite/here. Sin volumen, touch /usr/bin/pwn es EROFS.

Tres sitios, no más:

  1. Imagen (/, /app) — inmutable. Node y pnpm no instalan en runtime (multi-stage).
  2. /tmp — tmpfs. Muere con el contenedor.
  3. /data — volumen. sqlite, uploads, lo que backup copia.

Si el proceso necesita ~/.cache, apunta a /tmp o /data. No a /app. HOME=/tmp si alguna lib escribe ~/.npm.

Compose

services:
  agente:
    read_only: true
    tmpfs:
      - /tmp
      - /run
    volumes:
      - data:/data
    environment:
      TMPDIR: /tmp
      HOME: /tmp
      DB_PATH: /data/app.db

Compose documenta tmpfs como valor único o lista. Forma corta: path, o path:opciones. Opciones de la lista corta: mode, uid, gid. Ejemplo oficial: /data:mode=755,uid=1009,gid=1009 y un segundo mount /run.

Sintaxis larga (volumes con type: tmpfs): tmpfs.size en bytes (número o unidad) y tmpfs.mode en octal. mode en esa forma larga llegó en Compose 2.14.0.

docker run --read-only --tmpfs /tmp --mount type=volume,target=/data. Sin tmpfs, os.tmpdir() de Node explota: el overlay ya no acepta writes.

tmpfs no es disco gratis

Docs de tmpfs (Linux only, consultadas hoy):

  • Vive en memoria del host. Al stop, el mount se elimina; nada persiste.
  • Cuenta contra el cgroup de memoria (--memory / Compose mem_limit). Un tmpfs-size grande no suma RAM extra. Llenarlo puede OOM el contenedor (límites).
  • No se comparte entre contenedores.
  • --tmpfs no vale en Swarm; ahí --mount type=tmpfs.
  • --mount sin tmpfs-size: techo por defecto 50 % de la RAM del host.
  • --mount tmpfs-mode default 1777 (escribible por todos).
  • --tmpfs admite size=64m, noexec, nosuid, uid=1000, gid=1000, mode=1777. Ejemplo oficial: --tmpfs /data:noexec,size=1024,mode=1777.

Importante de esa misma página: el kernel puede mandar tmpfs a swap. No es un vault. Es “no queda en el overlay”.

Permisos del tmpfs a veces se resetean al restart. Workaround documentado: fija uid/gid en el mount, alineado con USER node.

Un PDF de 400 MB en /tmp con techo chico → ENOSPC de RAM, no de disco. Topea adjuntos o pon size= y falla claro.

Qué va a /data

sqlite, WAL, uploads. Logs: stdout, no archivo en overlay (disco). El volume /data tiene que existir antes de que Node abra la DB. Si el path es /app/data.db y /app es read-only, EROFS. Cambia DB_PATH=/data/app.db en el mismo PR que read_only.

Fly (overview de volumes, hoy): default 1 GB, máximo 500 GB; 1 Machine ↔ 1 volume; no se comparte entre apps; snapshots diarios 5 días por defecto (rango 1–60). No está disponible en build time. Railway: volumen o nada. Workers / Vercel Functions: no hay rootfs de Linux. No copies el yaml a wrangler.

Restart (restart) no desmonta el volumen. tmpfs sí se vacía. No pongas estado en /tmp “porque es más rápido”.

Tabla

Overlay inmutable, tmpfs y volumen

PathTipo (docs 2026-09-03)EscrituraPersiste
/ imagenCompose read_only / --read-onlynon/a
/appcapa de la imagennon/a
/tmptmpfs, default 50 % RAM host si no pones sizesí, RAM/cgroupno (stop lo borra)
/runtmpfs extra (sockets)no
/datavolume (--mount type=volume)
/dev/shm--shm-size (otro tmpfs)no

Caps, no-root, no sandbox

Read-only no es seccomp. Docker sigue montando /proc y /dev. Engine security (hoy): el daemon arranca con un set restringido de capabilities (allowlist). Best practice: quitar todas salvo las que el proceso pide. User namespaces existen desde Docker 1.10 y no vienen encendidos. Cierre de esa página: los contenedores son razonablemente seguros sobre todo si el proceso no es privilegiado. AppArmor / SELinux / GRSEC son capa extra, no sustituto.

El trio: cap_drop: [ALL] + read_only: true + USER node. Uno solo no basta. Si un tool “necesita root”, ese tool no va en el contenedor del webhook.

Kubernetes (security context, hoy): readOnlyRootFilesystem monta el rootfs del contenedor como read-only. Misma idea. emptyDir en memory ≈ tmpfs. No copies el Pod YAML a Compose; copia la intención.

SELinux/AppArmor en un VPS: si touch /data falla con EACCES y no EROFS, es uid, no el flag.

Errores comunes

EACCES al escribir un cache en /app

SíntomaCausa (docs)Fix
EROFS / EACCES /appcache de lib en overlayTMPDIR=/tmp HOME=/tmp
sqlite en overlaypath default /app/*.dbDB_PATH=/data/app.db
tmp lleno / OOMtmpfs cuenta al mem_limit; size no suma RAMsize=64m + menos archivos
Fly escribe en / y se pierdevolume 1:1; default 1 GB; no hay en buildmonta /data en runtime
pnpm add en prodruntime no es buildimagen inmutable (pin)
socket unix en /var/run/var/run quedó read-onlytmpfs /run o HTTP TCP (bind)
tmpfs noexec y un tool corre binario ahí--tmpfs noexecno ejecutes desde /tmp; o quita noexec a sabiendas
EACCES /data no EROFSuid del volume ≠ USERno-root

Relación con el resto

Checklist

  • read_only: true o docker run --read-only
  • tmpfs /tmp (y TMPDIR / HOME)
  • sqlite en /data, no en /app
  • Logs a stdout
  • uid/gid del tmpfs = USER del proceso
  • Ensayo: touch /app/x → EROFS; touch /data/x → ok; restart → /tmp vacío, /data/x sigue
  • Kill switch no necesita escribir la imagen

FAQ

¿Kubernetes readOnlyRootFilesystem? Misma palanca. En K8s el campo vive en el security context del contenedor. emptyDir con medium memory hace de tmpfs.

¿Next.js .next? El runner no compila. Multi-stage ya copió dist.

¿Caddy en el mismo Compose? El proxy puede ser otro servicio. El agente queda read-only.

¿/proc y /dev? Siguen montados. Read-only es “no mutar el filesystem de la imagen”, no un sandbox.

¿npm config set cache /tmp? Sí, si alguna lib insiste. No abras /app “un ratito”.

Un rootfs writable es el default perezoso. El agente no instala paquetes a las 3 AM. Si lo hace, el digest mintió.

Siguiente paso: si ya es read-only y igual persisten files raros, el volumen: disco. Sin runtime: curso.