Guía para Desarrolladores Principiantes en Docker

Índice de contenido

Esta es una formación básica para aprender a usar Docker, por lo tanto, SOLAMENTE tratamos los elementos básicos y necesarios para que puedas empezar ha realizar administrar Docker y hacer el deploy de tus primeros proyectos; en esta página tienes la documentación completa:

https://docs.docker.com/get-started/

Conceptos Claves

Como en todo en la vida, primero debemos tener claros algunos conceptos antes de pasar a la práctica. En los frameworks usualmente hablamos del patrón MVC, MTV, entre otros.
Y aquí ocurre lo mismo: necesitamos entender cómo está formado este programa llamado Docker. ¿Qué demonios es esto? Porque créeme que cuando pasemos a la práctica, todo se entenderá mucho más fácilmente.

Similitudes con Python, los ambientes virtuales, Node y PHP

El uso de contenedores, imágenes y Docker es muy similar a los ambientes virtuales en Python.

  • Python usa Pipenv, Conda, o simplemente venv.
  • Laravel y Node instalan dependencias dentro del proyecto (vendor/, node_modules/).

Pero Python instala por defecto todo a nivel del sistema operativo, lo cual es un problema.

Ejemplo práctico:

Tienes un proyecto hecho en Django hace 3 años (versión Django 4), y hoy necesitas crear otro proyecto pero con Django 7.
Si instalaras todo directo en el sistema operativo, choque asegurado.

Y no solo pasa con Python:

  • No puedes tener 3 PHP diferentes activos en el mismo sistema.
  • Tampoco puedes mezclar dependencias de forma desordenada sin aislar cada proyecto.
  • Por eso, en Python creamos un ambiente virtual por proyecto, lo cual encapsula y aísla sus dependencias. Y es justo ahí donde aparece el paralelismo con Docker.

¿Dónde se guardan las dependencias?

En Laravel tenemos vendor/, en Node tenemos node_modules/.

En Python, el equivalente es una carpeta llamada usualmente .venv o .pvenv, donde se guardan solo las dependencias de ese proyecto.

Eso no ocurre automáticamente: tú debes instalar y activar el ambiente virtual, igual que con Docker, que también debes instalar y configurar manualmente.

Con esto, puedes entender para que podemos usar Docker, como un ambiente en donde podemos ejecutar de manera aislada nuestros proyectos y las similitudes con imágenes y contenedores, en las cuales, las imágenes son simplemente las dependencias, como ocurre con dependencias como Django o Laravel, que son simplemente archivos estáticos y contenedores, que es cuando ya se estan ejecutando o interpretan las imágenes.

Docker

Docker es una plataforma que permite “empaquetar” aplicaciones con todas sus dependencias (librerías, configuración, etc.) para que puedan ejecutarse de forma consistente en cualquier entorno (tu máquina, servidores, nube).

Puedes ver Docker como una especia de ambiente virtual, como los que tenemos en Python mediante los venv, en el cual, podemos instalar paquetes (imágenes sería el equivalente en Docker) que son las dependencias de nuestro proyecto; la ventaja de esto, es que, se ejecutan de manera aislada del sistema operativo; con esto, podemos tener múltiples dependencias con versiones distintas para proyectos distintos instalados en el mismo sistema operativo pero, virtualizado mediante los contenedores en Docker; por ejemplo, podemos tener múltiples versiones de Python instaladas para distintos proyectos.

Otro ejemplo, es como la carpetas vendor de PHP o de node_modules de Node, en la cual, tenemos las dependencias (imágenes sería el equivalente en Docker) para cada proyecto; no es exactamente esto, pero, con esta idea te será más fácil entender como funciona Docker

Docker no es solo una herramienta de desarrollo y se usa ampliamente en producción, y Railway es un ejemplo muy claro de ello.

Imagen (Image)

Una imagen de Docker es como una “plantilla” inmutable, es decir, de solo lectura que contiene todo lo necesario para ejecutar una aplicación: sistema de archivos, dependencias, código, variables de entorno, etc; con esto, se crean los contenedores en Docker que es el siguiente punto que vamos a tratar.

  • Las imágenes están formadas por capas (layers) que representan cambios incrementalmente aplicados unas sobre otras.
  • Una vez creada, no se modifica: si quieres cambiar algo, creas una nueva imagen o haces “build” sobre una base existente. 

Imagina que una imagen de Docker es como un archivo .exe en tu computadora.
El .exe contiene todo el código y los recursos necesarios para ejecutar un programa (bibliotecas, dependencias, configuraciones...), pero mientras no lo ejecutes, el programa no está corriendo ni ocupa recursos.

Del mismo modo, una imagen de Docker es un paquete estático que incluye el sistema base, el entorno, las dependencias y la aplicación lista para funcionar. Pero no hace nada por sí sola hasta que creas un contenedor a partir de ella —que sería el equivalente a ejecutar el .exe.

Otra analogía es con las clases, una clase definida:

class Category(models.Model):
   title = models.CharField(max_length=500)
   slug = models.SlugField(max_length=500)

Es como una imagen, por si, no hacemos nada con ella, pero es cuando la ejecutamos (un contenedor) es decir, creamos una instancia, es que se emplea dicha clase/imagen.
 

Una imagen es como una plantilla inmutable, de solo lectura.
Es una referencia, un entorno con su sistema base y dependencias incluidas.

Ejemplos reales de imágenes disponibles:

  • python
  • ubuntu
  • nginx
  • mysql
  • postgres

Contenedor (Container)

Un contenedor es la instancia en ejecución de una imagen. Piensa en la imagen como el plano o molde, y el contenedor como la casa construida a partir de ese plano.
Una vez que lanzas un contenedor, este funciona como un proceso aislado, con su propio sistema de archivos (basado en la imagen), entorno, red, etc. 
Los contenedores comparten el kernel del sistema operativo host, lo que los hace más ligeros que máquinas virtuales completas.

Al ser un proceso, puedes ejecutarlo, crearlo, detenerlo, moverlo o eliminar el proceso; para eso, se emplea comandos como docker create container, pull, cet:

 $ docker run -i -t ubuntu /bin/bash

Imagina que un contenedor de Docker es como ejecutar un programa .exe en tu computadora.

