Una imagen Docker grande tarda más en construirse, en subirse al registro y en descargarse en cada servidor, y además arrastra paquetes que amplían la superficie de ataque. En este tutorial partirás de un Dockerfile típico para una aplicación Node.js y lo optimizarás paso a paso: .dockerignore, imagen base slim, orden de capas para aprovechar la caché, build multi-stage y usuario sin privilegios. Al final verás el mismo enfoque aplicado a un binario Go. Todo se hace en Ubuntu 24.04 con Docker Engine.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS, por ejemplo un VPS de CubePath, con al menos 2 GB de RAM.
- Un usuario no root con privilegios
sudoy miembro del grupodocker. - Docker Engine con el plugin Buildx instalado desde el repositorio oficial de Docker. BuildKit es el constructor por defecto en las versiones actuales, y lo necesitas para los montajes de caché del paso 5.
No necesitas tener Node.js instalado en el servidor: todo se ejecuta dentro de contenedores.
Paso 1: Crear la aplicación de ejemplo
Crea un directorio para el proyecto:
mkdir -p ~/app-node && cd ~/app-node
Genera package.json e instala Express usando un contenedor de Node. Las opciones -u y HOME hacen que los archivos generados sean de tu usuario y no de root:
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD":/app -w /app node:24-slim \
sh -c 'npm init -y >/dev/null && npm install express'
Esto crea package.json, package-lock.json y node_modules/. El archivo de bloqueo es importante: permite instalar exactamente las mismas versiones en cada build con npm ci.
Crea el servidor:
nano server.js
const express = require('express');
const app = express();
app.get('/', (req, res) => res.send('Hola desde CubePath\n'));
app.listen(3000, () => console.log('Escuchando en el puerto 3000'));
Paso 2: Medir el punto de partida
Este es el Dockerfile que se ve en muchos proyectos. Créalo como Dockerfile.inicial:
nano Dockerfile.inicial
FROM node:24
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD ["node", "server.js"]
Tiene tres problemas: la imagen base completa incluye compiladores y cientos de paquetes de Debian, COPY . . mete en la imagen todo el directorio (incluido tu node_modules local), y como el código se copia antes de instalar dependencias, cualquier cambio en server.js obliga a reinstalarlas.
Constrúyela y mira su tamaño:
docker build -f Dockerfile.inicial -t app:inicial .
docker images app
REPOSITORY TAG IMAGE ID CREATED SIZE
app inicial 5d3a1c7e9b02 10 seconds ago 1.13GB
Los tamaños exactos variarán según la versión de la imagen base, pero el orden de magnitud será este. Para ver qué capa aporta cada megabyte usa docker history:
docker history app:inicial
Las capas con más peso son las de la imagen base y la de RUN npm install.
Paso 3: Excluir archivos con .dockerignore
Cuando ejecutas docker build ., Docker envía el directorio entero como contexto de build. El archivo .dockerignore excluye lo que no debe llegar a la imagen: dependencias locales, historial de Git, secretos y artefactos. Créalo:
nano .dockerignore
node_modules
npm-debug.log
.git
.gitignore
.env
*.md
Dockerfile*
.dockerignore
Excluir node_modules es especialmente importante: las dependencias se instalan dentro de la imagen, y las locales podrían estar compiladas para otra plataforma. Excluir .env evita que un secreto acabe publicado en el registro.
Paso 4: Usar una imagen base slim y ordenar las capas
Docker reutiliza de la caché cada capa cuyo contenido y capas anteriores no hayan cambiado. Por eso conviene copiar primero lo que cambia poco (los manifiestos de dependencias), instalar, y copiar el código al final. Crea el Dockerfile:
nano Dockerfile
FROM node:24-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Qué ha cambiado:
node:24-slimes Debian con solo lo necesario para ejecutar Node, sin compiladores ni documentación.npm ci --omit=devinstala exactamente lo que dicepackage-lock.jsony omite las dependencias de desarrollo.npm cache clean --forcese ejecuta en la misma instrucciónRUN. Si lo hicieras en otra, la caché ya estaría guardada en la capa anterior y la imagen no bajaría de tamaño.USER nodeejecuta la aplicación con el usuario sin privilegios que ya incluye la imagen oficial.
Construye y compara:
docker build -t app:slim .
docker images app
REPOSITORY TAG IMAGE ID CREATED SIZE
app slim a41f0c2d8e55 5 seconds ago 232MB
app inicial 5d3a1c7e9b02 3 minutes ago 1.13GB
Ahora modifica el mensaje de server.js y vuelve a construir. En la salida verás CACHED en el paso de npm ci: solo se rehace la capa del código, y el build tarda un par de segundos.
sed -i 's/Hola desde CubePath/Hola de nuevo/' server.js
docker build -t app:slim .
=> CACHED [3/5] COPY package.json package-lock.json ./
=> CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force
=> [5/5] COPY server.js ./
Paso 5: Separar build y ejecución con multi-stage
En una aplicación real el build necesita herramientas que no hacen falta para ejecutarla: dependencias de desarrollo, un compilador de TypeScript, un bundler o python3 y make para módulos nativos. Un build multi-stage usa una etapa para compilar y copia a la imagen final solo el resultado.
Sustituye el contenido del Dockerfile:
nano Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --omit=dev
FROM node:24-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json server.js ./
USER node
EXPOSE 3000
CMD ["node", "server.js"]
Dos detalles:
- La etapa
depses donde iríannpm cicon dependencias de desarrollo ynpm run buildsi tu proyecto compila. La imagen final solo recibe lo que copies conCOPY --from. RUN --mount=type=cacheguarda la caché de npm en el constructor, fuera de la imagen. Los builds siguientes descargan menos y no hace faltanpm cache clean.
Construye, arranca un contenedor y comprueba que responde:
docker build -t app:multistage .
docker run -d --name app -p 127.0.0.1:3000:3000 app:multistage
curl http://127.0.0.1:3000/
Hola de nuevo
Confirma que el proceso no se ejecuta como root:
docker exec app id
uid=1000(node) gid=1000(node) groups=1000(node)
En esta aplicación tan sencilla el tamaño final es similar al del paso 4, porque no hay herramientas de build que descartar. En proyectos con TypeScript, Next.js o módulos nativos la diferencia suele ser de cientos de megas. Elimina el contenedor de prueba:
docker rm -f app
Paso 6: Llevarlo al extremo con un binario estático (Go)
Los lenguajes que generan binarios estáticos permiten una imagen final casi vacía. Como ejemplo, crea un servidor Go mínimo en otro directorio:
mkdir -p ~/app-go && cd ~/app-go
nano main.go
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hola desde Go")
})
http.ListenAndServe(":8080", nil)
}
Crea el Dockerfile. La etapa de build compila con la imagen oficial de Go, y la final usa gcr.io/distroless/static-debian12:nonroot, que solo contiene certificados CA, datos de zona horaria y un usuario sin privilegios:
nano Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1 AS build
WORKDIR /src
COPY main.go ./
RUN go mod init example.com/hola && \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hola .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/hola /hola
EXPOSE 8080
ENTRYPOINT ["/hola"]
CGO_ENABLED=0 genera un binario sin dependencias de libc, y -ldflags="-s -w" elimina la información de depuración. En un proyecto real tendrías go.mod y go.sum en el repositorio y los copiarías antes que el código, igual que en el paso 4.
Construye y compara con la imagen de compilación:
docker build -t hola-go .
docker images --format '{{.Repository}}:{{.Tag}} {{.Size}}' | grep -E 'hola-go|golang'
hola-go:latest 9.5MB
golang:1 850MB
La imagen final pesa unos pocos megas. La contrapartida es que no hay shell: no podrás usar docker exec -it hola-go sh. Para depurar, usa un contenedor de herramientas que comparta su red (docker run --rm -it --network container:<nombre> nicolaka/netshoot) o la variante :debug de distroless, que incluye busybox.
Paso 7: Inspeccionar las capas con dive
docker history dice cuánto pesa cada capa, pero no qué archivos contiene. La herramienta dive muestra el contenido capa a capa y señala el espacio desperdiciado (archivos añadidos en una capa y borrados en otra). Puedes ejecutarla como contenedor sin instalar nada:
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive:latest app:inicial
Navega entre capas con las flechas, cambia de panel con Tab y sal con Ctrl+C. El indicador Image efficiency score y la lista de archivos potencialmente desperdiciados te dicen qué limpiar.
Advertenciamontar
/var/run/docker.sockda al contenedor control total sobre Docker, equivalente a root en el host. Hazlo solo con imágenes de confianza y para usos puntuales como este.
Resumen de técnicas
| Técnica | Qué reduce |
|---|---|
.dockerignore | Contexto de build y archivos accidentales (secretos, .git) |
Imagen base slim, alpine o distroless | Paquetes del sistema que la aplicación no usa |
| Copiar manifiestos antes que el código | Tiempo de build, gracias a la caché |
Limpiar en la misma instrucción RUN | Espacio muerto en capas intermedias |
| Build multi-stage | Compiladores y dependencias de desarrollo en la imagen final |
RUN --mount=type=cache | Descargas repetidas entre builds |
Etiquetas fijas (node:24-slim, no latest) | Cambios inesperados de tamaño o comportamiento |
Sobre Alpine: sus imágenes son muy pequeñas, pero usan musl en lugar de glibc. Algunos módulos nativos de Node o ruedas de Python precompiladas no funcionan o hay que compilarlos, lo que alarga el build. Si no tienes un motivo concreto, las variantes slim son un buen equilibrio.
Para paquetes del sistema en imágenes Debian o Ubuntu, instala sin paquetes recomendados y borra las listas de apt en la misma capa:
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
Solución de problemas
npm ci falla porque package.json y package-lock.json no están sincronizados o no encuentra el lockfile. npm ci necesita package-lock.json y que esté sincronizado con package.json. Regenéralo con npm install y comprueba que no está en .dockerignore.
El build no reutiliza la caché aunque no has tocado las dependencias. Algo anterior a la instrucción RUN cambia en cada build: un COPY . . antes de instalar, un ARG con valor variable o un archivo que se regenera. Revisa el orden de las instrucciones.
Error: Cannot find module en la imagen final multi-stage. Falta algún archivo en los COPY --from. Copia también los directorios que genere tu build (por ejemplo dist/).
exec /hola: no such file or directory en la imagen distroless. El binario está enlazado dinámicamente. Compila con CGO_ENABLED=0 o usa la imagen gcr.io/distroless/base-debian12, que incluye glibc.
Conclusión
Has pasado de una imagen de más de 1 GB a una de unos 230 MB para Node.js y a menos de 10 MB para un binario Go, con builds que reutilizan la caché y procesos que no se ejecutan como root. Las mismas reglas sirven para cualquier lenguaje: contexto limpio, base mínima, capas ordenadas y separación entre build y ejecución.
Como siguientes pasos puedes:
- Escanear tus imágenes en busca de vulnerabilidades con Trivy antes de publicarlas.
- Construir imágenes para
amd64yarm64a la vez condocker buildx build --platform. - Automatizar el build en tu CI con la caché de BuildKit exportada al registro (
--cache-toy--cache-from).
