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 ARG y los comentarios) debe ser FROM.
  • Las líneas que empiezan por # son comentarios.
  • Si una línea es demasiado larga, puedes continuarla en la siguiente usando \.
Las instrucciones de Dockerfile se dividen en las que se aplican durante la compilación y durante la ejecución, además de las formas de combinar ENTRYPOINT y CMD
Instrucciones que se aplican durante la compilación (docker build) y la ejecución (docker run), y la combinación de ENTRYPOINT + CMD

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
  • COPY copió files.tar.gz sin modificarlo.
  • ADD lo descomprimió automáticamente: dentro de /add están directamente files/a.txt y files/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

ARGENV
Cuándo se aplicaSolo durante la compilaciónDurante la compilación y la ejecución del contenedor
Cómo se establecedocker build --build-arg NAME=valorEn Dockerfile o con docker run -e
¿Es visible dentro del contenedor?NoSí

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.0 pasado durante la compilación se usó en RUN (se escribió en /build-info.txt), pero ya no está disponible durante la ejecución del contenedor (<not set>).
  • El valor APP_ENV establecido con ENV sí 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 ARG ni ENV para pasar contraseñas o claves de API. Si necesitas datos confidenciales durante la compilación, usa RUN --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 curl o wget para que puedas hacerlo así (alpine incluye wget).
  • Compose puede esperar a que un servicio esté healthy antes 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 ENTRYPOINT y CMD al mismo tiempo, lo que se ejecuta es ENTRYPOINT + CMD.
  • Lo que escribes después de docker run solo reemplaza CMD y pasa a ser un argumento de ENTRYPOINT.
  • Para reemplazar el propio ENTRYPOINT, usa --entrypoint.
CMDENTRYPOINT
FunciónComando o argumentos predeterminadosEjecutable principal del contenedor
docker run imagen xxxxxx reemplaza CMDxxx se añade después de ENTRYPOINT
Cómo reemplazarloEscribe 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 directamente docker run -it myapp sh.
  • Si usas el contenedor como «un comando» (por ejemplo, una herramienta CLI): usa ENTRYPOINT para especificar el programa y CMD para 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 CMDPID 1 dentro del contenedordocker stop
CMD npm startnpm0.66 s
CMD ["npm", "start"]npm0.67 s
CMD node server.jsnode10.14 s
CMD ["node", "server.js"]node10.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 stop solo 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 sh porque sh de Alpine, cuando solo hay un comando, se reemplaza directamente por npm.

Soluciones (elige una):

  1. Gestiona SIGTERM en el programa para cerrar el servidor de forma ordenada (es la mejor opción; consulta Aplicaciones de Node.js).
  2. Añade --init al ejecutar el contenedor (en Compose, escribe init: true) para que un pequeño proceso init sea el PID 1 y reenvíe las señales.

Otros comandos

ComandoUso
LABELAñade metadatos, por ejemplo, LABEL org.opencontainers.image.source="https://github.com/..."
SHELLCambia el shell que se usa con el formato shell, por ejemplo, PowerShell en contenedores Windows
STOPSIGNALCambia la señal que envía docker stop (la imagen de nginx está configurada con SIGQUIT; consulta docker history en Imágenes)
ONBUILDInstrucció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, COPY y ARG se aplican durante la compilación; CMD, ENTRYPOINT, USER y ENV afectan la ejecución.
  • En docker run imagen xxx, xxx sustituye a CMD o se añade después de ENTRYPOINT.
  • Usa el formato exec (un arreglo JSON) con CMD y ENTRYPOINT.
  • No pases contraseñas mediante ARG ni ENV.

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