Mientras la imagen de Docker es el archivo guardado en tu disco (listo pero inactivo), el contenedor es ese mismo programa ya en ejecución: con su ventana abierta, sus procesos corriendo y su propio entorno funcionando de forma independiente.

Cuando cierras el programa, el proceso termina, pero el .exe sigue allí intacto.
De igual forma, cuando detienes o eliminas un contenedor, la imagen original sigue disponible para crear otro contenedor nuevo en cualquier momento.

Los contenedores en Docker:

  • Autocontenidos: cada contenedor incluye todo lo que necesita para funcionar, sin depender de programas o librerías instaladas en la máquina donde se ejecuta.
  • Aislados: los contenedores se ejecutan de forma independiente del sistema y de otros contenedores, lo que mejora la seguridad y evita conflictos.
  • Independientes: puedes crear, detener o eliminar un contenedor sin afectar a los demás. Cada uno se gestiona por separado.
  • Portátiles: un contenedor puede ejecutarse en cualquier lugar. El mismo que usas en tu computadora funcionará igual en un servidor o en la nube.

Otros conceptos funcionales:

Docker Daemon (dockerd)

El Docker Daemon es el proceso que realmente ejecuta y administra todo en Docker.
Se encarga de:

  • Crear y ejecutar contenedores.
  • Descargar o construir imágenes.
  • Manejar redes, volúmenes y otros recursos de Docker.
  • Comunicarse con otros daemons (por ejemplo, en entornos distribuidos o en clústeres).

En resumen:

Es el “motor” de Docker, el que hace el trabajo pesado.

Normalmente corre en segundo plano como un servicio del sistema y es el que recibe los comandos recibidos por el cliente que es el siguiente apartado que vamos a comentar.

Docker Client (docker)

El cliente de Docker es la herramienta con la que tú interactúas — por ejemplo, cuando escribes comandos como:

docker run -d -p 8080:80 nginx

Este comando no ejecuta el contenedor directamente.

El cliente simplemente envía una solicitud al daemon (usando la Docker API) diciéndole qué hacer, y el daemon es quien lo ejecuta realmente.

Además, un mismo cliente puede comunicarse con múltiples daemons (por ejemplo, un Docker local y otro remoto en la nube).

 

| Componente             | Qué hace                                                 | Ejemplo                                                   |
| ---------------------- | -------------------------------------------------------- | --------------------------------------------------------- |
| **Cliente (`docker`)** | Recibe tus comandos.                                     | `docker build`, `docker run`, `docker ps`                 |
| **Daemon (`dockerd`)** | Ejecuta las órdenes del cliente y gestiona los recursos. | Crea imágenes, inicia contenedores, configura redes, etc. |

En esta imagen, tomada desde la web de Docker, puedes ver claramente la distinción entre el cliente Docker Client (docker) (los comandos) y el demonio de Docker (dockerd) que es el que recibe los comandos y hace los cambios a nivel de nuestras imágenes y contenedores, que SON LA PIEZA FUNDAMENTAL Y BÁSICA EN DOCKER:


 

Instalación

La instalación de Docker es muy sencilla. Para empezar, busca en Google “Docker Install”. Entre los resultados, selecciona la página oficial de Docker (Docker Start).

Una vez cargada la página, se mostrará la opción de descarga según tu sistema operativo: macOS, Windows, Linux, macOS con Intel, etc. Simplemente descárgalo e instálalo siguiendo los pasos habituales: Next, Next, Next.

Qué estamos instalando

Al instalar Docker, se instalan dos componentes principales:

  1. Interfaz gráfica: te permite administrar contenedores y configuraciones de manera visual.
  2. CLI (Command Line Interface): la línea de comandos que te permite ejecutar comandos de Docker desde la terminal. Aunque el nombre suene técnico, es muy sencillo de usar.

Verificación de la instalación

Una vez finalizada la instalación, abre Docker y verás la interfaz gráfica cargando:

Docker UI

Para verificar la instalación de la CLI, abre cualquier terminal y escribe:

docker

Si todo está correcto, se mostrará la lista de comandos disponibles, confirmando que ambos componentes están instalados y listos para usar:

Usage:  docker [OPTIONS] COMMAND
A self-sufficient runtime for containers
Common Commands:
  run         Create and run a new container from an image
  exec        Execute a command in a running container
  ps          List containers
  build       Build an image from a Dockerfile
  bake        Build from a file
  pull        Download an image from a registry
  push        Upload an image to a registry
  images      List images
  login       Authenticate to a registry
  logout      Log out from a registry
  search      Search Docker Hub for images
  version     Show the Docker version information
  info        Display system-wide information

Comandos imprescindibles

En este listado, puedes ver algunas acciones que podemos realizar el Docker, como realizarlo mediante la CLI y su equivalente en la UI:

  • docker images: Ver imágenes: docker images (Pestaña Images)
  • docker run <ID / nombre>: Crear contenedor: docker run (Botón “Run” sobre una imagen)
  • docker ps -a: Ver contenedores: docker ps -a (Pestaña Containers)
  • docker logs <Container ID>: Ver logs: docker logs <id> (Sección “Logs”)
  • docker stop <ID / nombre> / docker rm <ID / nombre>: Detener/eliminar: docker stop/docker rm (Botones Stop / Delete)
  • docker rmi <ID / nombre>  elimina una imagen
  • docker exec -it  bash   Entrar dentro de un contenedor ACTIVO en modo interactivo (habilita el bash para lanzar comandos)
    • docker run -it <Container ID>(Ej ubuntu)  Crea el contenedor y lo deja en modo interactivo (habilita el bash para lanzar comandos)

Un comando imprescindible es el siguiente:

docker run -d -p 8080:80 nginx
  • docker run → Crea y arranca un nuevo contenedor.
  • -d → Lo ejecuta en modo “detached” (en segundo plano, no se queda "pegado" en la terminal).
  • -p 8080:80 → Expone el puerto 80 del contenedor en el puerto 8080 de tu PC.
  • nginx → Usa la imagen oficial de Nginx desde Docker Hub.

Funcionamiento básico

Del comando anterior, quitando los aspectos técnicos como el detached, o la configuración de los puertos, tenemos dos aspectos fundamentales en Docker que es la imagen y el contenedor:

  • Las imágenes descargadas (como nginx)
  • Los contenedores que crea a partir de esas imágenes

