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 sudo y miembro del grupo docker.
  • 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ónFunción
# syntax=docker/dockerfile:1Usa la versión estable más reciente del intérprete de Dockerfile de BuildKit
FROMImagen base. La variante slim de Debian ocupa mucho menos que la completa
ENVVariables 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
WORKDIRDirectorio de trabajo para las instrucciones siguientes; se crea si no existe
COPYCopia archivos del contexto de build a la imagen
RUNEjecuta un comando durante el build y guarda el resultado como una capa
USERUsuario con el que se ejecutan las instrucciones siguientes y el contenedor
EXPOSEDocumenta el puerto en el que escucha la aplicación. No publica nada por sí mismo
CMDComando 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.

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 RUN los comandos que deben ir juntos, como apt-get update y apt-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.