Wiedii — Política de Contenedores Docker
⚠️ Verificar versiones antes de usar — los números de versión de imágenes base en esta nota pueden estar desactualizados. Usar
docker pull <image>y revisar las notas de lanzamiento oficiales antes de fijar versiones. Ver politicas-core.
Un contenedor es una caja aislada que lleva la app y solo lo que necesita para correr; una imagen es la plantilla desde la que se crea esa caja. Un build multi-stage compila en una caja grande (con todas las herramientas) y copia solo el resultado a una caja pequeña que se despliega.
Todo contenedor de producción en Wiedii sigue estas convenciones. El objetivo es: imágenes mínimas, reproducibles, seguras y multi-plataforma por defecto.
1. Estructura multi-stage obligatoria
Todo Dockerfile debe usar al menos dos stages:
| Stage | Imagen base | Propósito |
|---|---|---|
build | Runtime completo (oven/bun, golang, node) | Compilar artefactos |
| Runtime (sin nombre especial) | Imagen mínima (alpine, distroless) | Ejecutar únicamente el artefacto |
El stage de runtime nunca instala dependencias de construcción. Solo recibe los artefactos copiados desde build.
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM oven/bun:1.3.14-alpine AS build
WORKDIR /app
# ... instalar deps y compilar ...
FROM caddy:2.11.4-alpine
# ... copiar artefactos y configurar runtime ...
2. Primera línea obligatoria
# syntax=docker/dockerfile:1
Activa BuildKit y habilita las instrucciones avanzadas (--mount, --secret, --ssh). Sin esta línea, las instrucciones de caché y secretos no están disponibles.
3. Imágenes base — versiones exactas
# ❌ Prohibido
FROM node:alpine
FROM caddy:latest
# ✅ Requerido
FROM oven/bun:1.3.14-alpine
FROM caddy:2.11.4-alpine
Renovate gestiona las actualizaciones automáticamente si las versiones están fijadas. Con :latest o :alpine, Renovate no puede rastrear ni actualizar.
4. Multi-plataforma: BUILDPLATFORM y TARGETARCH
FROM --platform=$BUILDPLATFORM oven/bun:1.3.14-alpine AS build
--platform=$BUILDPLATFORMen el stage de build → usa la arquitectura de la máquina que construye (ej.linux/arm64en Mac con M-chip). Evita la emulación lenta.- El stage de runtime no lleva
--platformexplícito → Docker selecciona la arquitectura correcta según el target.
Para condicionamiento por arquitectura en el runtime:
ARG TARGETARCH
RUN if [ "$TARGETARCH" = "arm64" ]; then echo "ARM path"; fi
Verificar ambas arquitecturas antes de sincronizar (política)
En local trabajamos sobre arm64 (Macs con chip M, vía OrbStack + docker compose). El CI construye amd64 por defecto. Por eso un build que pasa en tu máquina (arm64) puede fallar en CI (amd64): dependencias nativas, imágenes base sin esa arquitectura, o flags de compilación.
Política: antes de sincronizar cambios (push / MR) en un repo que tenga Dockerfile, verificar que la imagen construye en ambas arquitecturas:
docker buildx build --platform linux/amd64,linux/arm64 -t miapp:verify .
No hace falta --push ni --load: basta con que el build complete para las dos plataformas. Si tu Dockerfile usa --platform=$BUILDPLATFORM + cross-compilation (recomendado), la verificación es rápida; si compila con deps nativas, la otra arquitectura se emula (más lento) pero igual valida que no rompa en CI.
Prerrequisito (configurar una sola vez)
El build multi-plataforma no funciona con el builder por defecto (driver docker); hace falta un builder con driver docker-container:
docker buildx create --name multiarch --driver docker-container \
--platform linux/amd64,linux/arm64 --use --bootstrap
Comprobar qué builders y plataformas tienes (no hace falta recrear si ya existe uno):
docker buildx ls # busca un builder docker-container que liste linux/amd64 y linux/arm64
Emulación de la arquitectura no nativa:
- En local (OrbStack): no necesitas instalar nada extra — OrbStack ya provee la emulación x86 sobre Apple Silicon. Si además usas
--platform=$BUILDPLATFORM+ cross-compilation (patrón recomendado, sección 4), apenas hay pasos emulados y la verificación es rápida. No hace faltatonistiigi/binfmtni activar Rosetta. - En un host Linux pelado o runner de CI sin emulador integrado: ahí sí se registran los handlers QEMU una vez con
docker run --privileged --rm tonistiigi/binfmt --install all. Nuestro CI construyeamd64de forma nativa, así que normalmente tampoco lo necesita.
5. BuildKit cache mounts
Evita descargar dependencias en cada build. Apunta la caché a /var/cache/ vía la variable de entorno de cada herramienta (no a /root/.cache/): así funciona aunque el build corra como non-root, y la ruta se define una sola vez y se reutiliza en el target (DRY).
# Bun
ENV BUN_INSTALL_CACHE_DIR=/var/cache/bun
RUN --mount=type=cache,target=$BUN_INSTALL_CACHE_DIR \
bun install --frozen-lockfile
# Go
ENV GOCACHE=/var/cache/go/build \
GOMODCACHE=/var/cache/go/mod
RUN --mount=type=cache,target=$GOCACHE \
--mount=type=cache,target=$GOMODCACHE \
go build -o /app/bin ./...
# Python (uv)
ENV UV_CACHE_DIR=/var/cache/uv
RUN --mount=type=cache,target=$UV_CACHE_DIR \
uv sync --frozen
El cache mount persiste entre builds en la misma máquina. En CI, configurar el cache de BuildKit con --cache-from y --cache-to.
Dos capas, una ruta: el
ENVle dice a la herramienta dónde cachear; eltargetdel--mountle dice a BuildKit dónde montar el volumen (no lo deriva delENV). Reutilizar la variable en eltargetfunciona desde el frontenddockerfilev1.3.0, que# syntax=docker/dockerfile:1ya habilita. Usar/var/cache/(no/root/.cache/) hace que la caché funcione aunque el build no corra como root.
6. Usuario no-root obligatorio
El proceso en el runtime nunca corre como root.
Stack Bun / Node
FROM oven/bun:1.3.14-alpine
# La imagen oven/bun incluye el usuario `bun` (uid 1000)
COPY --chown=bun:bun . .
USER bun
Stack Caddy (servidor web estático)
caddy:2.x-alpine no incluye el usuario caddy. Crearlo explícitamente:
FROM caddy:2.11.4-alpine
RUN addgroup -S caddy \
&& adduser -S -G caddy caddy \
&& mkdir -p /data /config /srv \
&& chown -R caddy:caddy /data /config /srv /etc/caddy
COPY --chown=caddy:caddy etc/caddy/Caddyfile /etc/caddy/Caddyfile
COPY --from=build --chown=caddy:caddy /app/build /srv
USER caddy
Stack Go / Python / otros
Crear un usuario dedicado para el proceso:
RUN addgroup -S app && adduser -S -G app app
USER app
7. Puerto no privilegiado (8080)
Los servicios escuchan en un puerto no privilegiado (≥1024); la convención en Wiedii es 8080. El servidor (Caddy, nginx, el runtime) escucha en 0.0.0.0:8080.
EXPOSE 8080
Por qué no 80/443 en el proceso: como el contenedor corre non-root con capabilities dropeadas (sección 6 y sección 14), el proceso no puede bindear puertos privilegiados (menores a 1024) — y así debe ser. La exposición pública en 80/443 la maneja el borde (ingress / reverse-proxy / mapeo de puertos del host, p. ej. -p 80:8080), nunca el proceso.
Matiz: la razón no es que "80 sea inseguro" ni que "8080 evite una escalada de privilegios". El puerto interno es irrelevante para el exterior (el host/ingress lo remapea). La medida de seguridad real es non-root + drop caps + no-new-privileges; el puerto alto es la consecuencia natural (y la opción más simple, KISS, frente a añadir
CAP_NET_BIND_SERVICEo bajarnet.ipv4.ip_unprivileged_port_start).
8. HEALTHCHECK obligatorio
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD wget -qO- http://localhost:8080/health > /dev/null || exit 1
Para servicios sin endpoint HTTP dedicado, usar un comando que verifique el proceso:
HEALTHCHECK --interval=30s --timeout=3s \
CMD pgrep -x myapp > /dev/null || exit 1
Cuándo NO aplica. El HEALTHCHECK es para servicios de larga ejecución (HTTP o daemons/workers que quedan corriendo). Un contenedor de un solo uso —un script, un job batch, una CLI que ejecuta y termina— no lleva HEALTHCHECK: no tiene un estado estable que vigilar; su señal de salud es el exit code (
0= éxito, ≠0 = fallo), que es lo que evalúa quien lo orquesta (cron, el CI, o un Job/CronJob de Kubernetes — no un Deployment con probes). Forzar un HEALTHCHECK en un contenedor que termina no aporta y confunde el estado.
9. Configuración de servicios en etc/
Toda la configuración de los servicios que corren en contenedores va en etc/ en la raíz del repo, organizada en una subcarpeta por servicio o finalidad — nunca suelta en la raíz ni mezclada con src/. El nombre calza con el modelo mental de Unix: lo que vive en etc/<servicio>/ se monta (o copia) en /etc/... dentro del contenedor.
proyecto/
├── Dockerfile
├── compose.yaml
├── etc/
│ ├── caddy/ # Caddyfile (config del servidor web)
│ ├── constants/ # constantes/variables compartidas entre servicios
│ ├── mariadb/ # my.cnf / conf.d + scripts de init
│ ├── mongodb/ # mongod.conf
│ └── redis/ # redis.conf
├── src/
└── ...
Config horneada en la imagen (la de la propia app) — se copia en el build:
COPY --chown=caddy:caddy etc/caddy/Caddyfile /etc/caddy/Caddyfile
Config de servicios de infraestructura (bases de datos, caché) — se monta en compose, siempre de solo lectura (:ro):
services:
redis:
image: redis:7.4-alpine
command: ["redis-server", "/etc/redis/redis.conf"]
volumes:
- ./etc/redis/redis.conf:/etc/redis/redis.conf:ro
mariadb:
image: mariadb:11.4
volumes:
- ./etc/mariadb/conf.d:/etc/mysql/conf.d:ro
- ./etc/mariadb/initdb.d:/docker-entrypoint-initdb.d:ro
Los scripts de entrada (entrypoint.sh) también van bajo etc/<servicio>/.
Ejemplo completo de compose.yaml
Junta las convenciones: app construida desde el Dockerfile (non-root, 8080, healthcheck), servicios de infra con su config montada desde etc/<servicio>/ en :ro, y el hardening de runtime de sección 14. Las versiones de imágenes son ejemplos — pinnearlas y dejar que Renovate las actualice (sección 3).
Nombre del archivo:
compose.yaml— el nombre canónico de Compose v2. No usardocker-compose.yml/docker-compose.yaml(legacy de Compose v1, solo se conserva por retrocompatibilidad); además la regla #11 de politicas-core exige siempre.yaml, nunca.yml.
name: miapp
services:
api:
build:
context: .
args:
BUILDPLATFORM: ${BUILDPLATFORM:-linux/arm64}
image: miapp:local
restart: unless-stopped
ports:
- "8080:8080" # host:contenedor — el borde mapea 80/443 (sección 7)
environment:
MARIADB_HOST: mariadb
MONGODB_URI: mongodb://mongodb:27017/miapp
REDIS_URL: redis://redis:6379
depends_on:
mariadb: { condition: service_healthy }
mongodb: { condition: service_healthy }
redis: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health"]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
# Hardening de runtime (sección 14)
read_only: true
tmpfs: ["/tmp"]
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
mem_limit: 512m
cpus: "1.0"
networks: [backend]
mariadb:
image: mariadb:11.4
restart: unless-stopped
environment:
MARIADB_DATABASE: miapp
MARIADB_USER_FILE: /run/secrets/db_user
MARIADB_PASSWORD_FILE: /run/secrets/db_password
MARIADB_ROOT_PASSWORD_FILE: /run/secrets/db_root_password
volumes:
- mariadb-data:/var/lib/mysql
- ./etc/mariadb/conf.d:/etc/mysql/conf.d:ro
- ./etc/mariadb/initdb.d:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
cap_drop: ["ALL"]
cap_add: ["CHOWN", "SETGID", "SETUID", "DAC_OVERRIDE"] # mínimas que exige mariadb
security_opt: ["no-new-privileges:true"]
secrets: [db_user, db_password, db_root_password]
networks: [backend]
mongodb:
image: mongo:8.0
restart: unless-stopped
command: ["mongod", "--config", "/etc/mongo/mongod.conf"]
volumes:
- mongodb-data:/data/db
- ./etc/mongodb/mongod.conf:/etc/mongo/mongod.conf:ro
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
cap_drop: ["ALL"]
cap_add: ["CHOWN", "SETGID", "SETUID"]
security_opt: ["no-new-privileges:true"]
networks: [backend]
redis:
image: redis:7.4-alpine
restart: unless-stopped
command: ["redis-server", "/etc/redis/redis.conf"]
volumes:
- redis-data:/data
- ./etc/redis/redis.conf:/etc/redis/redis.conf:ro
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
read_only: true
tmpfs: ["/tmp"]
cap_drop: ["ALL"]
security_opt: ["no-new-privileges:true"]
networks: [backend]
networks:
backend:
driver: bridge
volumes:
mariadb-data:
mongodb-data:
redis-data:
secrets:
db_user:
file: ./etc/mariadb/secrets/db_user
db_password:
file: ./etc/mariadb/secrets/db_password
db_root_password:
file: ./etc/mariadb/secrets/db_root_password
Notas:
name:en vez deversion:— Compose v2 ignoraversion:(obsoleto);namefija el nombre del proyecto.- Config siempre
:ro— los servicios no deben poder reescribir su propia configuración. - Datos en volúmenes nombrados, nunca en bind mounts dentro del repo (no se versionan ni se hornean en la imagen).
cap_addsolo lo mínimo — elapicorre concap_drop: ALLy nada añadido; las bases de datos necesitan unas pocas capabilities para arrancar (ajustar según logs).- Secretos por archivo bajo
etc/<servicio>/secrets/(git-ignorado) — nunca credenciales enenvironment:(sección 15).
10. .dockerignore — estructura de tres capas
.dockerignore≠.gitignore. Docker no lee.gitignore; solo.dockerignore. Sus patrones son relativos a la raíz del contexto y no recursan por defecto (node_modules≠**/node_modules), a diferencia de.gitignore. Sirve para reducir el build context: builds más rápidos, imagen liviana y evitar filtrar.git/.enven capas.
# ─── Capa 1: control de versiones y metadatos del repo ────────────────────────
.git
.gitignore
.gitattributes
# ─── Capa 2: JS/TS — excluir a cualquier profundidad ─────────────────────────
# NOTA: usar **/node_modules (con **/) no node_modules.
# .gitignore aplica recursivamente; Docker NO — cada patrón es relativo
# a la raíz del contexto a menos que se use **/.
**/node_modules
build/
.docusaurus/
# ─── Capa 3: específico del proyecto ──────────────────────────────────────────
.env
.env.*
!.env.example
README.md
CLAUDE.md
graphify-out/
.claude/
Nunca excluir el código fuente ni los artefactos que el build necesita: tampoco los lockfiles (bun.lock, go.sum, composer.lock) ni vendor/ cuando Go usa -mod=vendor o PHP no corre composer install en el build.
11. Template completo — sitio estático Bun + Caddy
Referencia para proyectos Docusaurus / static sites:
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM oven/bun:1.3.14-alpine AS build
WORKDIR /app
ENV BUN_INSTALL_CACHE_DIR=/var/cache/bun
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/var/cache/bun \
bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM caddy:2.11.4-alpine
RUN addgroup -S caddy \
&& adduser -S -G caddy caddy \
&& mkdir -p /data /config /srv \
&& chown -R caddy:caddy /data /config /srv /etc/caddy
COPY --chown=caddy:caddy etc/caddy/Caddyfile /etc/caddy/Caddyfile
COPY --from=build --chown=caddy:caddy /app/build /srv
USER caddy
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s \
CMD wget -qO- http://localhost:8080 > /dev/null || exit 1
⚠️ Runner sin AVX (o dependencia incompatible con el runtime de Bun):
bun run buildrevienta conSIGILL. Compilar bajo Node manteniendo Bun como PM — ver el Caso especial (sección 16).
12. Template completo — API Bun/Node
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM oven/bun:1.3.14-alpine AS build
WORKDIR /app
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/var/cache/bun \
bun install --frozen-lockfile --production
COPY . .
FROM oven/bun:1.3.14-alpine
WORKDIR /app
COPY --from=build --chown=bun:bun /app/node_modules ./node_modules
COPY --chown=bun:bun src/ ./src/
COPY --chown=bun:bun package.json .
USER bun
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO- http://localhost:8080/health > /dev/null || exit 1
CMD ["bun", "run", "src/index.ts"]
13. Lo que nunca se hace en runtime
- ❌
apt-get install/apk adden el stage de runtime - ❌
bun install/npm installen el stage de runtime - ❌ Clonar repos o descargar binarios al arrancar el contenedor
- ❌ Escribir en el sistema de archivos fuera de
/tmpo volúmenes montados - ❌ Correr como root (uid 0)
- ❌ Usar
:latesto tags mutables como base
14. Seguridad de runtime (deploy)
El Dockerfile (build) es solo la mitad: el contenedor también debe ejecutarse con privilegios mínimos. Reparto:
- Desarrollo: la imagen corre non-root (sección 6) y es read-only-friendly (escribe solo en
/tmpo volúmenes), y documenta qué capabilities necesita (idealmente ninguna). - Infraestructura: aplica el hardening en el deploy (compose / Kubernetes
securityContext) — ver ci-arquitectura.
# compose — hardening de runtime de referencia
services:
api:
read_only: true # rootfs de solo lectura
tmpfs: [/tmp] # lo escribible, en tmpfs
cap_drop: ["ALL"] # quitar TODAS las capabilities
cap_add: [] # añadir solo si algo lo exige (justificado)
security_opt: ["no-new-privileges:true"] # sin escalada vía setuid/setgid
mem_limit: 512m # límites de recursos (mitiga DoS)
cpus: "1.0"
# PROHIBIDO: privileged: true (otorga TODAS las capabilities)
En Kubernetes el equivalente va en securityContext: runAsNonRoot: true, readOnlyRootFilesystem: true, allowPrivilegeEscalation: false, capabilities: { drop: ["ALL"] }.
15. Secretos en build
Las credenciales que el build necesita (p. ej. registries privados) se montan como secreto efímero — no quedan en ninguna capa de la imagen:
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
bun install --frozen-lockfile
# Local (dev): el secreto es un archivo que ya existe → src=
docker buildx build --secret id=npmrc,src=$HOME/.npmrc .
# CI (GitLab): el secreto es una variable de entorno (masked) → env=
docker buildx build --secret id=wiedii_registry_token,env=BUILD_SECRET_WIEDII_REGISTRY_TOKEN .
El secreto se monta dentro del build en /run/secrets/<id> por defecto (overridable con target=); el RUN lo lee de ahí (cat /run/secrets/<id>).
Nunca COPY un .env ni hornear secretos en ENV/capas — son visibles en docker history / docker inspect.
En CI usa
env=(la variable ya es de entorno), nosrc=con una ruta inventada —/run/secrets/es el montaje dentro del contenedor, no el origen en el runner. El estándar Wiedii de secretos de build en CI (prefijoBUILD_SECRET_, id en minúscula,env=) está en secretos-vs-variables-entorno.
Para distinguir qué es secreto (va por BuildKit secret / deploy) vs variable de entorno no sensible (puede ir por
ARG/ENV/--build-arg), y cómo aplica en CI, ver secretos-vs-variables-entorno.
16. Caso especial — Bun no puede ejecutar el build (CI sin AVX o librería incompatible)
Bun es siempre el package manager (bun install), pero su runtime (el motor JS JavaScriptCore) puede fallar al compilar en ciertos escenarios:
- Runner de CI sin AVX: el binario de Bun emite instrucciones SIMD (AVX) que algunas CPUs antiguas de runners no soportan. El build muere con
panic: Segmentation fault/SIGILLy el avisoCPU lacks AVX support. Importante:bun install(código nativo) sí corre en ese runner — lo que revienta es el motor JS ejecutando un build pesado (webpack/Docusaurus, etc.). - Dependencia incompatible con el runtime de Bun: algunas librerías (bindings nativos, APIs de Node específicas) no funcionan bajo Bun. Ver stack-tecnologico → "Bun como PM, Node.js como runtime".
Remedio: Bun instala, Node compila — vía node --run
No se cambia el package manager. Se separan dos stages: Bun instala, Node compila. Para correr el script de build sin meter otro package manager ni copiar binarios, se usa el task runner nativo de Node (node --run, Node 22+):
# syntax=docker/dockerfile:1
# Bun = package manager (su runtime NO se usa aquí)
FROM --platform=$BUILDPLATFORM oven/bun:1.3.14-alpine AS deps
WORKDIR /app
ENV BUN_INSTALL_CACHE_DIR=/var/cache/bun
COPY package.json bun.lock ./
RUN --mount=type=cache,target=/var/cache/bun \
bun install --frozen-lockfile
# Node = runtime. `node --run build` ejecuta el script "build" de package.json
# (resuelve node_modules/.bin) sin npm/yarn/bun y sin copiar binarios.
FROM --platform=$BUILDPLATFORM node:22.16.0-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN node --run build
# Runtime: igual que el template de sitio estático (sección 11)
FROM caddy:2.11.4-alpine
# ...
Qué NO hacer
- ❌ Copiar el binario de Bun a la stage de Node solo para
bun run build: ahí Bun no ejecuta nada (delega a Node por el shebang#!/usr/bin/env node) — es peso muerto y un binario foráneo sin función. - ❌ Usar
npm/yarncomo runner — mete un package manager ajeno (rompe "Bun es el PM"). - ❌ Referenciar la ruta interna profunda del binario (
node ./node_modules/@scope/.../bin.mjs) — frágil si el paquete cambia su layout;node --run(onode_modules/.bin/<tool>) es estable.
Requisitos y límites
node --runrequiere Node ≥ 22. Por debajo, usarnode_modules/.bin/<tool> <args>.node --runno ejecuta scriptspre/postni inyecta tantas env vars comonpm run— válido para builds simples (sinprebuild/postbuild).
Cuándo aplicarlo
Solo tras confirmar que el runtime de Bun no puede ejecutar el build en el entorno objetivo (revisar el log del job: SIGILL, CPU lacks AVX support, o crash de una dependencia bajo Bun). En runners con CPU moderna, el build bajo Bun es válido y más rápido — no aplicar este patrón por defecto.
Caso real:
wiki-wiediise construye en un runner de infraestructura sin AVX, así que usa este patrón (bun install+node --run build). Ver suDockerfile.
Referencias
- repo-setup — checklist de archivos estándar de cualquier repo Wiedii
- seguridad-api — seguridad de APIs (OWASP API Top 10,
/healthy/ready) - cross-platform — convenciones de compatibilidad multi-plataforma