Ver imágenes

En términos funcionales pasa lo siguiente:

  • Descarga la imagen nginx desde Docker Hub (si no la tienes ya, si quieres ver las imágenes, recuerda docker images).
  • Crea un contenedor en su propio entorno interno y NO en una carpeta en particular.
  • Expone el puerto 80 del contenedor en el puerto 8080 de tu host (tu Mac), gracias a la red virtual que Docker configura.
$ docker images

>> REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
>> nginx        latest    3b7732505933   12 days ago   255MB

$ docker run 3b7732505933    
O
$ docker run nginx    

No le estás diciendo ni que se quede activo, ni qué puertos exponer, así que parece “que no pasó nada”. En realidad sí se ejecutó, pero se cerró inmediatamente o quedó corriendo sin exponer nada.

En el caso anterior, NO tiene sentido ejecutar la imagen de nginex ya que, la misma para poder levantar el proceso satifactoriamente, se debe de especificar las opciones del puerto, como hicimos antes:

$ docker run -d -p 8080:80 nginx

O mediante redis:

$ docker run -d redis

Redis si podría trabajar sin exponerle el puerto ya que, por defecto el queda escuchando en el puerto 6379 y por lo tanto, para la implementación anterior, solo sería accesible desde otros contenedores Docker, es decir, de manera interna y no desde el PC perse (fuera de Docker).

Ver Contenedores

Si quisiéramos ver los contenedores de Docker (desde la UI lógicamente la pestaña de Contenedores):

Inicialmente, vamos a ejecutarlo sin el parámetro -a. Para este ejemplo, seguramente en tus pruebas anteriores también tendrás algo. Yo tengo un par de contenedores de las pruebas que hicimos con docker run, ya que, recordemos, cuando ejecutamos docker run con parámetros o sin parámetros, siempre se crea algún contenedor. Entonces, yo tengo al menos dos con los cuales trabajar

Muestra SOLO los contenedores que están corriendo/ejecutandose:

$ docker ps

>> CONTAINER ID   IMAGE          COMMAND                  CREATED      STATUS                     PORTS     NAMES
>> e678f12698bf   nginx          "/docker-entrypoint.…"   3 days ago   Exited (0) 5 minutes ago             peaceful_mclaren
>> c880881a55f8   3b7732505933   "/docker-entrypoint.…"   3 days ago   Exited (0) 3 days ago                modest_northcutt

Fíjate que nos devuelve información sobre el contenedor o los contenedores. Aquí aparece algo interesante, que se apoya en lo comentado antes: los contenedores son la pieza de ejecución de las imágenes.

Si no tienes nada iniciado, no verás absolutamente nada. Pero, ¿qué pasa si agregamos el parámetro -a?

Muestra TODOS los contenedores:

$ docker ps -a

En ambos casos:

>> CONTAINER ID   IMAGE     COMMAND   CREATED   STATUS    PORTS     NAMES

Ahora aparecen los contenedores que tenemos. La opción -a devuelve todos los contenedores, sin importar si están iniciados o no. Por otro lado, docker ps sin parámetros muestra únicamente los contenedores que se están ejecutando.

Ver las imágenes:

$ docker images

>>> REPOSITORY   TAG       IMAGE ID       CREATED       SIZE
>>> nginx        latest    3b7732505933   12 days ago   255MB

En resumen:

  • docker ps -a → todos los contenedores (activos o inactivos).
  • docker ps → solo los contenedores activos.

¿Por qué se llama ps y no containers?

Seguramente te preguntas: “Si para las imágenes usamos docker image, ¿por qué aquí no usamos docker containers?”

Esto es una tradición de Linux: ps significa process status, es decir, el estado del proceso. Un contenedor es, esencialmente, un proceso iniciado.

El término containers es más moderno y se incorporó alrededor de 2017, pero el comando ps se mantiene como legacy, una convención histórica de Docker.

Con estos conceptos, podemos entender mejor los fundamentos de Docker: las imágenes como base y los contenedores como piezas clave para ejecutar esos procesos.

Eliminar imágenes

Al igual que siempre, podemos pasarle el ID o el nombre de la imagen que queremos eliminar.
Por ejemplo, si tenemos una imagen llamada nginx, podemos escribir:

$ docker rmi nginx 
  • rmi significa remove image

¿Qué significa RMI?

RMI significa Remove Image, tal como antes teníamos con Docker RM, pero esta vez la I indica que se trata de una imagen. Con esto, ya queda bastante claro su propósito.

Detener contenedores

Docker Stop, como pueden suponer, lo que hace es detener un contenedor. A mí me gustaría que se llamara docker container stop, que sería un poco más claro, pero bueno, así es como está implementado. En la siguiente clase veremos el comando equivalente para eliminar un contenedor.

Para detener un contenedor, simplemente debemos pasarle una de dos cosas:

  1. El identificador del contenedor (ID).
  2. El nombre del contenedor.

Fíjate que los parámetros, como el nombre, se pueden personalizar, pero eso lo veremos más adelante.

Identificando contenedores

Recordemos que anteriormente usamos docker ps, que nos devuelve información sobre los contenedores en ejecución. Allí podemos ver tanto el ID como el nombre del contenedor, lo que nos permite copiarlos y pegarlos fácilmente para usar con docker stop.

Deteniendo contenedores

Para detener un contenedor, este debe estar iniciado.

Usando el nombre:

$ docker stop nombre_del_contenedor

Resultado: se detuvo correctamente.

Usando el ID:

$ docker stop ID_del_contenedor

Eliminar contenedores

Para eliminar un contenedor es similar al de detener y el contenedor debe estar detenido, ejecutamos:

Usando el nombre:

$ docker rm nombre_del_contenedor

O usando el ID:

$ docker rm ID_del_contenedor

Logs contenedores

Permite ver los logs de un contenedor:

$ docker logs -f --tail 20 -t contenedor
  • --tail = cuántas líneas quieres ver
    • --tail 20 últimas 20 líneas
  • -t = (timestamp) agrega la hora a cada línea
  • -f = (follow) queda "escuchando", y te muestra todo lo nuevo que vaya saliendo en vivo, sin cerrar (el contenedor debe de estar ejecutándose)

