Un Dockerfile es un archivo de texto con las instrucciones que Docker sigue para construir una imagen: la imagen base, los archivos que se copian, los paquetes que se instalan y el comando que arranca la aplicación. En este tutorial escribirás paso a paso el Dockerfile de una pequeña aplicación web en Python, optimizado para que se construya rápido, no se ejecute como root e informe de su estado. Al final verás cómo un build multietapa reduce una imagen compilada a unos pocos megabytes.
Requisitos previos
- Un servidor con Ubuntu 24.04 LTS y Docker Engine con el plugin Buildx instalados desde el repositorio oficial, por ejemplo un VPS de CubePath.
- Un usuario no root con privilegios
sudoy miembro del grupodocker. - Conocer los comandos básicos de Docker (
docker run,docker ps,docker logs).
Paso 1: Crear la aplicación de ejemplo
Crea un directorio para el proyecto:
mkdir -p ~/hola-docker && cd ~/hola-docker
Crea la aplicación, una API mínima con Flask:
nano app.py
import os
import socket
from flask import Flask, jsonify
app = Flask(__name__)
@app.get("/")
def index():
return jsonify(message="Hola desde Docker", host=socket.gethostname(),
env=os.environ.get("APP_ENV", "development"))
@app.get("/health")
def health():
return jsonify(status="ok")
Declara las dependencias con versiones fijas para que cada build instale exactamente lo mismo:
nano requirements.txt
flask==3.1.1
gunicorn==23.0.0
En producción la aplicación se servirá con Gunicorn, no con el servidor de desarrollo de Flask.
Paso 2: Escribir el Dockerfile
Crea el archivo Dockerfile (sin extensión) en la raíz del proyecto:
nano Dockerfile
# syntax=docker/dockerfile:1
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# 1. Dependencias primero, para aprovechar la caché
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 2. Código de la aplicación
COPY app.py .
# 3. Usuario sin privilegios
RUN groupadd --system app && useradd --system --gid app --no-create-home app
USER app
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"]
Qué hace cada instrucción:
| Instrucción | Función |
|---|---|
# syntax=docker/dockerfile:1 | Usa la versión estable más reciente del intérprete de Dockerfile de BuildKit |
FROM | Imagen base. La variante slim de Debian ocupa mucho menos que la completa |
ENV | Variables de entorno disponibles en el build y en el contenedor. Aquí evitan archivos .pyc y el buffering de la salida, para que los logs aparezcan al instante |
WORKDIR | Directorio de trabajo para las instrucciones siguientes; se crea si no existe |
COPY | Copia archivos del contexto de build a la imagen |
RUN | Ejecuta un comando durante el build y guarda el resultado como una capa |
USER | Usuario con el que se ejecutan las instrucciones siguientes y el contenedor |
EXPOSE | Documenta el puerto en el que escucha la aplicación. No publica nada por sí mismo |
CMD | Comando por defecto al arrancar el contenedor |
Usa siempre la forma JSON (exec) en CMD y ENTRYPOINT, como en el ejemplo. Así el proceso de la aplicación es el PID 1 y recibe la señal SIGTERM de docker stop, en lugar de quedar debajo de un /bin/sh -c que no la reenvía.
NotaUsa
COPYpara copiar archivos locales.ADDtambién descomprime archivos tar y descarga URLs, un comportamiento implícito que rara vez necesitas.
Paso 3: Excluir archivos con .dockerignore
Al construir, Docker envía todo el directorio (el contexto) al motor de build. Un archivo .dockerignore evita que se copien a la imagen archivos innecesarios o secretos:
nano .dockerignore
.git
.venv
__pycache__/
*.pyc
.env
Dockerfile
.dockerignore
Excluir .env es especialmente importante: sin esta línea, un COPY . . metería tus credenciales dentro de la imagen.
Paso 4: Construir y ejecutar la imagen
Construye la imagen y etiquétala con un nombre y una versión (-t). El punto final indica que el contexto es el directorio actual:
docker build -t hola-docker:1.0 .
[+] Building 18.4s (12/12) FINISHED
=> [internal] load build definition from Dockerfile
=> [1/6] FROM docker.io/library/python:3.12-slim
=> [2/6] WORKDIR /app
=> [3/6] COPY requirements.txt .
=> [4/6] RUN pip install --no-cache-dir -r requirements.txt
=> [5/6] COPY app.py .
=> [6/6] RUN groupadd --system app && useradd --system --gid app --no-create-home app
=> exporting to image
=> => naming to docker.io/library/hola-docker:1.0
Ejecuta un contenedor publicando el puerto 8000 solo en localhost y pasando una variable de entorno:
docker run -d --name hola -p 127.0.0.1:8000:8000 -e APP_ENV=production hola-docker:1.0
Comprueba que responde:
curl http://127.0.0.1:8000/
{"env":"production","host":"8d2c1f0e7a4b","message":"Hola desde Docker"}
Verifica también que el proceso no se ejecuta como root:
docker exec hola id
uid=999(app) gid=999(app) groups=999(app)
Paso 5: Aprovechar la caché de capas
Cada instrucción genera una capa, y Docker reutiliza las capas cuyas entradas no han cambiado. Por eso el Dockerfile copia requirements.txt e instala las dependencias antes de copiar el código: al cambiar app.py, las dependencias no se vuelven a instalar.
Compruébalo. Modifica el mensaje de app.py y vuelve a construir:
sed -i 's/Hola desde Docker/Hola de nuevo/' app.py
docker build -t hola-docker:1.1 .
=> CACHED [2/6] WORKDIR /app
=> CACHED [3/6] COPY requirements.txt .
=> CACHED [4/6] RUN pip install --no-cache-dir -r requirements.txt
=> [5/6] COPY app.py .
=> [6/6] RUN groupadd --system app && useradd --system --gid app --no-create-home app
El build tarda un par de segundos porque solo se rehacen las capas posteriores al cambio. Si hubieras escrito COPY . . antes de pip install, cualquier cambio en el código invalidaría la caché y reinstalaría todo.
Sigue también estas pautas al escribir instrucciones RUN:
- Encadena en un solo
RUNlos comandos que deben ir juntos, comoapt-get updateyapt-get install, para que no quede en caché un índice de paquetes antiguo. - Limpia en la misma instrucción lo que no necesitas. Borrarlo en una capa posterior no reduce el tamaño de la imagen.
Por ejemplo, si tu aplicación necesitara curl dentro de la imagen:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Paso 6: Añadir un healthcheck
HEALTHCHECK indica a Docker cómo comprobar que la aplicación funciona, no solo que el proceso existe. La imagen slim no incluye curl, así que la comprobación usa el propio Python. Añade estas líneas al Dockerfile, justo antes de CMD:
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=2)"]
Si la petición falla, Python termina con código distinto de cero y Docker cuenta un fallo. Tras tres fallos seguidos, el contenedor pasa a unhealthy. Reconstruye y sustituye el contenedor:
docker build -t hola-docker:1.2 .
docker rm -f hola
docker run -d --name hola -p 127.0.0.1:8000:8000 hola-docker:1.2
Espera unos segundos y consulta el estado:
docker inspect --format '{{.State.Health.Status}}' hola
healthy
La columna STATUS de docker ps también muestra (healthy). Docker Compose puede usar este estado para arrancar un servicio solo cuando su dependencia está sana.
Paso 7: Reducir el tamaño con un build multietapa
En lenguajes compilados, la imagen final no necesita el compilador. Un build multietapa usa una imagen grande para compilar y copia solo el binario a una imagen mínima. Crea un proyecto aparte en Go:
mkdir -p ~/hola-go && cd ~/hola-go
nano main.go
package main
import (
"fmt"
"log"
"net/http"
)
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hola desde Go")
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
Crea el archivo de módulo:
nano go.mod
module example.com/hola
go 1.24
Y el Dockerfile con dos etapas:
nano Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.24 AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/hola .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/hola /hola
EXPOSE 8080
ENTRYPOINT ["/hola"]
La primera etapa (build) tiene todo el toolchain de Go. La segunda parte de una imagen distroless, sin shell ni gestor de paquetes, que ya se ejecuta con un usuario no root. COPY --from=build trae únicamente el binario compilado estáticamente.
Construye y compara tamaños:
docker build -t hola-go:1.0 .
docker image ls --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E 'hola|golang'
hola-go:1.0 9.6MB
hola-docker:1.2 161MB
golang:1.24 853MB
Prueba la imagen:
docker run --rm -d --name hola-go -p 127.0.0.1:8080:8080 hola-go:1.0
curl http://127.0.0.1:8080/
docker stop hola-go
Hola desde Go
Solución de problemas
COPY failed: file not found in build context. El archivo no está en el directorio del contexto o lo excluye .dockerignore. Las rutas de COPY son siempre relativas al contexto, nunca pueden salir de él con ../.
El contenedor tarda 10 segundos en detenerse. El proceso no recibe SIGTERM, normalmente porque CMD usa la forma shell (CMD gunicorn ...). Cámbiala a la forma JSON.
Permission denied al escribir archivos con USER app. El usuario sin privilegios no puede escribir en directorios de root. Monta un volumen para los datos o asigna el directorio con COPY --chown=app:app o RUN chown antes de la instrucción USER.
Conclusión
Has escrito un Dockerfile que aprovecha la caché de capas, no incluye secretos gracias a .dockerignore, ejecuta la aplicación sin root, informa de su estado con HEALTHCHECK, y has visto cómo un build multietapa reduce una imagen a menos de 10 MB. Como siguientes pasos, define la aplicación junto a su base de datos con Docker Compose, guarda sus datos en volúmenes y sube la imagen a un registro privado para desplegarla en otros servidores.
