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.

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:
- Imagen (
/,/app) — inmutable. Node y pnpm no instalan en runtime (multi-stage). /tmp— tmpfs. Muere con el contenedor./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/ Composemem_limit). Untmpfs-sizegrande no suma RAM extra. Llenarlo puede OOM el contenedor (límites). - No se comparte entre contenedores.
--tmpfsno vale en Swarm; ahí--mount type=tmpfs.--mountsintmpfs-size: techo por defecto 50 % de la RAM del host.--mounttmpfs-modedefault 1777 (escribible por todos).--tmpfsadmitesize=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

| Path | Tipo (docs 2026-09-03) | Escritura | Persiste |
|---|---|---|---|
/ imagen | Compose read_only / --read-only | no | n/a |
/app | capa de la imagen | no | n/a |
/tmp | tmpfs, default 50 % RAM host si no pones size | sí, RAM/cgroup | no (stop lo borra) |
/run | tmpfs extra (sockets) | sí | no |
/data | volume (--mount type=volume) | sí | sí |
/dev/shm | --shm-size (otro tmpfs) | sí | 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

| Síntoma | Causa (docs) | Fix |
|---|---|---|
EROFS / EACCES /app | cache de lib en overlay | TMPDIR=/tmp HOME=/tmp |
| sqlite en overlay | path default /app/*.db | DB_PATH=/data/app.db |
| tmp lleno / OOM | tmpfs cuenta al mem_limit; size no suma RAM | size=64m + menos archivos |
Fly escribe en / y se pierde | volume 1:1; default 1 GB; no hay en build | monta /data en runtime |
pnpm add en prod | runtime no es build | imagen inmutable (pin) |
socket unix en /var/run | /var/run quedó read-only | tmpfs /run o HTTP TCP (bind) |
tmpfs noexec y un tool corre binario ahí | --tmpfs noexec | no ejecutes desde /tmp; o quita noexec a sabiendas |
EACCES /data no EROFS | uid del volume ≠ USER | no-root |
Relación con el resto
- Uid: no-root.
- Contexto build: dockerignore.
- Estado: backup.
- Init: tini.
Checklist
-
read_only: trueodocker run --read-only - tmpfs
/tmp(yTMPDIR/HOME) - sqlite en
/data, no en/app - Logs a stdout
-
uid/giddel tmpfs = USER del proceso - Ensayo:
touch /app/x→ EROFS;touch /data/x→ ok; restart →/tmpvacío,/data/xsigue - 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.
Lecturas relacionadas
Sigue explorando Deploy y otras piezas para builders.