Modo Interactivo bash

El siguiente comando que quiero comentar es Docker Exec, que nos permite ejecutar comandos dentro de un contenedor.

Consideraciones previas

Es importante entender que el contenedor debe estar habilitado y en ejecución. Dependiendo de la imagen, ejecutar comandos puede ser extremadamente útil:

  • Si tenemos una imagen como Ubuntu, podemos interactuar directamente con el sistema operativo.
  • Si es un Apache o Nginx, probablemente no sea posible ejecutar comandos de manera interactiva.
    • A menos que quieras modificar archivos: docker exec -it <apache-container> tail -f /var/log/apache2/error.log
  • En el caso de bases de datos, podemos ejecutar comandos SQL directamente.

En resumen, todo depende del propósito de la imagen.

El modo interactivo es simplemente habilitar una terminal en la cual ejecutar comandos; para ello, se emplean los siguiente modos:

  • -i (interactive) Deja escribir comandos en la terminal
  • -t (tty (terminal)) Te da una terminal “real”

Ejemplos:

$ docker run -it ubuntu 
$ docker exec -it a1b2c3d4e5 bash # a1b2c3d4e5 EJ de ID de un contenedor ejecutandose

Dependiendo de la imagen, puede que si tenga sentido ejecutar comandos; por ejemplo, un shell interactivo de Ubuntu:

$ docker run -it ubuntu

Un poco más elaborado:

$ docker run -d --name mi-ubuntu ubuntu sleep 1000
  • -d → lo ejecuta en segundo plano
  • --name mi-ubuntu → le da un nombre para no usar el ID
  • ubuntu → la imagen base

Entrar al contenedor con bash:

$ docker exec -it a1b2c3d4e5 bash
$ docker exec -it mi-ubuntu bash

Ahora estás dentro del contenedor.

Puedes ejecutar comandos como en cualquier Linux:

$ ls
$ pwd
$ apt update

Diferencia con Docker Run -it

  • Docker Run -it: inicia un contenedor y automáticamente abre una terminal interactiva.
  • Docker Exec -it: ingresa en un contenedor que ya está activo, permitiéndonos ejecutar comandos.

Para qué sirve sleep 1000

Cuando haces:

docker run -d --name mi-ubuntu ubuntu sleep 1000

sleep 1000 es el comando que corre dentro del contenedor.

Mantiene el contenedor activo por 1000 segundos (unos 16 minutos).

Como usamos -d (detached / en segundo plano), el contenedor necesita un proceso que siga corriendo, si no, Docker lo detendrá inmediatamente.

¿Qué pasa si no pones sleep?

docker run -d --name mi-ubuntu ubuntu

Ubuntu no tiene un proceso por defecto que se quede corriendo.

Entonces el contenedor arranca y se detiene al instante, porque no hay nada que mantener activo.

Si luego haces docker ps, no lo verás en la lista de contenedores activos, solo en docker ps -a como Exited.

✅ Por eso usamos sleep para mantenerlo vivo y poder entrar con docker exec -it <id> bash.

Comando docker stats

Vamos a conocer el comando docker stats, que sirve para ver estadísticas de uso de recursos en los contenedores (de ahí su nombre).

$ docker stats

Por ahora no tengo contenedores en ejecución, así que, si ejecuto el comando al inicio, no mostrará absolutamente nada.

Para tener algo que analizar, ejecutemos una imagen, por ejemplo, la de Nginx.

El comando sería algo así:

$ docker run -d --name my-nginx -p 8080:80 nginx

Por aquí puedes ver que se muestra:

  • La memoria total disponible en el equipo (en este caso, unos 7.5 GB).
  • El consumo del contenedor Nginx.
  • El uso de CPU asignado al contenedor.

Limitando recursos de un contenedor

Muchas veces queremos limitar la cantidad de recursos que asignamos a un contenedor.

Recordemos que el contenedor representa la parte de ejecución de una aplicación, por lo que es buena práctica establecer límites.

Por ejemplo, podemos usar las opciones --memory y --cpus al crear el contenedor:

$ docker run -d --name my-nginx --memory="50m" --cpus="0.5" nginx

Si ejecutamos nuevamente docker stats, veremos que el límite ya no es de 7 GB, sino de 50 MB.

Probando el consumo del contenedor

Por último, voy a ejecutar un comando que no tiene que ver directamente con Docker, pero que nos servirá para generar carga y ver el consumo.

$ for i in {1..1000}; do curl -s http://localhost:8080 > /dev/null; done

Construir Imágenes Personalizadas: Dockerfile

Recordemos que una imagen como un paquete único que contiene todo lo necesario para ejecutar un proceso Y PODEMOS CREAR LOS PROPIOS, en nuestro caso, serían nuestros proyecto en PHP, Node Python, etc; las imágenes personalizadas por nosotros contendrá un entorno Node/Python/PHP, el código de la misma. Para crear nuestras propias imágenes, debemos de crear un archivo especial en nuestro proyecto llamado Dockerfile.

Básicamente, estas son las piezas fundamentales sobre las cuales podemos crear nuestros contenedores para ejecutar aplicaciones. Por ahora, todo puede parecer un poco estático: instalamos imágenes de sistemas operativos o servidores, pero… ¿cómo interactuamos con ellas?

Construyendo Imágenes Personalizadas

Para crear nuestras propias imágenes usamos los famosos archivos llamados Dockerfile. Con estos archivos podemos definir cómo se construye nuestra imagen.

Recuerda: una imagen puede ser cualquier cosa: un sistema operativo, un lenguaje de programación, un servidor… o incluso un proyecto propio. En este caso, vamos a crear una imagen de nuestro proyecto en Flash.

Para construir una imagen personalizada:

  • Creamos un Dockerfile en la raíz de nuestro proyecto.
  • Definimos las reglas necesarias para que la imagen funcione correctamente.
  • Indicamos, si es necesario, qué otras imágenes son requeridas para que nuestra imagen tenga sentido. Por ejemplo, nuestro proyecto en Flash necesita la imagen de Python para funcionar.

Dockerfile

Un Dockerfile es simplemente un archivo de texto que contiene todas las instrucciones necesarias para construir una imagen de Docker. la estructura que tendrá, depende del proyecto y lo que quieras hacer, pero, usualmente cuenta con estos pasos fundamentales:

