Contenido
Tutorial de Dockerfile: instrucciones FROM, RUN, COPY, CMD y ENTRYPOINT explicadas
En la sección anterior escribiste tu primer Dockerfile con un ejemplo pequeño. Aquí explicaremos cada instrucción y compararemos con experimentos prácticos algunos pares que se suelen confundir.
Reglas básicas
- Las instrucciones no distinguen entre mayúsculas y minúsculas, pero por convención se escriben en mayúsculas para diferenciarlas de los argumentos.
- Se ejecutan de arriba abajo. La primera instrucción (excepto
ARGy los comentarios) debe serFROM. - Las líneas que empiezan por
#son comentarios. - Si una línea es demasiado larga, puedes continuarla en la siguiente usando
\.
FROM: imagen base
FROM python:3.14-slim
FROM --platform=linux/amd64 node:24-alpine
FROM scratch # Imagen completamente vacía para colocar programas compilados estáticamente
FROM golang:1.27 AS builder # Asigna un nombre a la etapa en compilaciones multietapa
RUN: ejecutar comandos durante la compilación
# Formato shell: se ejecuta mediante /bin/sh -c; puedes usar && y tuberías
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
# Formato exec: no pasa por el shell
RUN ["pip", "install", "--no-cache-dir", "flask"]
Cada RUN genera una capa. Escribe apt-get update y apt-get install en el mismo RUN; de lo contrario, la capa de update almacenada en caché podría quedar obsoleta y podrías instalar una versión antigua del paquete. Si al final borras la lista de paquetes (rm -rf /var/lib/apt/lists/*), la imagen será más pequeña. Consulta Caché de compilación y optimización para ver cuánto puede reducirse.
COPY y ADD: copiar archivos
COPY package.json package-lock.json ./
COPY src/ ./src/
COPY --chown=app:app . .
COPY --from=builder /app/dist ./dist # Copia desde otra etapa de compilación
ADD es parecido a COPY, pero se comporta de otra manera con los archivos tar comprimidos. Experimento: prepara un files.tar.gz (que contiene files/a.txt y files/b.txt) y copia el archivo con cada instrucción:
FROM alpine:3.23
COPY files.tar.gz /copy/
ADD files.tar.gz /add/
CMD ["sh", "-c", "echo '--- COPY:'; ls -R /copy; echo '--- ADD:'; ls -R /add"]
$ docker build -q -t add-demo .
sha256:2466246e51b76cd547fa913fce223fccc227ae19b9e53006b9ce203e1afc8ec4
$ docker run --rm add-demo
--- COPY:
/copy:
files.tar.gz
--- ADD:
/add:
files
/add/files:
a.txt
b.txt
COPYcopiófiles.tar.gzsin modificarlo.ADDlo descomprimió automáticamente: dentro de/addestán directamentefiles/a.txtyfiles/b.txt.
También puedes usar una URL como origen de ADD (se descarga automáticamente). Estos «comportamientos automáticos» pueden causar sorpresas. La recomendación oficial es usar COPY en general y ADD solo cuando necesites descomprimir archivos.
WORKDIR: directorio de trabajo
WORKDIR /app
Las instrucciones posteriores, como RUN, COPY y CMD, se ejecutan en este directorio; si no existe, se crea automáticamente. Usa WORKDIR en lugar de escribir RUN cd /app: cada RUN se ejecuta en un shell independiente y el cd no se conserva en la siguiente línea.
ENV y ARG: variables
| ARG | ENV | |
|---|---|---|
| Cuándo se aplica | Solo durante la compilación | Durante la compilación y la ejecución del contenedor |
| Cómo se establece | docker build --build-arg NAME=valor | En Dockerfile o con docker run -e |
| ¿Es visible dentro del contenedor? | No | Sí |
Experimento:
FROM alpine:3.23
ARG APP_VERSION=dev
ENV APP_ENV=production
RUN echo "building version $APP_VERSION" > /build-info.txt
CMD ["sh", "-c", "cat /build-info.txt; echo APP_ENV=$APP_ENV; echo APP_VERSION=${APP_VERSION:-<not set>}"]
$ docker build -q --build-arg APP_VERSION=1.2.0 -t args-demo .
sha256:3537c3e70baef148c3481f97bf325963b4809a15b806a2e38fed7e8eb5f50619
$ docker run --rm args-demo
building version 1.2.0
APP_ENV=production
APP_VERSION=<not set>
$ docker history --no-trunc --format "{{.CreatedBy}}" args-demo | head -n 3
CMD ["sh" "-c" "cat /build-info.txt; echo APP_ENV=$APP_ENV; echo APP_VERSION=${APP_VERSION:-<not set>}"]
RUN |1 APP_VERSION=1.2.0 /bin/sh -c echo "building version $APP_VERSION" > /build-info.txt # buildkit
ENV APP_ENV=production
- El valor
APP_VERSION=1.2.0pasado durante la compilación se usó enRUN(se escribió en/build-info.txt), pero ya no está disponible durante la ejecución del contenedor (<not set>). - El valor
APP_ENVestablecido conENVsí está disponible dentro del contenedor. - Fíjate en la segunda línea de
docker history:RUN |1 APP_VERSION=1.2.0 ...; el valor de ARG queda registrado en el historial de la imagen, así que cualquiera que tenga la imagen puede verlo.
Por eso, nunca uses
ARGniENVpara pasar contraseñas o claves de API. Si necesitas datos confidenciales durante la compilación, usaRUN --mount=type=secret. Consulta Variables de entorno y archivos .env.
Los ARG declarados antes de FROM solo se pueden usar dentro de FROM. Para usarlos después de FROM, debes volver a escribir ARG nombre.
EXPOSE: documentar puertos
EXPOSE 3000
EXPOSE 53/udp
EXPOSE solo sirve como documentación: indica qué puerto usará el contenedor, pero no lo abre realmente. Para publicar un puerto, debes usar docker run -p. Su único efecto práctico es combinarlo con -P, que publica todos los puertos de EXPOSE en puertos aleatorios del host (la imagen nginx tiene EXPOSE 80):
$ docker run --rm -d --name exp -P nginx:1.30
857560427b1218d038314394f03cc0eb8edf950062304efaf2f5f3dc995f6ef6
$ docker port exp
80/tcp -> 0.0.0.0:32768
80/tcp -> [::]:32768
$ docker rm -f exp
exp
USER: ejecutar como usuario no root
De forma predeterminada, el usuario dentro del contenedor es root. Si ejecutas el programa como un usuario normal, aunque lo vulneren, el alcance del impacto será menor:
FROM alpine:3.23
RUN addgroup -S app && adduser -S -G app app
USER app
CMD ["sh", "-c", "whoami; id"]
$ docker build -q -t user-demo .
sha256:bdcaab4c6133a5446cdaaa1ca3353e01a690dc9fd0647458543abcec0d6041f1
$ docker run --rm user-demo
app
uid=100(app) gid=101(app) groups=101(app),101(app)
$ docker run --rm alpine:3.23 whoami
root
Muchas imágenes oficiales ya incluyen un usuario normal. Por ejemplo, la imagen node tiene un usuario node, así que puedes usar directamente USER node.
VOLUME: declarar un directorio de datos
VOLUME /var/lib/data
Declara que este directorio debe almacenar datos persistentes. Si no montas nada al ejecutar el contenedor, Docker crea automáticamente un Volume anónimo (en la sección Volume encontrarás un ejemplo práctico). En la práctica, normalmente se especifica directamente con docker run -v o en Compose, por lo que no siempre necesitas escribir VOLUME en tu propio Dockerfile.
HEALTHCHECK: comprobación de estado
Docker ejecuta periódicamente el comando de comprobación de estado. El código de salida 0 indica que está sano; cualquier valor distinto de 0 indica que no está sano:
FROM nginx:1.30-alpine
HEALTHCHECK --interval=5s --timeout=3s --retries=3 \
CMD wget -q --spider http://localhost/ || exit 1
Prueba: espera unos segundos después de iniciarlo y luego elimina a propósito la página de inicio para que nginx devuelva un error:
$ docker run -d --name health health-demo
52bfa4cce8024630797ba6f33ba99dea44f80399fbadd617b2d04a8ac7f8e3b1
$ docker ps --filter name=health
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
52bfa4cce802 health-demo "/docker-entrypoint.…" 1 second ago Up Less than a second (health: starting) 80/tcp health
$ docker ps --filter name=health
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
52bfa4cce802 health-demo "/docker-entrypoint.…" 9 seconds ago Up 8 seconds (healthy) 80/tcp health
$ docker exec health rm /usr/share/nginx/html/index.html
$ docker ps --filter name=health
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
52bfa4cce802 health-demo "/docker-entrypoint.…" 29 seconds ago Up 28 seconds (unhealthy) 80/tcp health
$ docker inspect --format '{{range .State.Health.Log}}{{.ExitCode}} {{.Output}}{{println}}{{end}}' health
0
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden
1 wget: server returned error: HTTP/1.1 403 Forbidden
$ docker rm -f health
health
El estado cambia así: health: starting (recién iniciado) → healthy (comprobación superada) → tras eliminar la página de inicio, falla 3 veces seguidas (--retries=3) → unhealthy. .State.Health.Log registra el código de salida y la salida de cada comprobación.
- La imagen debe incluir herramientas como
curlowgetpara que puedas hacerlo así (alpine incluyewget). - Compose puede esperar a que un servicio esté
healthyantes de iniciar los servicios que dependen de él. Consulta la sintaxis de compose.yaml.
CMD y ENTRYPOINT: comando de inicio
Ambos determinan qué se ejecuta al iniciar el contenedor. La diferencia está en si se pueden reemplazar y cómo se combinan.
CMD: se puede reemplazar por completo
FROM alpine:3.23
CMD ["echo", "hello from CMD"]
$ docker build -q -t cmd-demo .
sha256:45b2af259ce828e227961bcc620b973758a1f757d948a7e23e056781afba324d
$ docker run --rm cmd-demo
hello from CMD
$ docker run --rm cmd-demo echo overridden
overridden
El comando que escribes después del nombre de la imagen en docker run reemplaza por completo CMD.
ENTRYPOINT: programa principal fijo
FROM alpine:3.23
ENTRYPOINT ["echo", "ENTRYPOINT says:"]
CMD ["default argument"]
$ docker build -q -t ep-demo .
sha256:32414489000b71c255d808cfd4cda73764b526525229dece3a8f1fd5487fc6d1
$ docker run --rm ep-demo
ENTRYPOINT says: default argument
$ docker run --rm ep-demo my argument
ENTRYPOINT says: my argument
$ docker run --rm --entrypoint date ep-demo
Mon Oct 12 23:59:17 UTC 2026
- Si existen
ENTRYPOINTyCMDal mismo tiempo, lo que se ejecuta esENTRYPOINT + CMD. - Lo que escribes después de
docker runsolo reemplazaCMDy pasa a ser un argumento deENTRYPOINT. - Para reemplazar el propio
ENTRYPOINT, usa--entrypoint.
| CMD | ENTRYPOINT | |
|---|---|---|
| Función | Comando o argumentos predeterminados | Ejecutable principal del contenedor |
docker run imagen xxx | xxx reemplaza CMD | xxx se añade después de ENTRYPOINT |
| Cómo reemplazarlo | Escribe el comando directamente después del nombre de la imagen | --entrypoint |
Criterio de elección:
- Aplicación general: escribe solo
CMD; si el usuario quiere abrir un shell, puede ejecutar directamentedocker run -it myapp sh. - Si usas el contenedor como «un comando» (por ejemplo, una herramienta CLI): usa
ENTRYPOINTpara especificar el programa yCMDpara los argumentos predeterminados.
Formato exec y formato shell
CMD ["node", "server.js"] # Formato exec (matriz JSON)
CMD node server.js # Formato shell
El formato shell ejecuta realmente /bin/sh -c "node server.js", por lo que puedes usar la expansión de variables de entorno y sintaxis de shell como &&. El formato exec ejecuta el programa directamente, sin pasar por un shell. En general, se recomienda usar el formato exec porque su comportamiento es más explícito.
El problema del PID 1 y los 10 segundos de espera de docker stop
Ciclo de vida de los contenedores menciona que, a veces, docker stop tarda los 10 segundos completos. A continuación, con el mismo programa de Node.js (server.js de la sección anterior, que no gestiona SIGTERM), se comparan cuatro formas de escribir CMD. Usa docker top para ver los procesos del contenedor y mide el tiempo que tarda docker stop:
$ docker top shell-npm
PID USER TIME COMMAND
4155 root 0:00 npm start
4182 root 0:00 {MainThread} node server.js
$ time docker stop shell-npm
shell-npm
real 0m 0.66s
user 0m 0.00s
sys 0m 0.00s
$ docker top exec-npm
PID USER TIME COMMAND
4318 root 0:00 npm start
4347 root 0:00 {MainThread} node server.js
$ time docker stop exec-npm
exec-npm
real 0m 0.67s
user 0m 0.00s
sys 0m 0.00s
$ docker top shell-node
PID USER TIME COMMAND
4488 root 0:00 {MainThread} node server.js
$ time docker stop shell-node
shell-node
real 0m 10.14s
user 0m 0.01s
sys 0m 0.01s
$ docker top exec-node
PID USER TIME COMMAND
4660 root 0:00 {MainThread} node server.js
$ time docker stop exec-node
exec-node
real 0m 10.14s
user 0m 0.01s
sys 0m 0.00s
| Forma de escribir CMD | PID 1 dentro del contenedor | docker stop |
|---|---|---|
CMD npm start | npm | 0.66 s |
CMD ["npm", "start"] | npm | 0.67 s |
CMD node server.js | node | 10.14 s |
CMD ["node", "server.js"] | node | 10.14 s |
La clave no está en el formato shell o exec, sino en quién es el PID 1:
- Linux aplica reglas especiales al PID 1: si el PID 1 no gestiona SIGTERM por su cuenta, la señal se ignora. Si node es directamente el PID 1 y el programa no gestiona SIGTERM,
docker stopsolo puede esperar 10 segundos y luego terminarlo a la fuerza con SIGKILL. - Cuando npm inicia el proceso, npm es el PID 1 y reenvía SIGTERM a node. Como node no es el PID 1, al recibir SIGTERM termina con el comportamiento predeterminado.
- En la primera fila, el formato shell no añade otra capa de
shporqueshde Alpine, cuando solo hay un comando, se reemplaza directamente pornpm.
Soluciones (elige una):
- Gestiona SIGTERM en el programa para cerrar el servidor de forma ordenada (es la mejor opción; consulta Aplicaciones de Node.js).
- Añade
--inital ejecutar el contenedor (en Compose, escribeinit: true) para que un pequeño proceso init sea el PID 1 y reenvíe las señales.
Otros comandos
| Comando | Uso |
|---|---|
LABEL | Añade metadatos, por ejemplo, LABEL org.opencontainers.image.source="https://github.com/..." |
SHELL | Cambia el shell que se usa con el formato shell, por ejemplo, PowerShell en contenedores Windows |
STOPSIGNAL | Cambia la señal que envía docker stop (la imagen de nginx está configurada con SIGQUIT; consulta docker history en Imágenes) |
ONBUILD | Instrucción que solo se ejecuta cuando otra imagen se basa en esta (se usa poco) |
Ejemplo completo: sitio web con Python
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN useradd --system app
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "app:app"]
PYTHONUNBUFFERED=1 hace que Python escriba la salida de inmediato, para que puedas verla al instante con docker logs. Consulta el proceso real de compilación y ejecución de este Dockerfile en Implementar una aplicación de Python con Docker.
Resumen
RUN,COPYyARGse aplican durante la compilación;CMD,ENTRYPOINT,USERyENVafectan la ejecución.- En
docker run imagen xxx,xxxsustituye a CMD o se añade después de ENTRYPOINT. - Usa el formato exec (un arreglo JSON) con
CMDyENTRYPOINT. - No pases contraseñas mediante
ARGniENV.
Ejercicios
Ejercicio 1: La imagen define ENTRYPOINT ["ping"] y CMD ["-c", "3", "localhost"]. ¿Qué se ejecuta realmente al ejecutar docker run imagen -c 1 example.com?
Respuesta
ping -c 1 example.com. Los argumentos de ejecución sustituyen a CMD y se añaden después de ENTRYPOINT.
Ejercicio 2: ¿Por qué ENV NODE_ENV production genera una advertencia con docker build --check?
Respuesta
Es la sintaxis antigua. Debe escribirse como ENV NODE_ENV=production; el nombre de la advertencia es LegacyKeyValueFormat.
Preguntas frecuentes
¿Cuál conviene usar, COPY o ADD?
Usa COPY por defecto. ADD descomprime archivos tar automáticamente y puede descargar URL; su comportamiento es menos intuitivo, así que úsalo solo cuando necesites esas funciones.
¿Cuál conviene usar, CMD o ENTRYPOINT?
La mayoría de las aplicaciones solo necesitan CMD, que es fácil de reemplazar. Usa ENTRYPOINT cuando la imagen funcione como una herramienta de comandos (siempre ejecuta el mismo programa y solo cambian los argumentos).
¿Cómo puedo comprobar si la sintaxis de Dockerfile tiene problemas?
Ejecuta docker build --check .; mostrará advertencias como JSONArgsRecommended y LegacyKeyValueFormat. Consulta [docker init](/es/docker/docker-init/).
Actualizado 14 de octubre de 2026