# Cómo optimizar un Dockerfile y reducir el tamaño de las imágenes

**Categoría:** DevOps y cloud · **Nivel:** Intermedio · **Lectura:** 12 min
**Publicada:** 2026-07-29 · **Actualizada:** 2026-07-29 · **Autoría:** Underc0de
**Versión HTML (canónica):** https://underc0de.org/guias/devops-cloud/optimizar-un-dockerfile-y-reducir-imagenes/

## Respuesta rápida

Optimizar un **Dockerfile** significa reducir el tamaño final de la imagen y acelerar las reconstrucciones, y ambas cosas dependen de las mismas decisiones: el orden de las instrucciones, la separación en varias etapas y la elección de la imagen base. Docker cachea cada instrucción como una **capa**, y si una capa se invalida, se invalidan también todas las que siguen; por eso conviene copiar primero los archivos de dependencias, instalarlas, y recién al final copiar el código fuente, que es lo que cambia en cada commit. Una **construcción multietapa** (*multi-stage build*) compila en una etapa descartable y copia a la imagen final solo el artefacto resultante. El tamaño no es un detalle estético: es superficie de ataque y velocidad de despliegue.

## Por qué el tamaño importa de verdad

Es fácil tratar el tamaño de una imagen **Docker** como un detalle cosmético: «total, el disco es barato». Pero cuando la imagen crece, empeoran dos cosas concretas. La primera es la **velocidad de despliegue**: cada actualización implica subir la imagen a un registro y bajarla en cada servidor o nodo, y una imagen de 900 MB tarda sensiblemente más que una de 90 MB. La segunda, más importante, es la **superficie de ataque**: todo lo que entra a la imagen final —un compilador, un gestor de paquetes, una shell— existe dentro del contenedor y, si alguien logra ejecutar código en él, puede usarlo para moverse o persistir. Una imagen mínima no es infalible, pero reduce lo disponible para quien ya entró.

> **El tamaño no es estética**
>
> Es superficie de ataque y velocidad de despliegue. Esta guía desarrolla un punto que [Docker desde cero](../docker-desde-cero-imagenes-contenedores-y-volumenes/index.md) menciona en una sola línea: partir de una imagen base chica y copiar las dependencias antes que el código.

## Cómo funciona la caché de capas

Cada instrucción de un Dockerfile —cada `FROM`, `COPY`, `RUN`— genera una **capa**: una unidad de solo lectura que Docker guarda y reutiliza si nada cambió. Al reconstruir, Docker recorre el archivo de arriba hacia abajo y, para cada instrucción, compara si puede reutilizar la capa de la construcción anterior o si tiene que ejecutarla de nuevo.

Acá está el punto que ordena todo lo demás: **si una capa se invalida, se invalidan también todas las capas siguientes**, sin importar si esas capas cambiaron o no. Docker no analiza si el resultado sería el mismo: apenas detecta un cambio, descarta la caché de ahí en adelante y ejecuta el resto desde cero.

![Diagrama en dos paneles. Arriba, dos versiones del mismo Dockerfile de Node.js: en la de la izquierda, incorrecta, el código se copia en la segunda capa y un cambio de código invalida cuatro de cinco capas, incluida la instalación de dependencias; en la de la derecha, correcta, las dependencias se copian e instalan primero y el mismo cambio de código solo invalida las dos últimas capas. Abajo, una construcción multietapa: una etapa «build», con el compilador y el código fuente, produce un binario y se descarta por completo; una flecha con la instrucción COPY --from=build indica que solo ese binario pasa a la imagen final, mucho más chica, que parte de una base mínima de ejecución.](../../assets/img/guias/dockerfile-capas-multietapa.svg)

*Arriba, el efecto del orden sobre la caché de capas. Abajo, cómo una construcción multietapa descarta la etapa de compilación y solo copia el artefacto final.*

La consecuencia práctica es una regla simple: las instrucciones que cambian con más frecuencia van lo más abajo posible. El código fuente cambia en cada commit; las dependencias —lo declarado en `package.json`, `requirements.txt` o `go.mod`— cambian mucho menos. Copiar el código antes que las dependencias mezcla lo volátil con lo estable, y cada cambio de una línea fuerza a reinstalar todo de nuevo.

## El orden de las instrucciones: antes y después

El efecto se ve mejor con un ejemplo: una aplicación Node.js empaquetada con el orden más habitual entre quien recién empieza con Docker.

```dockerfile
# Antes: código primero
FROM node:20
WORKDIR /app

COPY . .

RUN npm install
RUN npm run build

CMD ["node", "dist/server.js"]
```