El Dockerfile es la semilla, la pieza fundamental que nos permite crear nuestras propias imágenes. Por eso, encontrarás muchísimas páginas y ejemplos, porque su funcionamiento es sencillo de comprender: basta con partir de un proyecto base, que puede ser prácticamente cualquier cosa.

1. Crea el Archivo de Instrucciones

En la raíz de tu proyecto, crea un archivo llamado exactamente Dockerfile (con la D mayúscula y sin ninguna extensión).

  • Este archivo se crea en la raíz del proyecto, sea PHP, Laravel, CodeIgniter, Flash, FastAPI, Django, Node… lo que sea.
  • A partir de allí, definimos un conjunto de reglas. La estructura cambia dependiendo del proyecto, pero en la mayoría de los casos sigue un patrón similar.

2. Define la Imagen Base (FROM)

Especifica el punto de partida de tu imagen usando la instrucción FROM. Esto determina el sistema operativo y el entorno inicial. Por ejemplo, para un proyecto Node.js podrías usar:

FROM node:lts-alpine
  • En nuestro ejemplo, usamos Python para Flash, o Node para proyectos en Node.
  • Si tu proyecto necesita, por ejemplo, SQL, también puedes indicar la imagen de MySQL.

3. Establece el Directorio de Trabajo (WORKDIR)

Con la directiva WORKDIR, defines la carpeta dentro del contenedor donde se ejecutarán todos los comandos posteriores y donde se copiarán tus archivos. Por ejemplo:

WORKDIR /app

Este es el lugar donde se instalarán las dependencias, se copiará el proyecto, se ejecutarán comandos y se expondrán puertos.

Por convención, se usa /app, pero puedes nombrarlo como quieras.

4. Copia tus Archivos Locales (COPY)

Utiliza la instrucción COPY para transferir los archivos de tu máquina local al interior de la imagen. Para llevar todos los contenidos del directorio actual (el contexto) al directorio de trabajo del contenedor, harías:

COPY . .
  • Generalmente copiamos todo el contenido de nuestro proyecto al directorio de trabajo.

5. Ejecuta Comandos de Configuración (RUN)

La instrucción RUN ejecuta comandos durante el proceso de construcción de la imagen, ideal para instalar dependencias o configurar el entorno. Por ejemplo, para instalar dependencias de Node.js:

RUN yarn install --production

6. Especifica el Comando de Inicio (CMD)

CMD indica cuál es el comando predeterminado que se ejecutará cuando se inicie un contenedor a partir de esta imagen. Para lanzar una aplicación Node.js:

CMD ["node", "src/index.js"]
  • Para proyectos de servidor, esto suele incluir instalar dependencias y arrancar la aplicación.
  • En Python, por ejemplo, python app.py inicia la aplicación.
  • En Node, usaríamos npm install y luego npm start.

7. Informa sobre los Puertos (EXPOSE)

Si tu aplicación está configurada para escuchar en un puerto específico (ej. 3000), usa EXPOSE. Esto le notifica a Docker qué puertos estarán abiertos para la comunicación en tiempo de ejecución.

EXPOSE 3000

Ejemplo Completo de Dockerfile

Combinando todas estas directivas, un Dockerfile para una aplicación Node.js se vería así:

Dockerfile

# syntax=docker/dockerfile:1
FROM node:lts-alpine
WORKDIR /app
COPY . .
RUN yarn install --production
CMD ["node", "src/index.js"]
EXPOSE 3000

En resumen, el Dockerfile define cómo se construye tu imagen, desde la base hasta los comandos de ejecución y puertos. Cada proyecto requiere adaptaciones específicas, pero la estructura general sigue los pasos que hemos descrito:

  • Imagen base (FROM)
  • Directorio de trabajo (WORKDIR)
  • Copiar archivos del proyecto (COPY)
  • Exponer puertos (EXPOSE)
  • Ejecutar comandos (RUN o CMD)
  • En la siguiente clase veremos cómo elaborar este Dockerfile paso a paso para nuestro proyecto y cómo construir la imagen lista para ejecutarla en un contenedor.

Comando docker build

El comando docker build construye una imagen de Docker (nuestra imagen personalizado) a partir de las instrucciones contenidas en un Dockerfile y un contexto especificado.

$ docker build -t app-flask-chat-01 .
  • -t, Se utiliza la bandera -t (tag), la imagen resultante recibe un nombre y una etiqueta
  • el punto al final (.) significa copiar TODO el proyecto desde el directorio actual que es el que tiene:

App en Flask

Pensando en la estructura anterior, veamos como podemos crear un Dockerfile, para crear una imagen personalizada de un proyecto en Flask:

https://github.com/libredesarrollo/01-jan-chat

Creamos el Dockerfile:

# Usa una imagen base de Python oficial, por ejemplo la versión 3.12 pero mas pequena que la completa que es simplemente python
FROM python:3.12-slim

# Establece el directorio de trabajo dentro del contenedor, puede ser cualquier otro pero, por convension es app
WORKDIR /app

# Copia el archivo de requisitos e instala las dependencias
# --no-cache-dir desactivar el almacenamiento en caché de los paquetes descargados e instalados.
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copia TODO el contenido del proyecto (incluyendo app.py) al directorio de trabajo
COPY . .

# Expone el puerto en el que corre Flask (por defecto 5050)
# if __name__ == '__main__':
#    app.run(debug=True, host="0.0.0.0", port=5050)
EXPOSE 5050

# Comando para correr la aplicación
# Usa `python app.py` si modificaste app.run(host='0.0.0.0')
# o usa un comando más robusto si instalaste Gunicorn:
# CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]
CMD ["python", "app.py"]

Paso a Paso: Construyendo el Dockerfile

Ahora sí, comenzamos con el Dockerfile.
Recuerda que puedes inspirarte buscando imágenes base en Docker Hub, la biblioteca oficial.
Como nuestro proyecto está en Python, partiremos de una imagen de Python.

1. Seleccionar la imagen base

Usualmente, partimos de otra imagen base. En este caso, elegimos una imagen de Python, pero para que sea más ligera, usaremos la versión Python Slim:

