Saltar al contenido principal

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:

StageImagen basePropósito
buildRuntime 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=$BUILDPLATFORM en el stage de build → usa la arquitectura de la máquina que construye (ej. linux/arm64 en Mac con M-chip). Evita la emulación lenta.
  • El stage de runtime no lleva --platform explí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 falta tonistiigi/binfmt ni 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 construye amd64 de 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 ENV le dice a la herramienta dónde cachear; el target del --mount le dice a BuildKit dónde montar el volumen (no lo deriva del ENV). Reutilizar la variable en el target funciona desde el frontend dockerfile v1.3.0, que # syntax=docker/dockerfile:1 ya 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_SERVICE o bajar net.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 usar docker-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 de version: — Compose v2 ignora version: (obsoleto); name fija 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_add solo lo mínimo — el api corre con cap_drop: ALL y 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 en environment: (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/.env en 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 build revienta con SIGILL. 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 add en el stage de runtime
  • bun install / npm install en el stage de runtime
  • ❌ Clonar repos o descargar binarios al arrancar el contenedor
  • ❌ Escribir en el sistema de archivos fuera de /tmp o volúmenes montados
  • ❌ Correr como root (uid 0)
  • ❌ Usar :latest o 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 /tmp o 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), no src= 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 (prefijo BUILD_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 / SIGILL y el aviso CPU lacks AVX support. Importante: bun install (código nativo) 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/yarn como 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 (o node_modules/.bin/<tool>) es estable.

Requisitos y límites

  • node --run requiere Node ≥ 22. Por debajo, usar node_modules/.bin/<tool> <args>.
  • node --run no ejecuta scripts pre/post ni inyecta tantas env vars como npm run — válido para builds simples (sin prebuild/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-wiedii se construye en un runner de infraestructura sin AVX, así que usa este patrón (bun install + node --run build). Ver su Dockerfile.

Referencias

  • repo-setup — checklist de archivos estándar de cualquier repo Wiedii
  • seguridad-api — seguridad de APIs (OWASP API Top 10, /health y /ready)
  • cross-platform — convenciones de compatibilidad multi-plataforma