El problema está en `COPY . .`, en la segunda capa: copia todo el proyecto, incluido el código que cambia en cada commit. Como esa capa se invalida en cada cambio, arrastra en cascada las siguientes: instalación de dependencias, compilación y arranque. Una sola línea de código dispara una reinstalación completa de `node_modules`, aunque ninguna dependencia haya cambiado.

Reordenando para separar lo estable de lo volátil:

```dockerfile
# Después: orden correcto
FROM node:20
WORKDIR /app

# 1. Dependencias primero
COPY package.json package-lock.json ./
RUN npm ci

# 2. Código al final
COPY . .
RUN npm run build

CMD ["node", "dist/server.js"]
```

`npm ci` ahora solo se reejecuta si cambia `package.json` o `package-lock.json`. Un cambio de código sigue invalidando las capas desde `COPY . .` en adelante, pero ya no toca la instalación de dependencias, que en un proyecto grande suele ser la parte más lenta del build.

## .dockerignore: lo que no debería entrar

Antes de ejecutar la primera instrucción, Docker arma el **contexto de construcción**: copia el contenido del directorio donde corrés `docker build`, incluidos los subdirectorios. Si ese directorio tiene un `node_modules` de cientos de megabytes, un `.git` con todo el historial, o archivos con secretos, todo eso se envía igual, aunque el Dockerfile no lo use.

Un archivo `.dockerignore`, en la raíz del proyecto, evita ese envío. Usa el mismo formato de patrones que un `.gitignore`, sin necesidad de reorganizar el repositorio.

```text
node_modules
.git
.env
*.log
dist
Dockerfile
.dockerignore
```

El efecto es doble: el build es más rápido, porque hay menos para enviar, y baja el riesgo de copiar por accidente un archivo con credenciales dentro de una capa.

## Construcciones multietapa

Una **construcción multietapa** (*multi-stage build*) es un Dockerfile con más de una instrucción `FROM`. Cada `FROM` abre una etapa nueva, y se le puede poner nombre con `AS <nombre>`. Es un solo archivo, no varios Dockerfiles: creer que hacen falta archivos separados por etapa es un error común al empezar.