FROM python:3.12-slim

Esta versión es más liviana y eficiente, ya que contiene solo lo esencial para ejecutar Python.

2. Definir el directorio de trabajo

Por convención, usamos /app como directorio principal:

WORKDIR /app

Ahí se copiará todo el proyecto, y también será donde se ejecuten los comandos definidos más adelante.

3. Copiar dependencias e instalarlas

Lo siguiente es copiar el archivo de dependencias y luego instalarlas:

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

Esto garantiza que el entorno dentro del contenedor tenga todo lo necesario.

El flag --no-cache-dir evita que se guarde caché, manteniendo la imagen más ligera.

4. Copiar el resto del proyecto

Ahora copiamos el resto de los archivos del proyecto:

COPY . .

Esto incluye el código, plantillas, módulos, etc.

5. Exponer el puerto y ejecutar el proyecto

Definimos el puerto (en mi caso, 5050) y cómo se ejecutará la aplicación:

EXPOSE 5050
CMD ["python", "app.py"]

Construir y Ejecutar la Imagen

Con el Dockerfile listo, el siguiente paso es construir la imagen.

Tenemos la estructura para crear la imagen, falta construir la imagen personalizado de nuestra aplicación, para ello:

$ docker build -t app-flask-chat-01 .
  • -t, Se utiliza la bandera -t (tag), la imagen resultante recibe un nombre y una etiqueta
  • el punto al final (.) significa copiar TODO el proyecto desde el directorio actual que es el que tiene:
    __pycache__             chat_routes.py          llm_service.py          templates
     app.py                  Dockerfile              requirements.txt        test.py

Este comando crea la imagen, leyendo las instrucciones del Dockerfile.

Puedes verificarla en Docker Desktop o con:

$ docker images

Ejecutar el Contenedor

Ahí verás tu imagen recién generada.

Y para crear el contenedor de nuestra imagen personalizada:

$ docker run -d -p 5050:5050 --name flask-app-contenedor app-flask-chat-01

En cualquier comento, puedes ver el log del contenedor:

$ docker logs flask-app-contenedor 

Ideal por si el proyecto tiene errores, y deberías de ver algo como esto:

 * Running on all addresses (0.0.0.0)
 * Running on http://127.0.0.1:5050
 * Running on http://172.17.0.2:5050
Press CTRL+C to quit
 * Restarting with stat
 * Debugger is active!
 * Debugger PIN: 148-371-654

Con Gunicorn

Si queremos desplegar nuestra aplicación en producción, esto se convierte en un problema.
El hecho de que Flask indique que es un servidor de desarrollo significa que no está optimizado para entornos productivos, por lo que debemos utilizar un servidor apropiado para este tipo de despliegues.

En el caso de las aplicaciones desarrolladas con Flask, necesitamos emplear un servidor WSGI (Web Server Gateway Interface) orientado a producción.
Y justamente, uno de los más usados para este propósito es Uvicorn.

Sustituyendo el servidor de desarrollo por Uvicorn

Hasta ahora hemos estado utilizando el servidor de desarrollo, pero queremos hacerlo bien y simular un entorno de producción.

Para ello, comenzamos instalando el paquete de Uvicorn en nuestro proyecto Flask.

Agregamos requirements.txt:

$ pip install gunicorn

Y

$ pip freeze > requirements.txt

El Dockerfile:

CMD ["gunicorn", "--workers", "4", "--bind", "0.0.0.0:5050", "app:app"]
gunicorn -b 0.0.0.0:$PORT run:app

Explicando los parámetros:

  • --workers 4: indica el número de workers (procesos) que manejarán las peticiones de los usuarios.
    Puedes ajustar este valor según los recursos del servidor.
  • --host 0.0.0.0: define la dirección donde se ejecutará la aplicación dentro del contenedor (el equivalente a localhost).
  • --port 5050: especifica el puerto del proyecto.

El resto del archivo Dockerfile permanece igual.

Luego reconstruyes la imagen Docker y levantamos el servidor, tal cual hicimos antes:

$ docker build -t gapp-flask-chat-01 .
$ docker run -d -p 5050:5050 --name gflask-app-contenedor gapp-flask-chat-01

App en Django

Pensando en la estructura anterior, veamos como podemos crear un Dockerfile, para crear una imagen personalizada de un proyecto en Flask:

https://github.com/libredesarrollo/coruse-book-django-store

Creamos el Dockerfile:

# Imagen base
FROM python:3.12-slim

# Evitar que Python guarde pyc y buffer stdout
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1

# Carpeta de trabajo
WORKDIR /app

# Copiar dependencias primero
COPY requirements.txt .

# Instalar dependencias
RUN pip install --no-cache-dir -r requirements.txt

# Copiar todo el código
COPY . .

# Exponer puerto de Django
EXPOSE 8000

# Comando de inicio (Gunicorn para producción)
CMD ["gunicorn", "app.wsgi:application", "--bind", "0.0.0.0:8000"]

En donde app, es el nombre del proyecto en Django, en caso del proyecto anterior quedarías así:

CMD ["gunicorn", "mystore.wsgi:application", "--bind", "0.0.0.0:8000"]

Es donde se ubican los archivos de:
mystore/wsgi.py
Y
mystore/asgi.py

De tu proyecto.

Definimos un par de variables de entorno adicionales:

ENV PYTHONDONTWRITEBYTECODE=1

Indica a Python que no genere archivos .pyc (bytecode compilado).

Normalmente, Python crea archivos .pyc junto a tus .py para acelerar futuras ejecuciones.

En Docker eso no tiene sentido, porque cada vez que reconstruyes la imagen se vuelve a generar todo.

ENV PYTHONUNBUFFERED=1

Hace que Python escriba directamente a la salida estándar (stdout), sin guardar en buffer.

Sin esto, los logs pueden “retrasarse” o no verse en tiempo real dentro de docker logs.

Con esto, los print() y los logs de Django o Flask aparecen inmediatamente en el terminal o en docker logs -f.

Estas mismas variables de entorno las puedes emplear también en tus otros proyectos en Python, inclusive en el de Flask que vimos anteriormente para optimizar las imágenes.

Finalmente, si quieres emplear el servidor local:

CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]

Y al igual que antes, generamos las imagen y el contenedor:

$ docker build -t app-django-01 .
$ docker run -d -p 8000:8000 --name django-app-contenedor app-django-01

Conectando Docker con la Máquina Local: Explicación Práctica: host.docker.internal

Vamos a conocer un mecanismo que tenemos en Docker que nos permite, en pocas palabras, conectarnos desde un contenedor hacia nuestra máquina local. Es decir, desde un entorno aislado (el contenedor) podemos acceder a servicios que se ejecutan directamente en nuestro host.

El ejercicio es simple, tengo una app en Django con los CORS configurados ejecutándose en mi localhost (nada de Docker) y quiero conectar una app en Flask ejecutándose en un contenedor; para poder conectarse a nuestro host (nuestra PC) desde un contenedor en Mac y Windows tenemos el siguiente DNS:

Intentando con host.docker.internal

Existe un mecanismo especial: host.docker.internal, un DNS interno que permite a los contenedores comunicarse con el host.
Así que regeneramos la imagen agregando la siguiente línea al momento de generar la imagen y el contenedor:

--add-host=host.docker.internal:host-gateway
docker build -t app-flask-chat-01 .
docker run -d -p 5050:5050 --add-host=host.docker.internal:host-gateway app-flask-chat-01

Antes de esto, desde la app en Flask, nos conectamos a la app Django mediante:

# response = requests.get('http://127.0.0.1:8000/store/product')
response = requests.get('http://host.docker.internal:8000/store/product')

Con esto, ahora podremos conectarnos de un contenedor a nuestro host de una manera sencilla

Esto habilita la comunicación con el host desde dentro del contenedor.
Tras ejecutar nuevamente, vemos que el error cambia: ahora obtenemos un 403 (Forbidden) en lugar del 500 anterior.

Volumenes

La siguiente evolución lógica en este curso es conocer los volúmenes. Ya no solo trabajamos con imágenes y contenedores, sino también con una nueva herramienta: los volúmenes.

¿Qué son los volúmenes?

Un volumen es una forma de persistir datos fuera del contenedor. Hasta ahora, cuando eliminábamos un contenedor, se perdía toda la información que contenía. Con los volúmenes, esos datos se mantienen independientemente del ciclo de vida del contenedor.

  • Básicamente, funciona como un pendrive o disco duro externo: los datos se almacenan en un espacio independiente, fuera del contenedor.
  • Esto nos permite desarrollar sin tener que generar nuevas imágenes constantemente para mantener información.

Donde partimos

De momento, hemos visto algunos conceptos básicos sobre cómo trabajar con Docker. Lo principal ha sido aprender sobre imágenes, contenedores y cómo podemos manipularlos: el típico CRUD de crear, eliminar, listarlos, etcétera.

También vimos cómo tomar una imagen para ejecutarla en un contenedor, configurarla mediante los puertos, y otras opciones. Básicamente, eso es lo que hemos hecho hasta este punto.

Aparte de eso, revisamos otra sección, ya que con solo esos conocimientos no es suficiente. Hasta ahora estábamos “jugando”: creamos una imagen, levantamos un contenedor, pero realmente no estábamos utilizando nada de manera práctica. Por ejemplo, levantamos Nginx, pero no había nada ejecutándose allí.

Problemas al trabajar con Docker en desarrollo

Entonces surge la pregunta: ¿cómo podemos, como programadores, utilizar Docker para nuestras propias soluciones?

Ahí es donde entra este curso, que busca enseñarte a levantar tus proyectos en Docker de manera práctica. Vimos un preámbulo en el que, mediante el Dockerfile, podemos tener nuestro propio proyecto. Revisamos ejemplos como una aplicación en Flask y otra en Django, agregando ciertas reglas para levantar la aplicación en Docker.

Hasta aquí todo perfecto, pero si has experimentado un poco con Docker, habrás notado algunos problemas:

  • Docker puede usarse tanto en producción como en desarrollo.
  • En producción, no pasa tanto: simplemente levantamos un contenedor sobre un servidor ya configurado (por ejemplo, Unicorn).
  • En desarrollo, es diferente: cada cambio que hagamos en la aplicación requiere generar nuevamente la imagen (docker build) y levantar el contenedor (docker run).

Esto hace que el desarrollo sea poco funcional: cada cambio implica un proceso laborioso, eliminar contenedores anteriores, recrearlos, verificar errores, y volver a intentar.

Creación y uso de volúmenes

Vamos a probarlo paso a paso:

Abrimos la terminal y verificamos que Docker está ejecutándose.

Revisamos contenedores, imágenes y volúmenes. Todo forma parte del ecosistema de Docker.

Creamos un volumen usando:

$ docker volume create my_volume

Ahora tenemos un volumen vacío, como un pendrive recién creado. Podemos listar los volúmenes con:

$ docker volume ls

Montando el volumen en un contenedor

Para que tenga sentido, un volumen debe usarse con un contenedor. Por ejemplo:

$ docker run -it --name c1 -v my_volume:/data ubuntu
  • -v my_volume:/data indica que el volumen my_volume se montará en la carpeta /data dentro del contenedor.

Esto crea un espacio persistente donde se pueden guardar archivos, datos de la aplicación, logs, etc.

Dentro del contenedor:

$ cd /data
$ ls
$ echo "Hola desde contenedor" > archivo.txt
$ cat archivo.txt

Si eliminamos el contenedor y levantamos uno nuevo:

$ docker run -it -v mi_volumen:/data ubuntu bash

Veremos que la carpeta /data sigue ahí con los mismos archivos, porque los datos están persistiendo en el volumen, no en el contenedor.

Docker Compose: ¿Qué demonios es esto y qué pasó con los volúmenes?

¿Qué era lo que estábamos hablando anteriormente? Recuerda que el objetivo de este bloque, de esta sección, es poder utilizar Docker, pero no como una herramienta de deployment en producción, sino para trabajar perfectamente con nuestras aplicaciones durante el desarrollo.

La idea es que, por ejemplo, cuando tengamos cambios en el código, estos se puedan reflejar automáticamente en el contenedor y verlos por pantalla. Con lo que ya hemos visto de imágenes, contenedores y demás, no es suficiente; eso son solo las herramientas básicas, pero necesitamos más mecanismos.