Una etapa puede usarse solo para producir algo —compilar código, generar artefactos— y después descartarse por completo. Lo único que pasa a la siguiente es lo que se copia explícitamente con `COPY --from=<etapa>`. Retomando el ejemplo de Node, con una imagen [distroless](https://github.com/GoogleContainerTools/distroless) como base final:

```dockerfile
# Etapa build: compila todo
FROM node:20 AS build
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build

# Etapa final: solo runtime
FROM gcr.io/distroless/nodejs22-debian13
WORKDIR /app

COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules

CMD ["dist/server.js"]
```

La etapa `build`, con el compilador, las herramientas de npm y el código fuente completo, nunca llega a la imagen final: se usó, produjo `dist` y se descartó. Lo mismo aplica a lenguajes compilados: en Go o Rust se compila en una etapa con el toolchain completo y se copia solo el binario a una imagen final que puede no tener ni shell, incluida la opción extrema de partir de `FROM scratch`, una imagen vacía.

## Elegir la imagen base

La imagen base con la que arranca la etapa final determina buena parte del tamaño y de la superficie de ataque que quedan expuestos. Las opciones más habituales son, de más pesada a más liviana, la imagen completa de Debian o Ubuntu, sus variantes `slim`, las basadas en Alpine y las imágenes **distroless**.

| Imagen base | Qué incluye | Cuándo conviene |
|---|---|---|
| Debian/Ubuntu completa | Sistema completo: paquetes, shell, utilidades | Desarrollo y depuración; no para producción |
| Variante `slim` | Debian recortada: conserva `apt` y shell | Punto medio, si hace falta instalar algo o depurar |
| Alpine | Minimalista, basada en *musl*, con `apk` y shell | Liviana, si la app no depende de *glibc* |
| Distroless | Solo la app y sus dependencias de ejecución; sin paquetes ni shell | Producción, superficie de ataque mínima |

Las cifras concretas ilustran la diferencia. Según el propio proyecto [distroless](https://github.com/GoogleContainerTools/distroless) de Google, consultado en julio de 2026, su variante más chica (`gcr.io/distroless/static-debian13`) pesa alrededor de **2 MiB**, cerca de la mitad que Alpine (~5 MiB) y menos del **2 %** de una Debian completa (~124 MB). Son cifras de una variante puntual que varían según la etiqueta, pero el orden de magnitud se mantiene.

## Combinar apt-get update e install

Cuando la imagen base es Debian y hace falta instalar paquetes con `apt-get`, aparece un problema de caché distinto. La [documentación oficial de Docker](https://docs.docker.com/build/building/best-practices/) recomienda ejecutar `apt-get update` e `install` en la **misma** instrucción `RUN`: si `update` queda en una capa separada y se cachea, un build posterior puede reutilizarla sin actualizar el índice, e instalar una versión desactualizada —o rota— del paquete pedido. Docker llama a esto *cache busting*.

```dockerfile
# Evitar: update cacheado
RUN apt-get update
RUN apt-get install -y curl

# Preferido: una sola instrucción
RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*
```

La bandera `--no-install-recommends` no es de Docker: es una opción de `apt`, documentada en el [manual de `apt-get` de Debian](https://manpages.debian.org/bookworm/apt/apt-get.8.en.html), y evita instalar paquetes que el pedido solo «recomienda» sin necesitarlos. El `rm -rf /var/lib/apt/lists/*` al final borra el índice descargado por `update`, que ya no hace falta y de otro modo ocupa espacio en esa capa.

## Cómo medir el tamaño real

Todo lo anterior se puede verificar con el propio Docker, sin herramientas adicionales:

1. **Construir la versión original con un tag identificable.** `docker build -t mi-app:antes .`
2. **Anotar su tamaño.** `docker images mi-app` muestra el tamaño en la columna `SIZE`.
3. **Aplicar las técnicas.** Reordenar instrucciones, sumar `.dockerignore`, pasar a multietapa y cambiar la imagen base.
4. **Reconstruir con otro tag.** `docker build -t mi-app:despues .`
5. **Comparar ambos tamaños.** `docker images mi-app` lista las dos versiones, una junto a la otra.
6. **Inspeccionar qué pesa cada capa.** `docker history mi-app:despues` desglosa el tamaño por instrucción.

Medir antes y después de cada cambio ayuda a entender cuánto aportó cada técnica por separado.

## Errores frecuentes

- **Poner `COPY . .` al principio.** Invalida en cascada todas las capas siguientes, incluida la instalación de dependencias.
- **Tratar el tamaño como algo estético.** Una imagen grande no es solo más lenta de mover: también es más superficie de ataque.
- **Confundir multietapa con varios Dockerfiles.** Es un único archivo con varios `FROM`; lo que pasa de etapa en etapa es lo que se copia con `COPY --from`.
- **No usar `.dockerignore`.** El build copia `node_modules`, `.git` y archivos de entorno sin que el Dockerfile los use.
- **Separar `apt-get update` de `install` en capas distintas.** Puede quedar cacheada una actualización vieja del índice de paquetes.
- **Elegir distroless sin plan para depurar.** Sin shell ni gestor de paquetes, hace falta otra estrategia para investigar adentro.

## Preguntas frecuentes

**¿Qué es una construcción multietapa y cómo reduce el tamaño final?**
Una construcción multietapa es un Dockerfile con más de una instrucción FROM, cada una abriendo una etapa distinta dentro del mismo archivo, no varios archivos separados. Una etapa temprana, habitualmente nombrada con AS build, puede tener un compilador, un kit de desarrollo, dependencias que solo hacen falta para construir y el código fuente completo. Esa etapa produce un resultado y después se descarta por completo: no deja rastro en la imagen publicada. La imagen final arranca desde otro FROM, normalmente una base mucho más chica pensada solo para ejecutar, y a ella se copia explícitamente, con COPY --from=<etapa>, únicamente lo necesario para correr. El compilador, el código fuente y las herramientas de construcción nunca llegan a la imagen final.

**¿Por qué el orden de las instrucciones afecta la velocidad del build?**
Docker ejecuta las instrucciones del Dockerfile en orden y guarda el resultado de cada una como una capa cacheada. En cada reconstrucción revisa cada instrucción: si detecta un cambio, descarta la caché de esa capa y de todas las siguientes, sin importar si habrían dado el mismo resultado. Por eso conviene ubicar cerca del FROM lo que casi nunca cambia —copiar e instalar dependencias— y dejar el código fuente, que cambia en cada commit, para el final. Invertir ese orden hace que cualquier modificación mínima dispare de nuevo pasos costosos, como reinstalar todas las dependencias, aunque ninguna haya cambiado.

**¿Alpine o distroless?**
Depende de si necesitás entrar al contenedor a diagnosticar problemas. Alpine es minimalista, más chica que Debian o Ubuntu, pero conserva gestor de paquetes y shell, así que se puede usar docker exec para investigar; su costado a vigilar es que usa musl en lugar de glibc. Distroless va más allá: sin gestor de paquetes ni shell, solo la app y sus dependencias mínimas, más chica pero más difícil de inspeccionar por dentro. Para producción, donde depurar adentro debería ser la excepción, distroless suele ser la opción más defendible; si hace falta esa flexibilidad, Alpine es buen intermedio.

**¿Qué hace exactamente .dockerignore?**
Antes de ejecutar la primera instrucción, Docker arma el contexto de construcción: envía el contenido del directorio donde se corre docker build, incluidos los subdirectorios. Un archivo .dockerignore, en la raíz del proyecto, indica qué rutas excluir de ese envío, con el mismo formato de patrones que un .gitignore, sin reorganizar el repositorio. Sirve para dos cosas: acelerar el build, con menos datos para transferir, y evitar que se copien por accidente carpetas pesadas como node_modules o .git, o archivos con secretos.

**¿Por qué combinar apt-get update e install en una sola instrucción?**
Porque cada RUN genera su propia capa cacheada, y si apt-get update queda separado de install, una reconstrucción posterior puede reutilizar esa capa vieja del índice sin actualizarlo, aunque install sí se ejecute de nuevo. El resultado es instalar una versión desactualizada del paquete, y a veces una ya retirada del repositorio que falla al descargarse. Ejecutar ambos comandos en la misma instrucción RUN, unidos con &&, hace que se cacheen o se invaliden siempre juntos: si algo fuerza la reconstrucción de esa capa, se actualiza el índice antes de instalar. La documentación oficial de Docker llama a esto cache busting.

**¿Cómo mido el tamaño real de la imagen y comparo antes y después?**
No hace falta ninguna herramienta externa: alcanza con Docker. Construí la versión sin optimizar con un tag identificable (docker build -t mi-app:antes .) y anotá el tamaño en la columna SIZE de docker images mi-app. Aplicá las técnicas —reordenar instrucciones, sumar un .dockerignore, pasar a multietapa, cambiar la imagen base final— y reconstruí con otro tag, como mi-app:despues. docker images mi-app va a listar ambas versiones con su tamaño real. Para ver qué instrucción pesa más dentro de una imagen, docker history mi-app:despues desglosa el tamaño capa por capa.

## Fuentes

Documentación oficial y material de la comunidad consultados para esta guía. Fecha de consulta: 29 de julio de 2026.

### Aportes de la comunidad Underc0de

1. **Underc0de, foro.** [Curso de Docker (Básico - Medio - Avanzado)](https://underc0de.org/foro/qa-testing/curso-de-docker-basico-medio-avanzado/), por *DarkDante*, 12 de mayo de 2020, sección QA. No cubre multietapa ni tamaño.

### Documentación oficial

1. **Docker.** [Multi-stage builds](https://docs.docker.com/build/building/multi-stage/). Cómo declarar varias etapas y copiar artefactos entre ellas.
2. **Docker.** [Docker build cache](https://docs.docker.com/build/cache/). Cómo se cachea cada capa y cómo se invalida.
3. **Docker.** [Dockerfile best practices](https://docs.docker.com/build/building/best-practices/). Orden de instrucciones, .dockerignore y cache busting con apt-get.
4. **Docker.** [Base images](https://docs.docker.com/build/building/base-images/). Criterios para elegir la imagen base.
5. **Docker.** [Dockerfile reference](https://docs.docker.com/reference/dockerfile/). Sintaxis de FROM, COPY --from y RUN.
6. **Google Container Tools.** [distroless](https://github.com/GoogleContainerTools/distroless). Tamaño de las variantes y comparación con Alpine y Debian.
7. **Debian.** [apt-get(8) — manual de referencia](https://manpages.debian.org/bookworm/apt/apt-get.8.en.html). Definición de --no-install-recommends, propia de apt, no de Docker.

## Origen comunitario

En la sección QA del foro circula desde 2020 un curso de Docker de punta a punta. Esta guía no lo reproduce: toma el mismo interés de la comunidad y lo enfoca en un problema puntual —optimizar el Dockerfile y reducir el tamaño de la imagen— con documentación oficial como base.

## Guías relacionadas

- [Docker desde cero](../docker-desde-cero-imagenes-contenedores-y-volumenes/index.md)
- [Docker Compose para levantar aplicaciones completas](../docker-compose-para-levantar-aplicaciones-completas/index.md)
- [Seguridad de la cadena de suministro de software](../seguridad-de-la-cadena-de-suministro-de-software/index.md)
- [Docker vs. Kubernetes](../docker-vs-kubernetes-diferencias/index.md)

**Versión HTML (canónica):** https://underc0de.org/guias/devops-cloud/optimizar-un-dockerfile-y-reducir-imagenes/