Recordatorio: los volúmenes

Anteriormente vimos una pieza clave: los volúmenes. En resumidas cuentas —y lo viste en el video anterior— los volúmenes permiten persistir datos, lo cual es fundamental para lo que estamos mencionando. Pero necesitamos aún otra pieza clave: Docker Compose, que internamente ampliará el uso de los volúmenes para poder inyectar los cambios que estamos comentando.

¿Qué es Docker Compose?

Docker Compose es una herramienta que viene instalada junto con Docker (igual que pasa con pip cuando instalas Python o npm cuando instalas Node).

Básicamente, Compose permite emplear múltiples contenedores de Docker dentro de una sola aplicación.
Por ejemplo, aquí tenemos una aplicación Flask —la que vamos a construir ahora— donde utilizamos un contenedor para Flask (es decir, una imagen de Python) y también necesitamos Redis. Por lo tanto, son dos contenedores que deben comunicarse entre sí.

¿Y cómo los comunicamos?

Para eso está Compose: permite hacer esto de forma muy sencilla mediante un archivo de configuración.

Compose nos deja agregar todas las dependencias que necesite nuestro proyecto: Redis, MySQL, PostgreSQL, etc. Todo lo que necesites como contenedor.

Resumen de la definición

Docker Compose es una herramienta que permite definir y ejecutar aplicaciones con múltiples contenedores.
Ejemplo: un contenedor para Flask y otro para Redis.

Uso básico de Docker Compose

Compose tiene sus comandos típicos, lo que sería un pequeño “CRUD”: comandos para crear, parar, ver logs y listar.

En la documentación oficial puedes ver los comandos principales:

  • compose up → levantar la aplicación
  • compose down → detener y desmontar
  • logs → ver registros
  • ps → ver procesos/servicios activos

Todo lo típico.

En términos simples: Compose es un mecanismo para que desde un solo proyecto podamos utilizar múltiples contenedores, que al final son las dependencias de nuestra aplicación.

El proyecto de ejemplo con Flask y Redis

Este es el proyecto que vamos a montar como ejercicio. Es un ejemplo muy sencillo: una aplicación Flask que se conecta a Redis.
En el repositorio encontrarás:

  • Dependencias
  • Dockerfile
  • Archivo docker-compose.yml
  • Código fuente
  • Configuraciones adicionales

El proyecto consiste en un contador almacenado en Redis, de manera que cada vez que recargamos la página, Redis incrementa un valor y Flask lo muestra. Redis se utiliza aquí como caché, aunque también sirve para almacenar datos temporalmente, manejar sesiones, colas, etc.

El retorno de Flask simplemente muestra por pantalla el valor incrementado.

Dockerfile y consideraciones

En el Dockerfile tenemos lo mismo de siempre:

  • Imagen base de Python (la versión "alpine", más ligera).
  • Directorio de trabajo.
  • Instalación de dependencias (incluyendo las de Redis).
  • Copia del proyecto completo al contenedor.
  • Comando de ejecución.

Algunas variantes del proyecto usan flask run, que es lo recomendado para desarrollo, pero por ahora usaremos la forma más básica.

El archivo docker-compose.yml

Aquí viene la parte importante. Para usar Compose necesitamos este archivo específico. Puede llamarse docker-compose.yml o dockercompose.yml (con o sin guion bajo).

Servicios

En Compose, cada bloque de configuración representa un servicio, que básicamente es un contenedor.

Ejemplo de la estructura básica:

  • web: servicio principal (Flask).
  • redis: servicio para Redis.

En web se define:

  • build: . → que construya la imagen a partir del Dockerfile.
  • Puertos expuestos.
  • Dependencias.

En redis simplemente definimos la imagen que vamos a usar (en este caso, la versión alpine de Redis).

Agregar otros servicios

Si quisiéramos usar MySQL, simplemente agregamos:

mysql:
   image: mysql
   ...

Compose permite agregar tantos servicios como necesite nuestro proyecto.

Levantando la aplicación

Si no tenemos contenedores, imágenes o volúmenes previos, Compose los va a generar.
El comando principal:

$ docker compose up

Este comando:

  • Lee el archivo docker-compose.yml.
  • Construye las imágenes necesarias (si no existen).
  • Crea los contenedores.
  • Los levanta en el orden correcto.

El archivo Compose se convierte en la pieza clave del proyecto.
La aplicación Flask se levanta correctamente y ya podemos acceder a ella. Cada recarga incrementa el contador almacenado en Redis.

Problema pendiente: sincronización de cambios

Por ahora hacemos cambios en el proyecto y no se sincronizan con el contenedor.
Esto lo resolvemos en la siguiente clase con la parte de sincronización (volúmenes y docker watch).

Consideraciones importantes

  1. Primero configura el Dockerfile.
    Si algo falla allí, Compose tampoco funcionará.
  2. La base es siempre la aplicación.
    Si la app no levanta, el contenedor construido desde el Dockerfile tampoco lo hará, y Compose mucho menos.
  3. Compose es una capa superior.
    Si algo falla abajo (app → Dockerfile → Compose), debes resolver el problema desde la base hacia arriba.

Acepto recibir anuncios de interes sobre este Blog.

Guía básica para iniciar con Docker, empezar a crear tus primeros contenedores, incluso si nunca has usado Docker antes. Verás cómo funciona internamente y empezar a usar Docker en tus proyectos.

Algunas recomendaciones:

Benjamin Huizar Barajas

Laravel Legacy - Ya había tomado este curso pero era cuando estaba la versión 7 u 8. Ahora con la ac...

Andrés Rolán Torres

Laravel Legacy - Cumple de sobras con su propósito. Se nota el grandísimo esfuerzo puesto en este cu...

Cristian Semeria Cortes

Laravel Legacy - El curso la verdad esta muy bueno, por error compre este cuando ya estaba la versi...

Bryan Montes

Laravel Legacy - Hasta el momento el profesor es muy claro en cuanto al proceso de enseñanza y se pu...

José Nephtali Frías Cortés

Fllask 3 - Hasta el momento, están muy claras las expectativas del curso

| 👤 Andrés Cruz

Por aquí tienes el listado completo de clases que vamos a cubrir en el libro y curso:

Primeros pasos

Imágenes

Volumen